diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 038c0bdc77..2b4e96be6a 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -474,7 +474,7 @@ earlier version wrote wider is narrowed when abcd next writes it a person's reviewed commit, whose note wins an id both hold memory/ curated cross-project memory pages (a later phase; - nothing resolves it today) + the prompt router's recall reads them, read only) sources/ the local sources corpus the library verb maintains and /abcd:library reads. Created only by its explicit init: absent means every other corpus verb and the diff --git a/.abcd/development/brief/04-surfaces/07-library.md b/.abcd/development/brief/04-surfaces/07-library.md index afd9295aaf..873f0488ff 100644 --- a/.abcd/development/brief/04-surfaces/07-library.md +++ b/.abcd/development/brief/04-surfaces/07-library.md @@ -412,7 +412,7 @@ by the bare render; orphan and stale-claim audits are deferred. Not built: a curator role on the `principle-distiller` agent (the agent and the disembark sub-verb it feeds ship; this role does not), ingest and ask run -reports, user-scope memory outside the repo, and the automatic extraction of a +reports, any verb that writes user-scope memory outside the repo (the prompt recall only reads `~/.abcd.noindex/memory/`), and the automatic extraction of a spec's modification grammar into the store at spec completion. ### Composition with adjacent surfaces diff --git a/.abcd/development/brief/04-surfaces/37-memory.md b/.abcd/development/brief/04-surfaces/37-memory.md index 321f03b252..7542d5776e 100644 --- a/.abcd/development/brief/04-surfaces/37-memory.md +++ b/.abcd/development/brief/04-surfaces/37-memory.md @@ -4,7 +4,7 @@ session does not learn them again (itd-2610091918433290, spc-2610111627213138). It is an agents' command: its page is hidden from a person's menu, and the binary's help lists the verb with the agents. Recall of a note through the prompt hook is the same -spec's later step; this chapter covers the store and the verb. +spec's steps 2 to 4, shipped: a matching note or page comes back under `## MEMORY`, ranked by BM25F, and the rules verb can show why for a given prompt; this chapter covers the store and the verb. ## Sub-verbs diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 65917b020a..09a53bd61e 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -295,6 +295,12 @@ the checkout from the root the loader resolves for the working directory. - **`abcd rules `** renders that one domain, matched case-insensitively, whatever its state and whatever the kill switch, so a dormant domain stays inspectable. An unknown name exits 2. +- **`abcd rules --prompt ""`** shows what that one prompt would bring back + to a fresh session, running the prompt router's own path: the domains its + words match, then the memory notes and pages it would recall, ranked together + with each one's rank, score and the terms that scored it, and the form each + would take (`full`, `over-budget` or `too-large`), with any layer or page that + could not be read. It writes nothing: no session state is loaded or saved. - **Diagnostics go to stderr, never stdout**, so the JSON form stays one document: a refused root, a domain skipped for carrying no rules, and each bundled entry of a guardrail domain an override's list leaves out. diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index 5e605890c0..245d8d6ba8 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -775,7 +775,7 @@ with `--everywhere`; [the prompt router](#the-prompt-routers-output) recalls the beside the repository's `.abcd/memory.json`, whose notes a person commits in a reviewed change and the verb never writes, as one set in which a project note shadows a personal note with the same id), curated cross-project memory pages -under `memory/` (a later phase: nothing resolves it today), and the `sources/` corpus +under `memory/` (the prompt router's recall reads them, read only), and the `sources/` corpus `/abcd:library` reads and writes (abcd never creates that one unasked: only its explicit init does, and every other corpus verb and the page say so and stop when it is absent). It also holds the caller-controlled declarations: the owned diff --git a/.abcd/development/intents/planned/itd-2610091918433290-an-agent-working-in-an-abcd-managed.md b/.abcd/development/intents/planned/itd-2610091918433290-an-agent-working-in-an-abcd-managed.md index 18483d1c8e..a4bb758dad 100644 --- a/.abcd/development/intents/planned/itd-2610091918433290-an-agent-working-in-an-abcd-managed.md +++ b/.abcd/development/intents/planned/itd-2610091918433290-an-agent-working-in-an-abcd-managed.md @@ -47,7 +47,7 @@ We expect fewer repeated mistakes, because a note comes back exactly when a prom ## Open Questions -- None for the product thinker. The four questions first left here are settled under Decisions (2026-10-11); the technical questions the plan leaves open are in the bundle's spec. +_None for the product thinker. The four questions first left here are settled under Decisions (2026-10-11); the technical questions the plan leaves open are in the bundle's spec._ ## Audit Notes diff --git a/.abcd/development/specs/open/spc-2610111627213138-memory-recall.md b/.abcd/development/specs/open/spc-2610111627213138-memory-recall.md index d0257462f4..a62994841f 100644 --- a/.abcd/development/specs/open/spc-2610111627213138-memory-recall.md +++ b/.abcd/development/specs/open/spc-2610111627213138-memory-recall.md @@ -575,15 +575,19 @@ Steps 7 and 8 change no code. - the memory store's redactor (`storeRedactor` in internal/core/memory) is exported for the gate rather than a second one written (one-canonical-primitive) - the store and its verb land together, so the store has a caller from its first commit and no unwired code reaches main; a note written here is listed and removed at once, and is recalled from step 2 - a live trial in a host session checks that the host substitutes its session id into the command page, as the verb split's trial checked hiding; the capture goes under this spec's `## Live captures`. If the first host offers no substitution, the page leaves `--session` out and every note on that host is recorded as session unknown, which is the ruled fallback, not a reopened question + - landed: c8d1ab0df, 9ae13776b 2. One recall: notes come back through the prompt hook, and `rules --prompt` shows what a prompt brings back - packages: internal/core/recall (new: items, matching, rendering, the `recalled` ledger keyed on content; candidates that do not all fit the budget are taken in id order until step 4), internal/core/rules (exported prompt-index and term-match function; SessionState gains `recalled`; the reset clears it), internal/surface/cli (the prompt router calls recall after rules.Inject; the `--json` envelope gains `recalled`; `rules --prompt` listing matched domains with their hitting words, and matched notes), commands/ahoy.md (documents `rules --prompt`), .abcd/development/release/surface.json and docs/reference/cli/commands.md (the new flag) - tests: TestANoteComesBackOnAPromptAboutTests over two simulated sessions (N1), watched fail first; TestNoteRecallUsesTheRuleTermMatch (stems and phrases hit as a domain's recall words do); TestANoteInTheProjectsFileIsRecalled (N5); TestNoteTiedToAnotherRootIsNotRecalled and TestEverywhereNoteIsRecalledInAnyRepository; TestRecalledLedgerSurvivesTheRulesPrune; TestResetClearsBothLedgers; TestKillSwitchSilencesRecall; TestARefusedMachineLayerStillInjectsRules; TestMemoryTakesOnlyWhatTheRulesLeaveOfTheBudget; TestCandidatesThatFitAreTakenInIDOrder (replaced by step 4's ranking tests); TestRulesPromptShowsMatchedDomainsAndNotes and TestRulesPromptWritesNothing, watched fail first; TestRulesPromptRefusesAPositionalDomain; the existing inject and hook tests pass unchanged when no note matches (a healthy no-match still renders zero tokens) + - landed: e6d2a7df7, 59b69f398 3. Pages join the same recall, from both scopes - packages: internal/core/memory (an exported read of each page's filename, index line, body and `recall:` list, through the existing store handle; a read-only open of `~/.abcd.noindex/memory/`), internal/core/recall (page items, narrower scope wins; pages join the id order as `page:`, after notes, until step 4), internal/surface/cli (the router and `rules --prompt` hand pages to recall) - tests: TestAPageDeclaringAuthComesBackAndNoOther (P1); TestANoteAndAPageComeBackTogether (N2); TestRepeatedPromptInjectsMemoryOnce (P3); TestWorkspacePageSuppressesTheMachinePage (P4); TestRecallOpensOnlyTheDeclaredPlaces with a host memory directory of matching text under the test home (P5), each watched fail first; TestPageWithoutRecallIsNeverACandidate; TestRulesPromptShowsMatchedPages; the memory store's symlink and guarded-read tests pass against the new read + - landed: 57b55ed1f 4. The relevance ranking and its reasons - packages: internal/core/recall (ranking.go: BM25F as designed, ties; the ranked order replaces the id order in deciding which items fit the budget), internal/surface/cli (`rules --prompt` prints each candidate's score and each matched term's field and contribution) - tests: TestRareMatchingWordOutranksACommonOne, the case a raw count gets wrong, watched fail against a count-based order; TestRecallFieldOutweighsBody; TestTiesBreakOnScopeThenRecencyThenID; TestRankingIsDeterministicAcrossRuns; TestRankingDecidesWhichItemsFitTheBudget, watched fail against step 2's id order; TestRulesPromptShowsEachTermsContribution; golden scores for a small fixed corpus so a change to the formula is a visible diff + - landed: f752318ff 5. How full the session is: the status line's fill file, the transcript's tail, and unknown - packages: internal/core/statusline (payload.go parses the payload's `session_id`, never rendered or echoed), internal/surface/cli (statusline.go writes the fill file through the one fill writer, only on the path that renders abcd's own row; its help text and the doc comment above newStatuslineCommand say it writes only that file and where; cli.go's comment on `hookInput.TranscriptPath` names the prompt router as a second reader; the prompt router reads the fill and reports it in the `--json` envelope's `fill`; the session-start reset creates `~/.abcd.noindex/fill/` and the resets delete the session's file), internal/core/rules (the fill writer and reader, the file name under `~/.abcd.noindex/fill/`, the per-model window table, the stale sweep extended to the fill directory), internal/core/history (the token-count shape exported for the fill reader, no behaviour change) - tests: TestStatuslineRecordsTheFillForItsSession, watched fail first; TestStatuslineWritesOnlyTheFillFile (the narrowed TestStatuslineWritesNothing: one changed entry, the session's fill file under `~/.abcd.noindex/fill/` holding only `used_percentage` and `at`, and every other case, the pass-through path among them, unchanged); the narrowed TestStatusVerbReachesNoWritePrimitive (exactly one fill-writer call from one function in statusline.go; any other write primitive or second call site refused by name), with a fixture of a second write watched fail; TestStatuslineCreatesNoDirectory; TestStatuslineWritesNothingWithoutASessionID; TestStatuslineRowRendersWhenTheFillWriteFails; TestFillFileIsRefusedBehindASymlinkedHome; TestFillPrefersTheStatusLineFile; TestFillFromTheTranscriptTail, watched fail first; TestTranscriptReadIsBoundedToTheTail; TestRefusedTranscriptYieldsUnknownFill (symlink, foreign owner); TestUnnamedModelYieldsUnknownFill; TestAmbiguousWindowTakesTheSmaller; TestResetDeletesTheFillFile; TestResetCreatesTheFillDirectoryAndRefusesASymlinkedHome; TestAbsentFillDirectoryIsASilentSkip; TestStaleSweepReachesTheFillDirectory; TestHookEnvelopeNamesTheFillAndItsSource; TestRecallOpensOnlyTheDeclaredPlaces extended to the fill reader's two (P5); the statusline help snapshot updated @@ -748,6 +752,90 @@ each open to the facilitator's veto at review: bounds what a note can carry or how it is named without a ruling of its own, and is open to the facilitator's veto at review like the choices above. +### Settled here + +Choices an agent made while building steps 3 and 4 where this spec is silent, +each the narrowest reading the agent found that meets the criteria, and each +an agent's choice for review, open to the facilitator's veto like the design +choices above. + +- **Which pages the recall reads** (2026-10-12, step 3, agent's choice for + review). In each scope, the pages `index.md` lists, the page files at the + store's top level, whose names are `__.md`. A page's + title is its index line's summary, derived from the page the way the index + derives it, so the recall reads no `index.md` and opens only the four + places it declares; its body is the rest of the page after the line the + title comes from, so the title is counted and sent once. Grounds: P4 compares pages by that name, and the name's + characters keep a page's key (`page:`) safe to print in the + hook's stderr line and envelope. +- **A workspace page shadows the machine page of the same name, whether or + not it carries a recall list, and when the read refuses it** (2026-10-12, + step 3, agent's choice for review). Grounds: the narrower scope's page is the page of that name, as a + repository layer's field replaces the machine's and a note in the project's + file suppresses the machine's copy of its id; it also keeps one + `page:` key per name in the ledger and the envelope. +- **A machine page is read with the guards the machine `memory.json` gets** + (2026-10-12, step 3, agent's choice for review): the directory through + `fsutil.OpenHomeDeclarationScope`, the home scope's walk with the + directory judgement `fsutil.ReadHomeDeclaration` makes (a symlinked + directory, or one another account can change, refused), and each page a + regular file this account owns that no one else can write + (`fsutil.ReadHomeDeclarationDenyingIn`). A page failing + them is named on stderr and brings nothing back, while the scope's other + pages still come back. Grounds: a machine page injects text into every + session as a machine note does; the spec names only the directory's open, + and reading it alone would trust a page another account could write while + the notes file beside it is refused for the same thing. +- **A page store or a page that cannot be read is named** (2026-10-12, + step 3, agent's choice for review): on the hook's stderr with "recalling + nothing from it", and in `rules --prompt`'s `unreadable` list, which gains a + `kind` (`note` or `page`); a refused `memory.json` keeps step 2's words. +- **What a page's recency is** (2026-10-12, step 4, agent's choice for + review). The tie-break's "page modification" is the latest `ingested_at` + the page's source block records, a date, and a page that records none + sorts as the oldest. Grounds: a checkout stamps each file with the time it + wrote it out, so a file's modification time differs between clones and the + order would not be the same on every machine, as Ranking requires; + `ingested_at` is in the page and travels with it. +- **How the ranking counts terms and lengths** (2026-10-12, step 4, agent's + choice for review). Each distinct word of the prompt is one query term, + however often or in whatever inflection the prompt repeats it (tokens whose + candidate roots overlap, "merge" and "merging", are one term), and a word + in an item counts as that term when its stem is among the roots the + loader's term rule matches the prompt's tokens by, so the ranking finds a + term exactly where the matcher hits, and under one term at most. That takes two small exports from `internal/core/rules` + beside `TermMatcher` (`PromptTerms`, read from the same prompt index, and + `TextStems`), though step 4's packages line names only the recall and the + CLI. An item's length is its weighted length, recall words three times, + title twice, body once, BM25F's one pseudo-document of repeated fields; + `avg_len` and N run over every item read after the narrower-scope + suppression, so a page without a recall list, never an item, counts in + neither. A personal note tied to another repository counts in both + (amended 2026-10-17 on merging main, agent's choice for review): step 2's + review made the tie a lazy judgement of the matched candidates alone + (the recall package's `matching`, which asks git only when a tied note matched), so + the corpus is fixed before git is asked and never depends on whether it + was. +- **The ranked order keeps step 2's take** (2026-10-12, step 4, agent's + choice for review): candidates are taken in ranked order while they fit, + and the first that does not fit stops the take, so nothing comes back + ahead of an item ranked above it. The one exception is an item whose block + alone is larger than the whole per-turn budget, which no turn can carry: it + is set aside as too large, never recorded, named on the hook's stderr and + given the form `too-large` in `rules --prompt`, and it does not stop the + take. Grounds: a page can be as large as 4 MiB, and one that ranks first + would otherwise hold back every item below it on every turn. +- **Scores tie at a billionth** (2026-10-12, step 4, agent's choice for + review): two scores within a billionth of each other are a tie, broken by + scope, recency and key. Grounds: arithmetic equal on paper can differ in + its last bits between machines, and the order must be the same on every + machine. +- **The diagnostic ranks notes and pages together** (2026-10-12, step 4, + agent's choice for review): `rules --prompt` keeps its `notes` and `pages` + lists, and each entry carries its `rank` in the one order they share, its + `score`, and its `terms`, each with its counts in `recall`, `title` and + `body` and its `contribution`. + ## Open Questions None. The nine questions first left here, and the three the spec review diff --git a/.abcd/work/brief-review-flags.json b/.abcd/work/brief-review-flags.json index 671f92922a..23d685d7f8 100644 --- a/.abcd/work/brief-review-flags.json +++ b/.abcd/work/brief-review-flags.json @@ -208,6 +208,30 @@ "evidence": ".abcd/work/decisions/ (538 records) against .abcd/development/decisions/adrs/ (92 files)", "commit": "2a692195fd82fec4229e064a2fb0f15b34c80172", "applied": "2026-10-16T05:32:44Z" + }, + { + "chapter": "01-ahoy.md", + "sentence": "nothing resolves it today)", + "replacement": "the prompt router's recall reads them, read only)", + "evidence": "internal/core/memory/recallpages.go:80", + "commit": "332aa6a2dfd2bdd4a4631188b528cd1002a8729f", + "applied": "2026-10-23T06:27:37Z" + }, + { + "chapter": "07-library.md", + "sentence": "reports, user-scope memory outside the repo, and the automatic extraction of a", + "replacement": "reports, any verb that writes user-scope memory outside the repo (the prompt recall only reads `~/.abcd.noindex/memory/`), and the automatic extraction of a", + "evidence": "internal/core/recall/pages.go:56", + "commit": "332aa6a2dfd2bdd4a4631188b528cd1002a8729f", + "applied": "2026-10-23T06:27:37Z" + }, + { + "chapter": "37-memory.md", + "sentence": "spec's later step; this chapter covers the store and the verb.", + "replacement": "spec's steps 2 to 4, shipped: a matching note or page comes back under `## MEMORY`, ranked by BM25F, and the rules verb can show why for a given prompt; this chapter covers the store and the verb.", + "evidence": "internal/surface/cli/rules_prompt.go:51", + "commit": "332aa6a2dfd2bdd4a4631188b528cd1002a8729f", + "applied": "2026-10-23T06:27:37Z" } ] } diff --git a/.abcd/work/decisions/dcn-2610230630506726-the-memory-recall-spec-stays-open-when.md b/.abcd/work/decisions/dcn-2610230630506726-the-memory-recall-spec-stays-open-when.md new file mode 100644 index 0000000000..ead61b488d --- /dev/null +++ b/.abcd/work/decisions/dcn-2610230630506726-the-memory-recall-spec-stays-open-when.md @@ -0,0 +1,6 @@ +--- +id: dcn-2610230630506726 +slug: the-memory-recall-spec-stays-open-when +date: 2026-10-23 +--- +2026-10-23 — The memory-recall spec stays open when steps 3 and 4 land, with no close and no remainder (facilitator, on abcd-ae's question of 2026-10-23; spc-2610111627213138, itd-2610091918433290): PR #982 delivers steps 3 and 4, so steps 1 to 4 have landed, but steps 5 to 8 (how full the session is, context brackets, the docs and brief, and the live session with the doc-fidelity review and the close) are not built, so the spec is not closed and the intent stays planned; a --remainder close would only move those four steps into a new spec under the same intent, which adds a record and changes nothing, so the open spec keeps them and the change carries no Delivers trailer (grounds: AGENTS.md definition of done, a change that delivers part of an intent carries no trailer; commands on spec close --remainder) diff --git a/commands/ahoy.md b/commands/ahoy.md index d623ad34cd..9a1f5938e0 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -1553,12 +1553,23 @@ prompt in a fresh session, and writes nothing. `domains` lists each rule domain the prompt matches, with the recall words that hit (`hits`, or `*NAME` for a star activation); `notes` lists each memory note it recalls from `~/.abcd.noindex/memory.json` or the project's `.abcd/memory.json`, with -its `layer` (`machine` or `project`) and the recall words that hit. Each -carries a `form`: `full`, or `over-budget` when it would not fit the -per-turn budget and would come back on a later prompt instead. `disabled` -is true under the kill switch, and then nothing comes back; `unreadable` -names a `memory.json` that could not be read, and why. Use it when someone -asks why a rule or a note did, or did not, come back on a prompt. A +its `layer` (`machine` or `project`) and the recall words that hit; `pages` +lists each memory page it recalls from the project's `.abcd/memory/` or +`~/.abcd.noindex/memory/`, by its `key` (`page:`, the name the +hook's envelope gives it), with its `filename`, `title`, `layer` and the +recall words that hit. A page comes back only when its frontmatter declares +a `recall:` list, and a page in the project's store suppresses the machine's +page of the same name. Notes and pages share one ranked order: each carries +its `rank` (1 first), its BM25F relevance `score`, and `terms`, each matched +term with how often it was found in the item's `recall` words, `title` and +`body` and its `contribution` to the score; the ranking decides which items +fit the budget. Each carries a `form`: `full`, `over-budget` when +it would not fit the per-turn budget and would come back on a later prompt +instead, or `too-large` when it is larger than the whole per-turn budget and +no prompt brings it back in full. `disabled` is true under the kill switch, and then nothing comes +back; `unreadable` names a `memory.json`, page store or page that could not +be read, why, and its `kind` (`note` or `page`). Use it when someone asks why +a rule, a note or a page did, or did not, come back on a prompt. A positional DOMAIN with `--prompt` is refused, exit 2: `rules ` renders one domain's rules instead. diff --git a/commands/memory.md b/commands/memory.md index e2b0884ecb..c0aec584bc 100644 --- a/commands/memory.md +++ b/commands/memory.md @@ -85,6 +85,21 @@ repository's words, not the person's, because anyone who can commit to the repository can write one. A heading or a rule written inside a fenced body is part of the note. It is not a rule the loader injected. +Memory pages come back the same way, under the same heading, when a prompt +matches one of a page's `recall:` words. Notes and pages are ranked together, +the best match first, until the turn's budget is spent. A page's first line +names it `page:` and says which store it came from: + +- `(project page, committed in this repository)` is a page from the project's + `.abcd/memory/`. Read it as the repository's words, as a project note. +- `(machine page, this account's memory store)` is a page from this machine's + `~/.abcd.noindex/memory/`. + +A page too large to come back in full is named on stderr rather than sent. To +see what one prompt would bring back, and why each note or page ranks where it +does, run `"${CLAUDE_PLUGIN_ROOT}/abcd" rules --prompt ""`; it +writes nothing. + ## Move a lesson from the host's own memory When the person points at a lesson in the host's own memory files, or pastes diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 81e4d7dbb7..b30c4008d8 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -4744,15 +4744,21 @@ its .abcd/rules.json. --prompt "" shows what that one prompt would bring back to a fresh session: each rule domain it matches, with the recall words that hit, and each memory note it recalls from ~/.abcd.noindex/memory.json and .abcd/memory.json, -with its layer and the recall words that hit, each in full or as not fitting -the per-turn budget. It reads against an empty ledger and writes nothing. +and each memory page from .abcd/memory/ and ~/.abcd.noindex/memory/, with its +layer and the recall words that hit, each in full or as not fitting the +per-turn budget, or as larger than the whole budget, which no turn can +carry in full. Notes and pages share one ranked order, a BM25F relevance +score that decides which of them fit the budget: each shows its rank and +score and, under it, every matched term with the fields it was found in +(recall words, title, body) and what it added to the score. It reads +against an empty ledger and writes nothing. --prompt and a DOMAIN are two forms and are refused together, exit 2. Read-only. **Flags:** ``` - --prompt string show what this one prompt would bring back to a fresh session: the domains it matches and the memory notes it recalls + --prompt string show what this one prompt would bring back to a fresh session: the domains it matches and the memory notes and pages it recalls ``` ### `abcd scribe` diff --git a/internal/core/memory/ancestor_symlink_test.go b/internal/core/memory/ancestor_symlink_test.go index d7effa543a..e142147311 100644 --- a/internal/core/memory/ancestor_symlink_test.go +++ b/internal/core/memory/ancestor_symlink_test.go @@ -77,4 +77,17 @@ func TestMemoryStoreDirSymlinkRefused(t *testing.T) { if _, err := Bare(repoRoot); err == nil { t.Error("Bare followed a symlinked .abcd/memory store; the directory symlink must be refused") } + + // (d) The prompt router's recall read (spc-2610111627213138 step 3) must + // refuse it too, and disclose nothing of the target. + pages, refused := ReadRecallPages(repoRoot) + for _, p := range pages { + if strings.Contains(p.Body, secret) { + t.Fatalf("ReadRecallPages disclosed an out-of-repo page through the store symlink: %q", p.Filename) + } + } + var unsafe *UnsafeStorePathError + if len(refused) != 1 || !errors.As(refused[0].Err, &unsafe) { + t.Errorf("ReadRecallPages followed a symlinked .abcd/memory store (refused %+v); the directory symlink must be refused", refused) + } } diff --git a/internal/core/memory/recallpages.go b/internal/core/memory/recallpages.go new file mode 100644 index 0000000000..6aebabd8e9 --- /dev/null +++ b/internal/core/memory/recallpages.go @@ -0,0 +1,229 @@ +package memory + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// recallpages.go — the read the prompt router's memory recall makes of the +// pages (spc-2610111627213138 step 3): each page's filename, its index line's +// summary, its body and its recall: list, from the workspace store and, read +// only, from the machine's ~/.abcd.noindex/memory/. Nothing here writes. + +// RecallPage is one memory page as the recall reads it. +type RecallPage struct { + // Filename is the page's __.md name in its store. + Filename string + // Summary is the page's index.md summary, derived from the page as + // RenderIndex derives it, so the recall reads no index.md. + Summary string + // Body is the page's text after its frontmatter, less the line Summary + // comes from, trimmed: the title is not counted, or sent, twice. + Body string + // Recall is the page's recall: frontmatter list, empty when it has none. + Recall []string + // IngestedAt is the latest ingested_at the page's source block records + // (one source's, or the latest of several), the zero time when it records + // none. It is the page's own record of when it was written, which travels + // with it; the file's modification time is whenever a checkout wrote it. + IngestedAt time.Time +} + +// RecallRefusal names a scope or a page the read refused, and why. File is +// the store-relative or tilde form, never an expanded home; Filename is the +// refused page's own name, "" when the whole scope was refused. +type RecallRefusal struct { + File string + Filename string + Err error +} + +// machineStoreDisplay is the machine page scope as a message names it. +var machineStoreDisplay = abcdhome.Display("memory") + "/" + +// workspaceStoreDisplay is the workspace page scope as a message names it. +const workspaceStoreDisplay = ".abcd/memory/" + +// ReadRecallPages reads the workspace store under repoRoot through the store +// handle: a symlinked or swapped store is refused whole, each page is read +// through the handle's guarded read. An absent store yields nothing. The +// pages read are those index.md is rendered from (the store's top-level page +// files, storeHandle.pageNames) whose names are __.md; +// index.md itself is never read. +func ReadRecallPages(repoRoot string) ([]RecallPage, []RecallRefusal) { + store, err := openStore(repoRoot) + if err != nil { + return nil, []RecallRefusal{{File: workspaceStoreDisplay, Err: err}} + } + defer store.Close() + return recallPagesFrom(store, workspaceStoreDisplay, store.read) +} + +// ReadMachineRecallPages reads the machine scope, ~/.abcd.noindex/memory/ +// under home, read only, with the guards the machine memory.json is read +// with: the directory is opened through fsutil.OpenHomeDeclarationScope, so a +// symlinked ~/.abcd.noindex or memory/ is refused, and so is one another +// account can change; each page is read through the declaration's guarded +// read relative to that descriptor, a regular file this account owns that no +// one else can write. An absent scope, no absolute home, or a home or +// ~/.abcd.noindex this account cannot search yields nothing, whatever the +// directories above it are. +func ReadMachineRecallPages(home string) ([]RecallPage, []RecallRefusal) { + if home == "" || !filepath.IsAbs(home) { + return nil, nil + } + // Absence is decided before any directory is judged, as + // ReadHomeDeclaration decides it, so an exposed ~/.abcd.noindex that + // holds no page scope costs nothing and says nothing. + if _, err := os.Lstat(abcdhome.Path(home, "memory")); err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, fs.ErrPermission) { + return nil, nil + } + return nil, []RecallRefusal{{File: machineStoreDisplay, Err: recallReadError(err)}} + } + root, err := fsutil.OpenHomeDeclarationScope(home, abcdhome.Rel("memory")) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, fs.ErrPermission) { + return nil, nil + } + return nil, []RecallRefusal{{File: machineStoreDisplay, Err: err}} + } + store := &storeHandle{dir: abcdhome.Path(home, "memory"), root: root} + defer store.Close() + read := func(name string, limit int64) ([]byte, error) { + data, _, err := fsutil.ReadHomeDeclarationDenyingIn(root, home, abcdhome.Rel("memory", name), limit, 0) + return data, err + } + return recallPagesFrom(store, machineStoreDisplay, read) +} + +// The recall runs on every prompt, over a store of any size, +// so what one prompt reads of a scope is bounded: no page larger than a +// recall could send (a block over the injection budget is set aside whole), +// and no more pages, or bytes, than the bounds below. The bounds keep the +// hook's worst case within its budget; real stores sit far below them. +const ( + // maxRecallPageBytes is twice the injection budget (64 KiB): a page past + // it can never come back, so its body is never read for ranking. + maxRecallPageBytes = 128 << 10 + // maxRecallPagesPerScope and maxRecallBytesPerScope bound one scope's + // pages per prompt, read in name order. + maxRecallPagesPerScope = 256 + maxRecallBytesPerScope = 1 << 20 +) + +// recallPagesFrom reads, through read, the pages the store lists whose name +// is __.md, in name order and within the bounds above; a +// page read refuses is named, never followed, and pages past a bound are +// named once. +func recallPagesFrom(store *storeHandle, shown string, read func(string, int64) ([]byte, error)) ([]RecallPage, []RecallRefusal) { + var pages []RecallPage + var refused []RecallRefusal + var bytesRead, tried int + for _, name := range store.pageNames() { + if _, _, _, ok := ParsePageFilename(name); !ok { + continue + } + // Every page tried counts, a refused one included, so a store of + // pages the recall refuses cannot make it open every one of them. + if tried >= maxRecallPagesPerScope || bytesRead >= maxRecallBytesPerScope { + refused = append(refused, RecallRefusal{File: shown, Err: fmt.Errorf("it holds more pages than one prompt's recall reads (%d pages or %d bytes); from %s on, its pages are not recalled", + maxRecallPagesPerScope, maxRecallBytesPerScope, name)}) + break + } + tried++ + raw, err := read(name, maxRecallPageBytes) + if err != nil { + refused = append(refused, RecallRefusal{File: shown + name, Filename: name, Err: recallReadError(err)}) + continue + } + if bytesRead+len(raw) > maxRecallBytesPerScope { + refused = append(refused, RecallRefusal{File: shown, Err: fmt.Errorf("it holds more pages than one prompt's recall reads (%d pages or %d bytes); from %s on, its pages are not recalled", + maxRecallPagesPerScope, maxRecallBytesPerScope, name)}) + break + } + bytesRead += len(raw) + p := parsePage(string(raw)) + info := pageInfoOf(name, p) + pages = append(pages, RecallPage{Filename: name, Summary: info.Summary, Body: bodyAfterSummary(p.body), Recall: recallList(p.fm), + IngestedAt: latestIngest(p.source())}) + } + return pages, refused +} + +// bodyAfterSummary is body less its first non-blank line, the one +// pageInfoOf takes the summary from, trimmed. +func bodyAfterSummary(body string) string { + rest := strings.TrimLeft(body, "\r\n\t ") + if i := strings.IndexByte(rest, '\n'); i >= 0 { + return strings.TrimSpace(rest[i+1:]) + } + return "" +} + +// recallList is a page's recall: list, its non-blank string entries trimmed. +func recallList(fm map[string]any) []string { + raw, ok := fm["recall"].([]any) + if !ok { + return nil + } + var out []string + for _, v := range raw { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out +} + +// latestIngest is the latest ingested_at date a source block records, in +// its single-source or multi-source shape; the zero time when none parses. +func latestIngest(source map[string]any) time.Time { + entries := []any{source} + if list, ok := source["sources"].([]any); ok { + entries = append(entries, list...) + } + var latest time.Time + for _, e := range entries { + m, ok := e.(map[string]any) + if !ok { + continue + } + s, ok := m["ingested_at"].(string) + if !ok { + continue + } + if at, err := time.Parse(time.DateOnly, s); err == nil && at.After(latest) { + latest = at + } + } + return latest +} + +// recallReadError words a refused page read without the expanded path. +func recallReadError(err error) error { + switch { + case errors.Is(err, fsutil.ErrTooBig): + return fmt.Errorf("it is larger than the %d bytes a recall reads, so it is never recalled", maxRecallPageBytes) + case errors.Is(err, fsutil.ErrNotRegular): + return errors.New("it is not a regular file") + case errors.Is(err, fsutil.ErrDeclarationWritable): + return errors.New("it is writable by others, so its words are not necessarily yours (chmod go-w it)") + case errors.Is(err, fsutil.ErrDeclarationForeignOwner): + return errors.New("it is not owned by this session's uid") + } + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err + } + return err +} diff --git a/internal/core/memory/recallpages_test.go b/internal/core/memory/recallpages_test.go new file mode 100644 index 0000000000..7493b14566 --- /dev/null +++ b/internal/core/memory/recallpages_test.go @@ -0,0 +1,325 @@ +package memory + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// recallPageText is a typed page with a recall: list. +func recallPageText(summary string, recall ...string) string { + return "---\nrecall: [" + strings.Join(recall, ", ") + "]\nsource:\n class: session_memory\n ingested_at: 2026-10-01\n---\n# " + summary + "\n\nThe body.\n" +} + +func writeStoreFile(t *testing.T, dir, name, text string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(text), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(dir, name), mode); err != nil { + t.Fatal(err) + } +} + +// TestRecallPagesReadsTheIndexedPages: the recall's read yields each page's +// filename, its index line's summary, its body and its recall: list, for +// pages named __.md at the store's top level only. +func TestRecallPagesReadsTheIndexedPages(t *testing.T) { + repo := t.TempDir() + mem := filepath.Join(repo, ".abcd", "memory") + writeStoreFile(t, mem, "fact_eng_auth-flow.md", recallPageText("Auth uses short-lived tokens", "auth", "login"), 0o644) + writeStoreFile(t, mem, "fact_eng_silent.md", "---\nsource:\n class: session_memory\n---\n# No recall list\n", 0o644) + writeStoreFile(t, mem, "index.md", recallPageText("not a page", "auth"), 0o644) + writeStoreFile(t, mem, "notes.md", recallPageText("not a typed name", "auth"), 0o644) + writeStoreFile(t, filepath.Join(mem, "sources"), "fact_eng_nested.md", recallPageText("nested", "auth"), 0o644) + + pages, refused := ReadRecallPages(repo) + if len(refused) != 0 { + t.Fatalf("refused = %+v", refused) + } + var names []string + for _, p := range pages { + names = append(names, p.Filename) + } + if !slices.Equal(names, []string{"fact_eng_auth-flow.md", "fact_eng_silent.md"}) { + t.Fatalf("pages = %v", names) + } + p := pages[0] + if p.Summary != "Auth uses short-lived tokens" || !slices.Equal(p.Recall, []string{"auth", "login"}) || + !strings.Contains(p.Body, "The body.") || strings.Contains(p.Body, "recall:") { + t.Fatalf("page = %+v", p) + } + if len(pages[1].Recall) != 0 { + t.Fatalf("a page with no recall list read one: %+v", pages[1]) + } + if got, refused := ReadRecallPages(t.TempDir()); len(got) != 0 || len(refused) != 0 { + t.Fatalf("an absent store read %v %v", got, refused) + } +} + +// TestRecallPagesRefuseASymlinkedPage: a page committed as a symlink to a +// file outside the store is never followed — it is not listed as a page, so +// its target never reaches the recall — in either scope. +func TestRecallPagesRefuseASymlinkedPage(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.md") + if err := os.WriteFile(outside, []byte(recallPageText("LEAKED outside text", "auth")), 0o600); err != nil { + t.Fatal(err) + } + for _, dir := range []string{filepath.Join(repo, ".abcd", "memory"), abcdhome.Path(home, "memory")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + plantSymlink(t, outside, filepath.Join(dir, "fact_eng_auth-flow.md")) + } + workspace, _ := ReadRecallPages(repo) + machine, _ := ReadMachineRecallPages(home) + for _, p := range append(workspace, machine...) { + if strings.Contains(p.Summary+p.Body, "LEAKED") { + t.Fatalf("a symlinked page was followed: %+v", p) + } + } +} + +// TestRecallPagesRefuseASymlinkedStore: the workspace store committed as a +// directory symlink, and a symlinked ~/.abcd.noindex or memory/, are refused +// whole and named, never walked. +func TestRecallPagesRefuseASymlinkedStore(t *testing.T) { + target := t.TempDir() + writeStoreFile(t, target, "fact_eng_auth-flow.md", recallPageText("LEAKED through the link", "auth"), 0o600) + + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + plantSymlink(t, target, filepath.Join(repo, ".abcd", "memory")) + pages, refused := ReadRecallPages(repo) + var unsafe *UnsafeStorePathError + if len(pages) != 0 || len(refused) != 1 || refused[0].File != ".abcd/memory/" || !errors.As(refused[0].Err, &unsafe) { + t.Fatalf("a symlinked workspace store was read: pages %+v refused %+v", pages, refused) + } + + for name, link := range map[string]func(home string){ + "memory": func(home string) { + if err := os.MkdirAll(abcdhome.Path(home), 0o700); err != nil { + t.Fatal(err) + } + plantSymlink(t, target, abcdhome.Path(home, "memory")) + }, + "abcd-home": func(home string) { + real := filepath.Join(t.TempDir(), "dotfiles") + if err := os.MkdirAll(filepath.Join(real, "memory"), 0o700); err != nil { + t.Fatal(err) + } + writeStoreFile(t, filepath.Join(real, "memory"), "fact_eng_auth-flow.md", recallPageText("LEAKED through the link", "auth"), 0o600) + plantSymlink(t, real, abcdhome.Path(home)) + }, + } { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + link(home) + pages, refused := ReadMachineRecallPages(home) + if len(pages) != 0 || len(refused) != 1 || !errors.Is(refused[0].Err, fsutil.ErrHomeScopeSymlinked) { + t.Fatalf("a symlinked machine scope was read: pages %+v refused %+v", pages, refused) + } + if strings.Contains(refused[0].Err.Error(), home) { + t.Fatalf("the refusal names the expanded home: %v", refused[0].Err) + } + }) + } +} + +// TestMachineRecallPagesAreReadAsTheMachineFilesAre: a machine page is read +// as the machine memory.json is — a regular file this account owns that no +// one else can write — and one others can write is refused and named, while +// the scope's other pages are still read. An absent scope, or no home, reads +// nothing and creates nothing. +func TestMachineRecallPagesAreReadAsTheMachineFilesAre(t *testing.T) { + home := t.TempDir() + dir := abcdhome.Path(home, "memory") + writeStoreFile(t, dir, "fact_eng_auth-flow.md", recallPageText("Kept to this account", "auth"), 0o600) + writeStoreFile(t, dir, "fact_eng_auth-keys.md", recallPageText("Anyone can rewrite this", "auth"), 0o666) + + pages, refused := ReadMachineRecallPages(home) + if len(pages) != 1 || pages[0].Filename != "fact_eng_auth-flow.md" { + t.Fatalf("pages = %+v", pages) + } + if len(refused) != 1 || refused[0].File != abcdhome.Display("memory", "fact_eng_auth-keys.md") || + !strings.Contains(refused[0].Err.Error(), "writable by others") { + t.Fatalf("refused = %+v", refused) + } + + empty := t.TempDir() + for _, h := range []string{empty, "", "relative/home"} { + if pages, refused := ReadMachineRecallPages(h); len(pages) != 0 || len(refused) != 0 { + t.Fatalf("home %q read %+v %+v", h, pages, refused) + } + } + if _, err := os.Lstat(abcdhome.Path(empty)); !os.IsNotExist(err) { + t.Fatalf("the read created %s: %v", abcdhome.Path(empty), err) + } +} + +// TestRecallPagesCarryTheirLatestIngestDate: a page's recency, the +// ranking's tie-break, is the latest ingested_at its source block records — +// one source's, or the latest of several — and the zero time when it records +// none, never the file's modification time, which a checkout sets. +func TestRecallPagesCarryTheirLatestIngestDate(t *testing.T) { + repo := t.TempDir() + mem := filepath.Join(repo, ".abcd", "memory") + writeStoreFile(t, mem, "fact_eng_one.md", recallPageText("One source", "auth"), 0o644) + writeStoreFile(t, mem, "fact_eng_many.md", "---\nrecall: [auth]\nsource:\n classes: [session_memory]\n sources:\n"+ + " - {class: session_memory, ingested_at: 2026-09-01}\n - {class: session_memory, ingested_at: 2026-10-05}\n---\n# Many sources\n", 0o644) + writeStoreFile(t, mem, "fact_eng_none.md", "---\nrecall: [auth]\n---\n# No source block\n", 0o644) + + at := map[string]string{} + pages, refused := ReadRecallPages(repo) + if len(refused) != 0 { + t.Fatalf("refused = %+v", refused) + } + for _, p := range pages { + at[p.Filename] = p.IngestedAt.Format("2006-01-02") + if p.Filename == "fact_eng_none.md" && !p.IngestedAt.IsZero() { + t.Fatalf("a page with no source block has a date: %v", p.IngestedAt) + } + } + if at["fact_eng_one.md"] != "2026-10-01" || at["fact_eng_many.md"] != "2026-10-05" { + t.Fatalf("ingest dates = %v", at) + } +} + +// TestMachineRecallPagesRefuseADirectoryOthersCanChange: the machine scope's +// directories are judged as the machine memory.json's are — one every +// account can write is refused, its pages unread, and named in tilde form — +// while an exposed ~/.abcd.noindex holding no memory/ still reads as absent. +func TestMachineRecallPagesRefuseADirectoryOthersCanChange(t *testing.T) { + for name, exposed := range map[string][]string{"abcd-home": nil, "memory": {"memory"}} { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + dir := abcdhome.Path(home, "memory") + writeStoreFile(t, dir, "fact_eng_auth-flow.md", recallPageText("Anyone could have put this here", "auth"), 0o600) + if err := os.Chmod(abcdhome.Path(home, exposed...), 0o777); err != nil { + t.Fatal(err) + } + pages, refused := ReadMachineRecallPages(home) + if len(pages) != 0 || len(refused) != 1 || !errors.Is(refused[0].Err, fsutil.ErrHomeScopeExposed) { + t.Fatalf("an exposed directory's pages were read: pages %+v refused %+v", pages, refused) + } + if msg := refused[0].Err.Error(); strings.Contains(msg, home) || !strings.Contains(msg, abcdhome.Display(exposed...)+" can be written by every account") { + t.Fatalf("the refusal does not name the directory in tilde form: %s", msg) + } + }) + } + home := t.TempDir() + if err := os.MkdirAll(abcdhome.Path(home), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(abcdhome.Path(home), 0o777); err != nil { + t.Fatal(err) + } + if pages, refused := ReadMachineRecallPages(home); len(pages) != 0 || len(refused) != 0 { + t.Fatalf("an exposed home with no page scope read %+v %+v", pages, refused) + } +} + +// TestRecallPageBodyDoesNotRepeatItsTitle: a page's title is the line its +// index summary comes from, so the body the recall weighs and renders is the +// rest of the page; the title's words are not counted, or sent, twice. +func TestRecallPageBodyDoesNotRepeatItsTitle(t *testing.T) { + repo := t.TempDir() + writeStoreFile(t, filepath.Join(repo, ".abcd", "memory"), "fact_eng_auth-flow.md", + "---\nrecall: [auth]\n---\n\n# Auth uses short-lived tokens\n\nRefresh them hourly.\n# A later heading stays\n", 0o644) + pages, _ := ReadRecallPages(repo) + if len(pages) != 1 { + t.Fatalf("pages = %+v", pages) + } + if p := pages[0]; p.Summary != "Auth uses short-lived tokens" || p.Body != "Refresh them hourly.\n# A later heading stays" { + t.Fatalf("summary %q body %q", p.Summary, p.Body) + } +} + +// TestRecallPagesReadsNoPageLargerThanARecallCanSend: a page larger than the +// recall's read cap is refused with that reason and its body is never read +// into the recall, so one oversized page cannot slow every prompt. +func TestRecallPagesReadsNoPageLargerThanARecallCanSend(t *testing.T) { + repo := t.TempDir() + mem := filepath.Join(repo, ".abcd", "memory") + big := recallPageText("A big page", "auth") + strings.Repeat("word ", maxRecallPageBytes/5+1) + writeStoreFile(t, mem, "fact_eng_big.md", big, 0o644) + writeStoreFile(t, mem, "fact_eng_small.md", recallPageText("A small page", "auth"), 0o644) + pages, refused := ReadRecallPages(repo) + if len(pages) != 1 || pages[0].Filename != "fact_eng_small.md" { + t.Fatalf("pages = %+v", pages) + } + if len(refused) != 1 || refused[0].Filename != "fact_eng_big.md" || !strings.Contains(refused[0].Err.Error(), "never recalled") { + t.Fatalf("refused = %+v", refused) + } +} + +// TestRecallPagesBoundTheScope: a scope holding more pages, or more bytes, +// than one prompt's recall reads yields the first by name up to the bound, +// and one refusal naming the bound, so no store makes every +// prompt read without limit. +func TestRecallPagesBoundTheScope(t *testing.T) { + repo := t.TempDir() + mem := filepath.Join(repo, ".abcd", "memory") + for i := 0; i < maxRecallPagesPerScope+5; i++ { + writeStoreFile(t, mem, fmt.Sprintf("fact_eng_p%04d.md", i), recallPageText("Page", "auth"), 0o644) + } + pages, refused := ReadRecallPages(repo) + if len(pages) != maxRecallPagesPerScope { + t.Fatalf("read %d pages, want the bound %d", len(pages), maxRecallPagesPerScope) + } + if len(refused) != 1 || !strings.Contains(refused[0].Err.Error(), "more pages") { + t.Fatalf("refused = %+v", refused) + } + + heavy := t.TempDir() + hmem := filepath.Join(heavy, ".abcd", "memory") + body := strings.Repeat("word ", (maxRecallPageBytes-1024)/5) + n := maxRecallBytesPerScope/(maxRecallPageBytes-1024) + 3 + for i := 0; i < n; i++ { + writeStoreFile(t, hmem, fmt.Sprintf("fact_eng_h%02d.md", i), recallPageText("Heavy", "auth")+body, 0o644) + } + hpages, hrefused := ReadRecallPages(heavy) + total := 0 + for _, p := range hpages { + total += len(p.Body) + len(p.Summary) + } + if total > maxRecallBytesPerScope || len(hpages) >= n { + t.Fatalf("read %d of %d heavy pages, %d bytes, past the %d-byte bound", len(hpages), n, total, maxRecallBytesPerScope) + } + if len(hrefused) != 1 || !strings.Contains(hrefused[0].Err.Error(), "more pages") { + t.Fatalf("refused = %+v", hrefused) + } +} + +// TestRecallPagesCountRefusedPagesTowardTheBound: a page the recall refuses +// still counts toward the scope's bound, so a store of oversized pages cannot +// make every prompt open every one of them, or name each on stderr. +func TestRecallPagesCountRefusedPagesTowardTheBound(t *testing.T) { + repo := t.TempDir() + mem := filepath.Join(repo, ".abcd", "memory") + big := recallPageText("Too big", "auth") + strings.Repeat("x", maxRecallPageBytes) + for i := 0; i < maxRecallPagesPerScope+5; i++ { + writeStoreFile(t, mem, fmt.Sprintf("fact_eng_b%04d.md", i), big, 0o644) + } + pages, refused := ReadRecallPages(repo) + if len(pages) != 0 { + t.Fatalf("an oversized page was read: %d", len(pages)) + } + if len(refused) > maxRecallPagesPerScope+1 { + t.Fatalf("%d refusals for a bound of %d pages", len(refused), maxRecallPagesPerScope) + } +} diff --git a/internal/core/recall/items.go b/internal/core/recall/items.go index 669ce46fad..5ee3866134 100644 --- a/internal/core/recall/items.go +++ b/internal/core/recall/items.go @@ -8,38 +8,52 @@ // and a rule domain's recall word hit on exactly the same prompts. The // per-session ledger is SessionState.Recalled, keyed on each item's content, // cleared by the loader's resets. The budget is what the rules leave of the -// loader's one per-turn budget (rules.InjectionBudgetBytes). Until the -// relevance ranking lands (step 4), the candidates are taken in id order. +// loader's one per-turn budget (rules.InjectionBudgetBytes), and the +// relevance ranking (ranking.go, BM25F) decides which candidates fit it. // -// Step 2 recalls notes; pages join the same path in step 3. Nothing here -// writes to stdout or knows a transport. +// Notes come from both memory.json layers (step 2) and pages from both page +// scopes (step 3, pages.go), through one path. Nothing here writes to stdout +// or knows a transport. package recall import ( "sort" + "time" "github.com/intentdriven/abcd/internal/core/memnote" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" ) -// The layers an item comes from, as the diagnostic names them. +// The layers an item comes from, as the diagnostic names them: a note's +// memory.json, or a page's scope (the project's .abcd/memory/ or the +// machine's ~/.abcd.noindex/memory/). const ( LayerProject = "project" LayerMachine = "machine" ) -// Item is one thing the recall can bring back: a note now, a page from step -// 3. Key is its ledger key and its place in the id order — a note's id -// (mem-…), and a page's "page:" after every note. Tie is the root -// commit a personal note is tied to; nil for a note recalled everywhere and -// for every item in the project's file. +// The kinds of item. +const ( + KindNote = "note" + KindPage = "page" +) + +// Item is one thing the recall can bring back: a note or a page. Key is its +// ledger key and the ranking's last tie-break — a note's id (mem-…), and a +// page's "page:". At is when it was written, the ranking's +// recency tie-break: a note's written_at, a page's latest ingested_at, the +// zero time when it records none. Tie is the root commit a personal note is +// tied to; nil for a note recalled everywhere, for every item in the +// project's file, and for every page. type Item struct { Key string + Kind string Layer string Title string Body string Recall []string + At time.Time Tie *string } @@ -51,18 +65,20 @@ type Item struct { // (gitutil.RootCommitContext): the tied items are then left out. type RootFunc func() (string, error) -// Refusal names a layer the recall could not read, and why, in words safe to -// print: the file in tilde or repository-relative form, the reason -// home-redacted and terminal-safe. +// Refusal names a layer, page scope or page the recall could not read, and +// why, in words safe to print: the file in tilde or repository-relative form, +// the reason home-redacted and terminal-safe. Kind is what it would have +// brought back: KindNote for a memory.json, KindPage for a page scope or page. type Refusal struct { File string `json:"file"` Reason string `json:"reason"` + Kind string `json:"kind"` } // noteItems turns both note layers into items, in id order. A personal note // carries the root it is tied to (Item.Tie), and is recalled only in that // repository, or everywhere when its root is null; the tie is judged after -// matching (Matching), so reading the layers asks git nothing. Every note in +// matching (see matching), so reading the layers asks git nothing. Every note in // the project's file is an item. A note in both layers (an id is minted once, // so only a hand copy does that) is the project's: the machine's copy is // suppressed. @@ -90,7 +106,8 @@ func noteItems(machine, project []memnote.Note) []Item { } func noteItem(n memnote.Note, layer string, tie *string) Item { - return Item{Key: n.ID, Layer: layer, Title: n.Title, Body: n.Body, Recall: append([]string(nil), n.Recall...), Tie: tie} + at, _ := time.Parse(time.RFC3339, n.WrittenAt) + return Item{Key: n.ID, Kind: KindNote, Layer: layer, Title: n.Title, Body: n.Body, Recall: append([]string(nil), n.Recall...), At: at, Tie: tie} } // Layers is what a read of both note layers gave: the notes as they stand @@ -110,21 +127,21 @@ func ReadLayers(home, repoRoot string) (Layers, []Refusal) { var l Layers var refused []Refusal if f, _, err := memnote.ReadMachine(home); err != nil { - refused = append(refused, Refusal{File: memnote.MachineDisplayPath, Reason: Reason(err)}) + refused = append(refused, Refusal{File: memnote.MachineDisplayPath, Reason: Reason(err), Kind: KindNote}) } else { l.Machine = f.Notes } if f, _, err := memnote.ReadRepo(repoRoot); err != nil { - refused = append(refused, Refusal{File: memnote.RepoRelPath, Reason: Reason(err)}) + refused = append(refused, Refusal{File: memnote.RepoRelPath, Reason: Reason(err), Kind: KindNote}) } else { l.Project = f.Notes } return l, refused } -// ReadNotes is ReadLayers turned into the recall's items. A personal note's +// readNotes is ReadLayers turned into the recall's items. A personal note's // tie is judged after matching, against the RootFunc the recall is handed. -func ReadNotes(home, repoRoot string) ([]Item, []Refusal) { +func readNotes(home, repoRoot string) ([]Item, []Refusal) { l, refused := ReadLayers(home, repoRoot) return noteItems(l.Machine, l.Project), refused } diff --git a/internal/core/recall/match.go b/internal/core/recall/match.go index a1c0824c08..d1fd708e39 100644 --- a/internal/core/recall/match.go +++ b/internal/core/recall/match.go @@ -9,8 +9,9 @@ type Candidate struct { } // candidates returns the items any of whose recall words hits prompt under -// the loader's term rule (rules.TermMatcher), in the items' order — id order -// from noteItems. The order chooses nothing yet; Recall takes them in it. +// the loader's term rule (rules.TermMatcher), in the items' order. The order +// chooses nothing: Rank orders the candidates (after matching judges the +// ties), and Recall takes them in it. func candidates(items []Item, prompt string) []Candidate { if len(items) == 0 { return nil @@ -31,13 +32,13 @@ func candidates(items []Item, prompt string) []Candidate { return out } -// Matching is candidates with each personal note's tie judged: a candidate +// matching is candidates with each personal note's tie judged: a candidate // tied to a root is kept only when root answers that same root. root is // asked only when a tied item matched, and then once, so a prompt that // matches no tied item costs no git call; a nil root answers "". When root // errs, every tied candidate is left out and the error is returned beside // the rest. -func Matching(items []Item, prompt string, root RootFunc) ([]Candidate, error) { +func matching(items []Item, prompt string, root RootFunc) ([]Candidate, error) { cands := candidates(items, prompt) tied := false for _, c := range cands { diff --git a/internal/core/recall/opens_linux_test.go b/internal/core/recall/opens_linux_test.go new file mode 100644 index 0000000000..54e9f964d2 --- /dev/null +++ b/internal/core/recall/opens_linux_test.go @@ -0,0 +1,199 @@ +//go:build linux + +package recall + +import ( + "context" + "encoding/binary" + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/core/rules" + "github.com/intentdriven/abcd/internal/gittest" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// openRecorder is the filesystem seam that records every open under the +// trees it watches: an inotify instance with an IN_OPEN watch on every +// directory below each root, so a file or a directory opened by anything in +// the process — a primitive, a library, a git child — is recorded, whatever +// path the code took to reach it. +type openRecorder struct { + fd int + dirs map[int32]string +} + +func recordOpens(t *testing.T, roots ...string) *openRecorder { + t.Helper() + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC | syscall.IN_NONBLOCK) + if err != nil { + t.Skipf("inotify unavailable: %v", err) + } + t.Cleanup(func() { syscall.Close(fd) }) + r := &openRecorder{fd: fd, dirs: map[int32]string{}} + for _, root := range roots { + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return err + } + wd, err := syscall.InotifyAddWatch(fd, p, syscall.IN_OPEN|syscall.IN_ONLYDIR|syscall.IN_DONT_FOLLOW) + if err != nil { + return err + } + r.dirs[int32(wd)] = p + return nil + }) + if err != nil { + t.Fatalf("watching %s: %v", root, err) + } + } + // The walk that placed the watches opened each directory it listed; + // those opens are the recorder's own, so they are drained unread. + r.opened(t) + return r +} + +// opened drains the events queued so far and returns each path opened, once. +func (r *openRecorder) opened(t *testing.T) []string { + t.Helper() + seen := map[string]bool{} + buf := make([]byte, 64<<10) + for { + n, err := syscall.Read(r.fd, buf) + if errors.Is(err, syscall.EAGAIN) { + break + } + if err != nil { + t.Fatalf("reading inotify events: %v", err) + } + for off := 0; off+syscall.SizeofInotifyEvent <= n; { + wd := int32(binary.NativeEndian.Uint32(buf[off:])) + mask := binary.NativeEndian.Uint32(buf[off+4:]) + nameLen := int(binary.NativeEndian.Uint32(buf[off+12:])) + name := strings.TrimRight(string(buf[off+syscall.SizeofInotifyEvent:off+syscall.SizeofInotifyEvent+nameLen]), "\x00") + off += syscall.SizeofInotifyEvent + nameLen + if mask&syscall.IN_Q_OVERFLOW != 0 { + t.Fatal("the inotify queue overflowed, so opens went unrecorded") + } + dir, ok := r.dirs[wd] + if !ok || mask&syscall.IN_OPEN == 0 { + continue + } + p := dir + if name != "" { + p = filepath.Join(dir, name) + } + seen[p] = true + } + } + var out []string + for p := range seen { + out = append(out, p) + } + slices.Sort(out) + return out +} + +// TestRecallOpensOnlyTheDeclaredPlaces (P5): with a host memory directory +// full of matching text under the home, the recall — every note and page in +// reach read, then matched — opens only the four places it declares (the two +// memory.json layers and the two page scopes) and the directories on the way +// to them, plus the repository's own git data when a personal note asks git +// for the root commit. Nothing under the host's memory directory is opened. +func TestRecallOpensOnlyTheDeclaredPlaces(t *testing.T) { + home := t.TempDir() + repo := gittest.NewRepo(t) + repo.Write("README.md", "a repository\n") + repo.Commit("root") + root := repo.Root() + tied := strings.TrimSpace(repo.Git("rev-list", "--max-parents=0", "HEAD")) + + machineNotes := abcdhome.Path(home, "memory.json") + machinePages := abcdhome.Path(home, "memory") + projectNotes := filepath.Join(root, ".abcd", "memory.json") + projectPages := filepath.Join(root, ".abcd", "memory") + for _, dir := range []string{machinePages, projectPages} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + writeFile(t, machineNotes, `{"schema_version": 1, "notes": [`+ + noteJSON("mem-2610111200000001", "Auth tokens expire hourly", "Refresh before the hour.", "auth")+`, `+ + tiedNoteJSON("mem-2610111200000002", "This repo's auth lives in one package", "Look there first.", tied, "auth")+`]}`) + writeFile(t, projectNotes, `{"schema_version": 1, "notes": [`+projectNoteJSON("mem-2610111200000003", "Auth is reviewed twice", "Ask for a second reviewer.", "auth")+`]}`) + writePage(t, machinePages, "fact_eng_auth-keys.md", "# Auth keys live in the keychain\n", "auth") + writePage(t, projectPages, "fact_eng_auth-flow.md", "# Auth uses short-lived tokens\n", "auth") + + // The host's own memory directory, full of text that matches: a reader + // that scanned the home, or followed the host's layout, would recall it. + host := filepath.Join(home, ".claude", "projects", "-repo", "memory") + if err := os.MkdirAll(host, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(host, "MEMORY.md"), "- auth: the host's own lesson about auth\n") + writePage(t, host, "fact_eng_auth-host.md", "# The host's auth page\n", "auth") + writeFile(t, filepath.Join(home, ".claude", "memory.json"), `{"schema_version": 1, "notes": []}`) + + rec := recordOpens(t, home, root) + items, refused := ReadItems(home, root) + // The root is asked of git itself, as the router asks it, so the opens + // git makes to answer are among those recorded. + here := func() (string, error) { return gitutil.RootCommitContext(context.Background(), root) } + res := Recall(Request{Items: items, Prompt: "the auth login is broken", Budget: rules.InjectionBudgetBytes, Root: here}) + opened := rec.opened(t) + + declared := []string{machineNotes, machinePages, projectNotes, projectPages} + allowed := func(p string) bool { + for _, d := range declared { + if p == d || filepath.Dir(p) == d && (d == machinePages || d == projectPages) || strings.HasPrefix(d, p+string(filepath.Separator)) { + return true + } + } + // A personal note tied to this repository asks git for its root + // commit, and git reads the repository's own data to answer. + return strings.HasPrefix(p, filepath.Join(root, ".git")+string(filepath.Separator)) || p == filepath.Join(root, ".git") + } + if len(opened) == 0 { + t.Fatal("the recorder saw no open at all, so it proves nothing") + } + for _, p := range opened { + if p == host || strings.HasPrefix(p, filepath.Join(home, ".claude")) { + t.Errorf("the recall opened %s, under the host's own files", p) + continue + } + if !allowed(p) { + t.Errorf("the recall opened %s, which is not one of the places it declares", p) + } + } + for _, d := range declared { + if !slices.Contains(opened, d) { + t.Errorf("the recorder did not see %s opened, so it is not watching what the recall reads", d) + } + } + + // And the recall read what the four places hold, and nothing of the host's. + if len(refused) != 0 { + t.Errorf("refused = %+v", refused) + } + want := []string{"mem-2610111200000001", "mem-2610111200000002", "mem-2610111200000003", "page:fact_eng_auth-flow.md", "page:fact_eng_auth-keys.md"} + if got := slices.Sorted(slices.Values(res.Recalled)); !slices.Equal(got, want) { + t.Errorf("recalled %v, want %v", got, want) + } + if strings.Contains(res.Text, "host's") { + t.Errorf("the host's memory came back:\n%s", res.Text) + } +} + +func writeFile(t *testing.T, p, content string) { + t.Helper() + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/core/recall/pages.go b/internal/core/recall/pages.go new file mode 100644 index 0000000000..35f681eb3a --- /dev/null +++ b/internal/core/recall/pages.go @@ -0,0 +1,77 @@ +package recall + +import ( + "sort" + + "github.com/intentdriven/abcd/internal/core/memory" +) + +// pages.go — memory pages join the recall (spc-2610111627213138 step 3). A +// page is an item when its frontmatter carries a recall: list; its key is +// "page:". + +// PageKeyPrefix begins a page item's key; the rest is its filename. +const PageKeyPrefix = "page:" + +// pageItems turns both page scopes into items, in key order. A page of the +// same __ name in both scopes is the workspace's: the +// narrower scope's page is the page of that name, and the machine's is +// suppressed, whether or not the workspace page carries a recall list, and +// when the workspace page could not be read (refusedHere names those). A +// page with no recall: list is never an item, so it is never a candidate. +func pageItems(workspace, machine []memory.RecallPage, refusedHere ...string) []Item { + shadowed := map[string]bool{} + for _, name := range refusedHere { + shadowed[name] = true + } + var out []Item + for _, p := range workspace { + shadowed[p.Filename] = true + if len(p.Recall) > 0 { + out = append(out, pageItem(p, LayerProject)) + } + } + for _, p := range machine { + if shadowed[p.Filename] || len(p.Recall) == 0 { + continue + } + out = append(out, pageItem(p, LayerMachine)) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +func pageItem(p memory.RecallPage, layer string) Item { + return Item{Key: PageKeyPrefix + p.Filename, Kind: KindPage, Layer: layer, Title: p.Summary, Body: p.Body, + Recall: append([]string(nil), p.Recall...), At: p.IngestedAt} +} + +// readPages reads both page scopes through the memory store's recall read — +// the workspace's .abcd/memory/ under repoRoot, the root the rules loader +// resolved, and the machine's ~/.abcd.noindex/memory/ under home, read only — +// and returns their items. A scope or a page that cannot be read is a +// Refusal and yields nothing, never an error. +func readPages(home, repoRoot string) ([]Item, []Refusal) { + workspace, wr := memory.ReadRecallPages(repoRoot) + machine, mr := memory.ReadMachineRecallPages(home) + var refused []Refusal + var refusedHere []string + for _, r := range wr { + if r.Filename != "" { + refusedHere = append(refusedHere, r.Filename) + } + } + for _, r := range append(wr, mr...) { + refused = append(refused, Refusal{File: r.File, Reason: Reason(r.Err), Kind: KindPage}) + } + return pageItems(workspace, machine, refusedHere...), refused +} + +// ReadItems is every note and page in reach, in id order — notes (mem-…) +// before pages (page:…) — and every layer, scope or page that could not be +// read. It is what the prompt router and `rules --prompt` hand the recall. +func ReadItems(home, repoRoot string) ([]Item, []Refusal) { + notes, refused := readNotes(home, repoRoot) + pages, pr := readPages(home, repoRoot) + return append(notes, pages...), append(refused, pr...) +} diff --git a/internal/core/recall/pages_test.go b/internal/core/recall/pages_test.go new file mode 100644 index 0000000000..97c522e534 --- /dev/null +++ b/internal/core/recall/pages_test.go @@ -0,0 +1,228 @@ +package recall + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/core/rules" +) + +// pagesFixture is a home and a repository with an empty page store in each +// scope: the workspace's .abcd/memory/ and the machine's ~/.abcd.noindex/memory/. +func pagesFixture(t *testing.T) (home, repo, workspace, machine string) { + t.Helper() + home = t.TempDir() + repo = t.TempDir() + workspace = filepath.Join(repo, ".abcd", "memory") + machine = abcdhome.Path(home, "memory") + for _, dir := range []string{workspace, machine} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + return home, repo, workspace, machine +} + +// writePage writes one memory page into dir: a recall: list when recall is +// given, a source block, then body. +func writePage(t *testing.T, dir, name, body string, recall ...string) { + t.Helper() + fm := "---\n" + if len(recall) > 0 { + fm += "recall: [" + strings.Join(recall, ", ") + "]\n" + } + fm += "source:\n class: session_memory\n---\n" + if err := os.WriteFile(filepath.Join(dir, name), []byte(fm+body), 0o600); err != nil { + t.Fatal(err) + } +} + +// noteJSON is a machine-layer note recalled everywhere (root null). +func noteJSON(id, title, body string, recall ...string) string { + return fmt.Sprintf(`{"id": %q, "title": %q, "body": %q, "recall": %s, "root": null, "origin": "agent", + "written_by": "Example:model-1", "written_at": "2026-10-11T12:00:00Z", "session": null}`, id, title, body, jsonList(recall)) +} + +// tiedNoteJSON is a machine-layer note tied to the repository whose root +// commit is root. +func tiedNoteJSON(id, title, body, root string, recall ...string) string { + return fmt.Sprintf(`{"id": %q, "title": %q, "body": %q, "recall": %s, "root": %q, "origin": "agent", + "written_by": "Example:model-1", "written_at": "2026-10-11T12:00:00Z", "session": null}`, id, title, body, jsonList(recall), root) +} + +// projectNoteJSON is a note in the project's file, which carries no root. +func projectNoteJSON(id, title, body string, recall ...string) string { + return fmt.Sprintf(`{"id": %q, "title": %q, "body": %q, "recall": %s, "origin": "agent", + "written_by": "Example:model-1", "written_at": "2026-10-11T12:00:00Z", "session": null}`, id, title, body, jsonList(recall)) +} + +func jsonList(words []string) string { + b, err := json.Marshal(words) + if err != nil { + panic(err) + } + return string(b) +} + +func readAndRecall(t *testing.T, home, repo, prompt string, ledger map[string]string) Result { + t.Helper() + items, refused := ReadItems(home, repo) + if len(refused) != 0 { + t.Fatalf("refused = %+v", refused) + } + return Recall(Request{Items: items, Prompt: prompt, Ledger: ledger, Budget: rules.InjectionBudgetBytes}) +} + +// TestAPageDeclaringAuthComesBackAndNoOther (P1): a page declaring +// recall: [auth, login] comes back on a prompt containing "auth", and no +// other page does. +func TestAPageDeclaringAuthComesBackAndNoOther(t *testing.T) { + home, repo, workspace, machine := pagesFixture(t) + writePage(t, workspace, "fact_eng_auth-flow.md", "# Auth uses short-lived tokens\n\nRefresh them before the hour is out.\n", "auth", "login") + writePage(t, workspace, "fact_eng_billing.md", "# Billing runs nightly\n\nThe auth of the billing job is separate.\n", "billing", "invoice") + writePage(t, machine, "pref_eng_editor.md", "# Prefer short commit subjects\n", "commit") + + res := readAndRecall(t, home, repo, "why does auth keep failing?", nil) + if !slices.Equal(res.Recalled, []string{"page:fact_eng_auth-flow.md"}) { + t.Fatalf("recalled %v, want only the auth page\n%s", res.Recalled, res.Text) + } + for _, want := range []string{"## MEMORY", "page:fact_eng_auth-flow.md", "Auth uses short-lived tokens", "Refresh them before the hour is out."} { + if !strings.Contains(res.Text, want) { + t.Fatalf("the block does not carry %q:\n%s", want, res.Text) + } + } + for _, not := range []string{"Billing runs nightly", "Prefer short commit subjects"} { + if strings.Contains(res.Text, not) { + t.Fatalf("a page that does not declare auth came back (%q):\n%s", not, res.Text) + } + } +} + +// TestRepeatedPromptInjectsMemoryOnce (P3): the same prompt repeated within +// a session injects the matched page once; the loader's backstop prompt, +// which clears both ledgers, brings it back. +func TestRepeatedPromptInjectsMemoryOnce(t *testing.T) { + home, repo, workspace, _ := pagesFixture(t) + writePage(t, workspace, "fact_eng_auth-flow.md", "# Auth uses short-lived tokens\n", "auth") + rs := rules.RuleSet{SchemaVersion: 1, Domains: map[string]rules.Domain{}} + const prompt = "fix the auth bug" + const backstop = 3 + + var state rules.SessionState + var sent []int + for turn := 1; turn <= backstop; turn++ { + inj := rules.Inject(rs, prompt, state, backstop) + res := readAndRecall(t, home, repo, prompt, inj.State.Recalled) + inj.State.Recalled = res.Ledger + state = inj.State + if strings.Contains(res.Text, "Auth uses short-lived tokens") { + sent = append(sent, turn) + } + } + if !slices.Equal(sent, []int{1, backstop}) { + t.Fatalf("the page came back on turns %v, want the first and the backstop's %v", sent, []int{1, backstop}) + } +} + +// TestWorkspacePageSuppressesTheMachinePage (P4): a recall word that hits a +// page of the same name in both scopes brings the workspace page back and +// suppresses the machine page. +func TestWorkspacePageSuppressesTheMachinePage(t *testing.T) { + home, repo, workspace, machine := pagesFixture(t) + writePage(t, workspace, "fact_eng_auth-flow.md", "# The project's auth page\n", "auth") + writePage(t, machine, "fact_eng_auth-flow.md", "# The person's own auth page\n", "auth") + writePage(t, machine, "fact_eng_auth-keys.md", "# Where the person keeps auth keys\n", "auth") + + res := readAndRecall(t, home, repo, "rotate the auth tokens", nil) + want := []string{"page:fact_eng_auth-flow.md", "page:fact_eng_auth-keys.md"} + if !slices.Equal(slices.Sorted(slices.Values(res.Recalled)), want) { + t.Fatalf("recalled %v, want %v", res.Recalled, want) + } + if !strings.Contains(res.Text, "The project's auth page") || strings.Contains(res.Text, "The person's own auth page") { + t.Fatalf("the workspace page did not win over the machine page of the same name:\n%s", res.Text) + } + if !strings.Contains(res.Text, "Where the person keeps auth keys") { + t.Fatalf("a machine page with no workspace twin was suppressed:\n%s", res.Text) + } +} + +// TestPageWithoutRecallIsNeverACandidate: a page with no recall: list is +// silent whatever the prompt says, even its own words. +func TestPageWithoutRecallIsNeverACandidate(t *testing.T) { + home, repo, workspace, machine := pagesFixture(t) + writePage(t, workspace, "fact_eng_auth-flow.md", "# Auth uses short-lived tokens\n") + writePage(t, machine, "fact_eng_auth-keys.md", "# Auth keys live in the keychain\n") + + items, _ := ReadItems(home, repo) + if len(items) != 0 { + t.Fatalf("a page with no recall list is an item: %+v", items) + } + if res := readAndRecall(t, home, repo, "auth tokens short-lived keychain", nil); res.Text != "" { + t.Fatalf("a page with no recall list came back:\n%s", res.Text) + } +} + +// TestPageItemsCarryTheirIngestDate: a page item's At, the ranking's recency +// tie-break, is the page's latest ingested_at. +func TestPageItemsCarryTheirIngestDate(t *testing.T) { + home, repo, workspace, _ := pagesFixture(t) + if err := os.WriteFile(filepath.Join(workspace, "fact_eng_auth-flow.md"), + []byte("---\nrecall: [auth]\nsource:\n class: session_memory\n ingested_at: 2026-10-01\n---\n# Auth\n"), 0o600); err != nil { + t.Fatal(err) + } + items, _ := ReadItems(home, repo) + if len(items) != 1 || items[0].At.Format("2006-01-02") != "2026-10-01" { + t.Fatalf("items = %+v", items) + } +} + +// TestAWorkspacePageShadowsTheMachinePageEvenWhenItIsSilent: the project's +// page of a name is the page of that name whether or not it can come back — +// one with no recall list, and one the read refuses (over the page cap) — +// so the machine's page of the same name never stands in for it. +func TestAWorkspacePageShadowsTheMachinePageEvenWhenItIsSilent(t *testing.T) { + home, repo, workspace, machine := pagesFixture(t) + writePage(t, workspace, "fact_eng_silent.md", "# The project's page, with no recall list\n") + writePage(t, machine, "fact_eng_silent.md", "# The person's page of the same name\n", "auth") + big := filepath.Join(workspace, "fact_eng_too-big.md") + if err := os.WriteFile(big, []byte("---\nrecall: [auth]\n---\n# Big\n"+strings.Repeat("x", 4<<20)), 0o600); err != nil { + t.Fatal(err) + } + writePage(t, machine, "fact_eng_too-big.md", "# The person's page named like the refused one\n", "auth") + + items, refused := ReadItems(home, repo) + if len(items) != 0 { + t.Fatalf("a machine page stood in for the project's page of its name: %+v", items) + } + if len(refused) != 1 || refused[0].File != ".abcd/memory/fact_eng_too-big.md" { + t.Fatalf("refused = %+v", refused) + } +} + +// TestARecalledPageIsLabelledAsAPage: a page's head names it as a page of +// its scope, never as a note — the machine's label "personal note, +// agent-written" would misstate whose words a page is +// (GHSA-22f8-qf5r-gjgq's provenance rule). +func TestARecalledPageIsLabelledAsAPage(t *testing.T) { + home, repo, workspace, machine := pagesFixture(t) + writePage(t, workspace, "lesson_auth_tokens.md", "# Auth tokens\n\nRotate them.\n", "auth") + writePage(t, machine, "lesson_auth_sessions.md", "# Auth sessions\n\nExpire them.\n", "auth") + res := readAndRecall(t, home, repo, "the auth flow", nil) + for _, want := range []string{ + "- page:lesson_auth_tokens.md (project page, committed in this repository): ", + "- page:lesson_auth_sessions.md (machine page, this account's memory store): ", + } { + if !strings.Contains(res.Text, want) { + t.Errorf("block lacks %q:\n%s", want, res.Text) + } + } + if strings.Contains(res.Text, "note") { + t.Errorf("a page is labelled as a note:\n%s", res.Text) + } +} diff --git a/internal/core/recall/ranking.go b/internal/core/recall/ranking.go new file mode 100644 index 0000000000..c49450d730 --- /dev/null +++ b/internal/core/recall/ranking.go @@ -0,0 +1,175 @@ +package recall + +import ( + "math" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/rules" +) + +// ranking.go — the relevance ranking (spc-2610111627213138 step 4): BM25 with +// per-field weights (BM25F; Robertson, Zaragoza and Taylor, "Simple BM25 +// Extension to Multiple Weighted Fields", 2004), over every note and page in +// reach. The query terms are the prompt's stemmed tokens from the loader's own +// prompt index (rules.PromptTerms), and an item's fields are stemmed by the +// same rule (rules.TextStems), so the ranking weighs exactly the words the +// matcher matches. The ranking orders candidates and never adds one; the order +// decides which of them fit the budget. + +// The BM25 parameters, the usual defaults. +const ( + bm25K1 = 1.2 + bm25B = 0.75 +) + +// The fields an item's words are found in, as the diagnostic names them. +const ( + FieldRecall = "recall" + FieldTitle = "title" + FieldBody = "body" +) + +// scoreGrain is the finest difference between two scores the ranked order +// tells apart. +const scoreGrain = 1e-9 + +// fieldWeights is each field's weight, in the order fieldsOf returns them: a +// term found in an item's recall words counts three times, in its title +// twice, in its body once. The weighted frequencies are summed per term +// before saturation, and an item's length is its weighted length, as BM25F's +// one pseudo-document of repeated fields prescribes. +var fieldWeights = [3]float64{3, 2, 1} + +// TermScore is one query term's part in an item's score: how many times it +// was found in each field, and what it contributed. +type TermScore struct { + Term string `json:"term"` + Recall int `json:"recall"` + Title int `json:"title"` + Body int `json:"body"` + Contribution float64 `json:"contribution"` +} + +// Ranked is a candidate with its score and the terms behind it. +type Ranked struct { + Candidate + Score float64 + Terms []TermScore +} + +// itemStats is one item's stemmed fields and weighted length. +type itemStats struct { + fields [3]map[string]int + length float64 +} + +func statsOf(it Item) itemStats { + var st itemStats + for i, text := range [3]string{strings.Join(it.Recall, " "), it.Title, it.Body} { + counts := map[string]int{} + stems := rules.TextStems(text) + for _, s := range stems { + counts[s]++ + } + st.fields[i] = counts + st.length += fieldWeights[i] * float64(len(stems)) + } + return st +} + +// found counts a query term in one field: every token whose stem is among +// the roots the term rule matches the prompt token by. +func found(counts map[string]int, t rules.PromptTerm) int { + n := 0 + for _, root := range t.Roots { + n += counts[root] + } + return n +} + +// Rank matches items against prompt, judges each tied note's root +// (matching, which asks root only when a tied item matched), and returns the +// candidates in ranked order, each with its score and the terms behind it, +// with the root lookup's error beside them. The score is BM25F: +// for each query term t found in the item, +// +// idf(t) · tf_w / (tf_w + k1 · (1 − b + b · len / avg_len)) +// +// summed over the terms, with idf(t) = ln(1 + (N − n_t + 0.5) / (n_t + 0.5)) +// over every item in reach (N items, n_t of them holding t), so a word every +// item shares counts for little and a rare word that matches counts for a +// lot. Every item read is in reach, a personal note tied to another +// repository among them: the tie is judged lazily, only for the candidates, +// so the corpus never depends on whether git was asked. Ties break on the narrower scope (the project's before the +// machine's), then the more recent At, then the key, so the order is the +// same on every machine and every run. +func Rank(items []Item, prompt string, root RootFunc) ([]Ranked, error) { + cands, rootErr := matching(items, prompt, root) + if len(cands) == 0 { + return nil, rootErr + } + terms := rules.PromptTerms(prompt) + stats := make([]itemStats, len(items)) + stat := map[string]int{} + total := 0.0 + for i, it := range items { + stats[i] = statsOf(it) + stat[it.Key] = i + total += stats[i].length + } + avg := total / float64(len(items)) + idf := make([]float64, len(terms)) + for j, t := range terms { + holding := 0 + for _, st := range stats { + for _, counts := range st.fields { + if found(counts, t) > 0 { + holding++ + break + } + } + } + n, nt := float64(len(items)), float64(holding) + idf[j] = math.Log(1 + (n-nt+0.5)/(nt+0.5)) + } + out := make([]Ranked, 0, len(cands)) + for _, c := range cands { + st := stats[stat[c.Item.Key]] + norm := 1.0 + if avg > 0 { + norm = 1 - bm25B + bm25B*st.length/avg + } + r := Ranked{Candidate: c} + for j, t := range terms { + ts := TermScore{Term: t.Stem, Recall: found(st.fields[0], t), Title: found(st.fields[1], t), Body: found(st.fields[2], t)} + tf := fieldWeights[0]*float64(ts.Recall) + fieldWeights[1]*float64(ts.Title) + fieldWeights[2]*float64(ts.Body) + if tf == 0 { + continue + } + ts.Contribution = idf[j] * tf / (tf + bm25K1*norm) + r.Score += ts.Contribution + r.Terms = append(r.Terms, ts) + } + out = append(out, r) + } + sort.SliceStable(out, func(i, j int) bool { return rankedBefore(out[i], out[j]) }) + return out, rootErr +} + +// rankedBefore is the ranked order: the higher score, then the narrower +// scope, then the more recent At, then the key. Scores are compared at a +// billionth (scoreGrain), so two scores equal but for the last bits a +// different machine's float arithmetic may leave tie on every machine. +func rankedBefore(a, b Ranked) bool { + if sa, sb := math.Round(a.Score/scoreGrain), math.Round(b.Score/scoreGrain); sa != sb { + return sa > sb + } + if ap, bp := a.Item.Layer == LayerProject, b.Item.Layer == LayerProject; ap != bp { + return ap + } + if !a.Item.At.Equal(b.Item.At) { + return a.Item.At.After(b.Item.At) + } + return a.Item.Key < b.Item.Key +} diff --git a/internal/core/recall/ranking_test.go b/internal/core/recall/ranking_test.go new file mode 100644 index 0000000000..eeabe72552 --- /dev/null +++ b/internal/core/recall/ranking_test.go @@ -0,0 +1,324 @@ +package recall + +import ( + "flag" + "fmt" + "math" + "math/rand/v2" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/rules" +) + +var update = flag.Bool("update", false, "rewrite testdata/ranking.golden from this run's scores") + +// item is a test item: a note-shaped key in the project's layer, written at +// the zero time, unless the caller changes it. +func item(key string, recall []string, title, body string) Item { + return Item{Key: key, Kind: KindNote, Layer: LayerProject, Title: title, Body: body, Recall: recall} +} + +func rankedKeys(rs []Ranked) []string { + var out []string + for _, r := range rs { + out = append(out, r.Item.Key) + } + return out +} + +// TestRareMatchingWordOutranksACommonOne: the case a raw count of matching +// recall words gets wrong. Every item shares "deploy" and "service"; one +// alone carries "kerberos". The prompt names all three, so a count puts the +// two-word matches first, while the ranking puts the rare word's item first. +func TestRareMatchingWordOutranksACommonOne(t *testing.T) { + items := []Item{ + item("mem-2610111200000001", []string{"deploy", "service"}, "Deploys roll one service at a time", "Wait for health checks."), + item("mem-2610111200000002", []string{"deploy", "service"}, "A service deploy needs a ticket", "File it first."), + item("mem-2610111200000003", []string{"deploy", "service"}, "Deploy windows are mornings", "Never on a Friday."), + item("mem-2610111200000004", []string{"deploy", "service"}, "Service logs rotate daily", "Grab them early."), + item("mem-2610111200000005", []string{"deploy", "service"}, "Each service owns its deploy script", "Keep it in the repo."), + item("mem-2610111200000006", []string{"kerberos"}, "Kerberos tickets expire in ten hours", "Renew before the long job."), + } + const prompt = "deploy the service behind kerberos" + + // The fixture is the case a count gets wrong: by matching words, the + // kerberos item is last. + count := slices.Clone(candidates(items, prompt)) + slices.SortStableFunc(count, func(a, b Candidate) int { return len(b.Hits) - len(a.Hits) }) + if count[0].Item.Key == "mem-2610111200000006" { + t.Fatal("the fixture does not make a raw count put the common words first") + } + + ranked := rankOf(items, prompt) + if len(ranked) != len(items) || ranked[0].Item.Key != "mem-2610111200000006" { + t.Fatalf("ranked %v, want the rare word's item first", rankedKeys(ranked)) + } + if ranked[0].Score <= ranked[1].Score { + t.Fatalf("the rare word's item does not outscore the rest: %v vs %v", ranked[0].Score, ranked[1].Score) + } +} + +// TestRecallFieldOutweighsBody: a term found in an item's recall words +// counts three times, in its title twice, in its body once. Three items of +// the same weighted length differ only in where "auth" is found. +func TestRecallFieldOutweighsBody(t *testing.T) { + items := []Item{ + item("mem-2610111200000001", []string{"token"}, "", "auth filler filler filler"), // body: 3 + 4 = 7 + item("mem-2610111200000002", []string{"token"}, "auth", "filler filler"), // title: 3 + 2 + 2 = 7 + item("mem-2610111200000003", []string{"token", "auth"}, "", "filler"), // recall: 6 + 1 = 7 + } + ranked := rankOf(items, "the auth token") + want := []string{"mem-2610111200000003", "mem-2610111200000002", "mem-2610111200000001"} + if got := rankedKeys(ranked); !slices.Equal(got, want) { + t.Fatalf("ranked %v, want recall over title over body %v", got, want) + } + if !(ranked[0].Score > ranked[1].Score && ranked[1].Score > ranked[2].Score) { + t.Fatalf("scores do not fall with the field's weight: %v %v %v", ranked[0].Score, ranked[1].Score, ranked[2].Score) + } +} + +// TestTiesBreakOnScopeThenRecencyThenID: items of equal score come in the +// narrower scope first, then the more recent, then by key. +func TestTiesBreakOnScopeThenRecencyThenID(t *testing.T) { + older := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC) + mk := func(key, layer string, at time.Time) Item { + it := item(key, []string{"auth"}, "The same lesson", "The same words.") + it.Layer, it.At = layer, at + return it + } + items := []Item{ + mk("mem-2610111200000001", LayerMachine, newer), + mk("mem-2610111200000004", LayerProject, older), + mk("mem-2610111200000003", LayerProject, newer), + mk("mem-2610111200000002", LayerProject, newer), + mk("mem-2610111200000005", LayerProject, time.Time{}), + } + ranked := rankOf(items, "auth") + for _, r := range ranked[1:] { + if r.Score != ranked[0].Score { + t.Fatalf("the fixture's scores differ, so it tests no tie: %v", ranked) + } + } + want := []string{"mem-2610111200000002", "mem-2610111200000003", "mem-2610111200000004", "mem-2610111200000005", "mem-2610111200000001"} + if got := rankedKeys(ranked); !slices.Equal(got, want) { + t.Fatalf("ranked %v, want %v", got, want) + } +} + +// TestRankingIsDeterministicAcrossRuns: the same items in any order give the +// same ranking, scores and reasons included. +func TestRankingIsDeterministicAcrossRuns(t *testing.T) { + items := goldenCorpus() + const prompt = "the auth tokens expire before the deploy" + want := fmt.Sprint(rankOf(items, prompt)) + r := rand.New(rand.NewPCG(1, 2)) + for i := 0; i < 50; i++ { + shuffled := slices.Clone(items) + r.Shuffle(len(shuffled), func(a, b int) { shuffled[a], shuffled[b] = shuffled[b], shuffled[a] }) + if got := fmt.Sprint(rankOf(shuffled, prompt)); got != want { + t.Fatalf("run %d ranked differently:\n%s\n---\n%s", i, got, want) + } + } +} + +// TestRankingDecidesWhichItemsFitTheBudget: when only one of two candidates +// fits, the higher-ranked survives, not the one first in id order. +func TestRankingDecidesWhichItemsFitTheBudget(t *testing.T) { + filler := strings.TrimSpace(strings.Repeat("words ", 150)) + items := []Item{ + item("mem-2610111200000001", []string{"deploy"}, "A deploy lesson", filler), + item("mem-2610111200000002", []string{"deploy", "kerberos"}, "A kerberos deploy lesson", filler), + } + budget := len(headerLine) + len(renderItem(items[1])) + 10 + res := Recall(Request{Items: items, Prompt: "deploy with kerberos", Budget: budget}) + if !slices.Equal(res.Recalled, []string{"mem-2610111200000002"}) || !slices.Equal(res.Omitted, []string{"mem-2610111200000001"}) { + t.Fatalf("recalled %v omitted %v, want the higher-ranked item to fit", res.Recalled, res.Omitted) + } + // With room for both, they come back in ranked order. + all := Recall(Request{Items: items, Prompt: "deploy with kerberos", Budget: rules.InjectionBudgetBytes}) + if !slices.Equal(all.Recalled, []string{"mem-2610111200000002", "mem-2610111200000001"}) { + t.Fatalf("recalled %v, want ranked order", all.Recalled) + } +} + +// TestRankReasonsAddUpToTheScore: each matched term names the fields it was +// found in, and the contributions sum to the score. +func TestRankReasonsAddUpToTheScore(t *testing.T) { + for _, r := range rankOf(goldenCorpus(), "the auth tokens expire before the deploy") { + sum := 0.0 + for _, ts := range r.Terms { + if ts.Recall+ts.Title+ts.Body == 0 || ts.Contribution <= 0 { + t.Fatalf("%s: a term with no field or no contribution: %+v", r.Item.Key, ts) + } + sum += ts.Contribution + } + if math.Abs(sum-r.Score) > 1e-12 { + t.Fatalf("%s: contributions sum to %v, score %v", r.Item.Key, sum, r.Score) + } + } +} + +// goldenCorpus is a small fixed corpus of notes and pages. +func goldenCorpus() []Item { + at := func(s string) time.Time { + tm, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return tm + } + return []Item{ + {Key: "mem-2610111200000001", Kind: KindNote, Layer: LayerMachine, At: at("2026-10-11T12:00:00Z"), + Title: "Auth tokens expire hourly", Body: "Refresh the token before the hour is out.", Recall: []string{"auth", "token"}}, + {Key: "mem-2610111200000002", Kind: KindNote, Layer: LayerProject, At: at("2026-10-10T09:30:00Z"), + Title: "Deploys need the dry-run flag", Body: "Pass --dry-run to the deploy script first.", Recall: []string{"deploy"}}, + {Key: "mem-2610111200000003", Kind: KindNote, Layer: LayerMachine, At: at("2026-10-09T08:00:00Z"), + Title: "The tests need the race flag", Body: "Run the race-enabled tests before saying they pass.", Recall: []string{"test", "race"}}, + {Key: "page:fact_eng_auth-flow.md", Kind: KindPage, Layer: LayerProject, At: at("2026-10-01T00:00:00Z"), + Title: "Auth uses short-lived tokens", Body: "Tokens expire after an hour; the deploy rotates the signing key.", Recall: []string{"auth", "login"}}, + {Key: "page:fact_eng_deploy-window.md", Kind: KindPage, Layer: LayerMachine, At: at("2026-09-20T00:00:00Z"), + Title: "Deploy windows", Body: "Deploy in the morning, never before a holiday.", Recall: []string{"deploy", "release"}}, + } +} + +// TestRankingGoldenScores: the scores of a small fixed corpus, so a change to +// the formula, its weights or its parameters is a visible diff of +// testdata/ranking.golden (go test -run TestRankingGoldenScores -update). +func TestRankingGoldenScores(t *testing.T) { + var b strings.Builder + for _, prompt := range []string{ + "the auth tokens expire before the deploy", + "deploy the release", + "run the tests with the race detector", + "login fails after the token refresh", + } { + fmt.Fprintf(&b, "prompt: %s\n", prompt) + for _, r := range rankOf(goldenCorpus(), prompt) { + fmt.Fprintf(&b, " %s %.6f\n", r.Item.Key, r.Score) + for _, ts := range r.Terms { + fmt.Fprintf(&b, " %s recall=%d title=%d body=%d %.6f\n", ts.Term, ts.Recall, ts.Title, ts.Body, ts.Contribution) + } + } + } + golden := filepath.Join("testdata", "ranking.golden") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(golden, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("%v (run with -update to write it)", err) + } + if b.String() != string(want) { + t.Fatalf("the ranking's scores changed; if the change is meant, run with -update and review the diff:\n%s", b.String()) + } +} + +// TestRankingFindsATermWhereTheMatcherHits: a single-word recall word is +// found among the prompt's query terms exactly when the loader's term rule +// hits it, so the ranking weighs the words the matcher matched and no others. +func TestRankingFindsATermWhereTheMatcherHits(t *testing.T) { + words := []string{"test", "tests", "merge", "commit", "attest", "docs", "push", "release"} + prompts := []string{ + "the tests keep failing", "merging the branch", "an attestation", "committing the fix", + "pushes to the remote", "update the docs", "testing the release", "paint a landscape", + } + for _, p := range prompts { + hit := rules.TermMatcher(p) + terms := rules.PromptTerms(p) + for _, w := range words { + stems := rules.TextStems(w) + found := false + for _, term := range terms { + if len(stems) == 1 && slices.Contains(term.Roots, stems[0]) { + found = true + } + } + if found != hit(w) { + t.Errorf("%q in %q: found among the query terms = %v, the term rule hits = %v", w, p, found, hit(w)) + } + } + } +} + +// TestAnItemLargerThanTheWholeBudgetDoesNotBlockTheRest: an item whose block +// alone is larger than the whole per-turn budget can never come back in +// full, so it is set aside as too large, never recorded, and the items +// ranked below it still come back; one that only misses this turn's +// remainder still stops the take, as before. +func TestAnItemLargerThanTheWholeBudgetDoesNotBlockTheRest(t *testing.T) { + huge := strings.TrimSpace(strings.Repeat("kerberos deploy notes ", rules.InjectionBudgetBytes/20)) + items := []Item{ + item("page:fact_eng_huge.md", []string{"deploy", "kerberos"}, "Every kerberos deploy ever", huge), + item("mem-2610111200000002", []string{"deploy"}, "Deploys need the dry-run flag", "Pass it first."), + } + ranked := rankOf(items, "deploy with kerberos") + if ranked[0].Item.Key != "page:fact_eng_huge.md" { + t.Fatalf("the fixture does not rank the huge page first: %v", rankedKeys(ranked)) + } + res := Recall(Request{Items: items, Prompt: "deploy with kerberos", Budget: rules.InjectionBudgetBytes}) + if !slices.Equal(res.Recalled, []string{"mem-2610111200000002"}) || !slices.Equal(res.TooLarge, []string{"page:fact_eng_huge.md"}) || + len(res.Omitted) != 0 { + t.Fatalf("recalled %v too large %v omitted %v", res.Recalled, res.TooLarge, res.Omitted) + } + if _, ok := res.Ledger["page:fact_eng_huge.md"]; ok { + t.Fatal("an item that never came back was recorded as sent") + } + if len(res.Text) > rules.InjectionBudgetBytes { + t.Fatalf("the block is over the budget: %d", len(res.Text)) + } + // With the rules leaving too little for the note this turn, the note is + // omitted and tried again, and the huge page is still only too large. + tight := Recall(Request{Items: items, Prompt: "deploy with kerberos", Budget: 10}) + if len(tight.Recalled) != 0 || !slices.Equal(tight.Omitted, []string{"mem-2610111200000002"}) || !slices.Equal(tight.TooLarge, []string{"page:fact_eng_huge.md"}) { + t.Fatalf("recalled %v too large %v omitted %v", tight.Recalled, tight.TooLarge, tight.Omitted) + } +} + +// TestOneWordInTwoInflectionsIsOneTerm: a prompt that says a word twice in +// two inflections ("merge before merging") asks for one term, so an item's +// word is found, and counted, once — never under two terms, each with its +// own idf. +func TestOneWordInTwoInflectionsIsOneTerm(t *testing.T) { + for _, prompt := range []string{"merge before merging", "merging before the merge", "commit after committing the fix"} { + terms := rules.PromptTerms(prompt) + owner := map[string]string{} + for _, term := range terms { + for _, root := range term.Roots { + if other, ok := owner[root]; ok { + t.Fatalf("%q: root %q belongs to two terms, %q and %q", prompt, root, other, term.Stem) + } + owner[root] = term.Stem + } + } + } + ranked := rankOf([]Item{item("mem-2610111200000001", []string{"merge"}, "Merges need a review", "Ask first.")}, "merge before merging") + if len(ranked) != 1 { + t.Fatalf("ranked = %+v", ranked) + } + var found []string + for _, ts := range ranked[0].Terms { + if ts.Recall > 0 { + found = append(found, ts.Term) + } + } + if len(found) != 1 { + t.Fatalf("the recall word was found under %d terms %v: %+v", len(found), found, ranked[0].Terms) + } +} + +// rankOf is Rank over items tied to no repository, where no root is asked. +func rankOf(items []Item, prompt string) []Ranked { + ranked, _ := Rank(items, prompt, nil) + return ranked +} diff --git a/internal/core/recall/recall.go b/internal/core/recall/recall.go index e82e6a42b8..31c52ace18 100644 --- a/internal/core/recall/recall.go +++ b/internal/core/recall/recall.go @@ -4,11 +4,13 @@ import ( "fmt" "hash/fnv" "strings" + + "github.com/intentdriven/abcd/internal/core/rules" ) // Request is one prompt's recall. type Request struct { - // Items are what the memory layers yield (noteItems, ReadNotes). + // Items are every note and page in reach (ReadItems). Items []Item // Prompt is the prompt the hook was handed. Prompt string @@ -22,20 +24,24 @@ type Request struct { // switch silences it too. Disabled bool // Root answers the repository's root commit, for the items tied to one; - // asked only once a tied item matched (Matching). + // asked only once a tied item matched (matching). Root RootFunc } // Result is one prompt's recall. Text is empty when nothing new comes back. // Recalled names each item Text carries, in order; Omitted each new -// candidate that did not fit the budget, not recorded and so retried on the -// next turn. Ledger is the updated recalled ledger, a copy. RootErr is the +// candidate that did not fit this turn's budget, not recorded and so retried +// on the next turn; TooLarge each new candidate whose block alone is larger +// than the whole per-turn budget (rules.InjectionBudgetBytes), which no turn +// can carry, so it is set aside without stopping the take and never +// recorded. Ledger is the updated recalled ledger, a copy. RootErr is the // root-commit lookup's error (git did not answer in time) when a tied item // matched and was left out for it — unrecorded, so retried on the next turn. type Result struct { Text string Recalled []string Omitted []string + TooLarge []string Ledger map[string]string RootErr error @@ -59,12 +65,15 @@ func (r Result) Labels(keys []string) []string { return out } -// Recall matches the items against the prompt, drops every candidate already -// sent this session with unchanged content, and takes the rest in id order -// while they fit the budget: the first that does not fit stops the take, so -// an item never comes back ahead of one before it in the order. Its only I/O -// is req.Root, asked when a tied item matched, and it never reflects prompt -// bytes into the block. +// Recall matches the items against the prompt, judges each tied note's root +// (matching), ranks the candidates (Rank), drops every one already sent this +// session with unchanged content, and takes the rest in ranked order while +// they fit the budget: the first that does not fit stops the take, so an +// item never comes back ahead of one ranked above it that a later turn can +// still carry. An item larger than the whole per-turn budget is set aside as +// TooLarge instead, so it cannot hold back every item below it on every +// turn. Its only I/O is req.Root, asked when a tied item matched, and it +// never reflects prompt bytes into the block. func Recall(req Request) Result { ledger := map[string]string{} for k, v := range req.Ledger { @@ -74,10 +83,10 @@ func Recall(req Request) Result { if req.Disabled { return res } - cands, rootErr := Matching(req.Items, req.Prompt, req.Root) + ranked, rootErr := Rank(req.Items, req.Prompt, req.Root) res.RootErr = rootErr var fresh []Item - for _, c := range cands { + for _, c := range ranked { if ledger[c.Item.Key] == signature(c.Item) { continue } @@ -92,18 +101,21 @@ func Recall(req Request) Result { } var b strings.Builder size := len(headerLine) - for i, it := range fresh { + stopped := false + for _, it := range fresh { block := renderItem(it) - if size+len(block) > req.Budget { - for _, rest := range fresh[i:] { - res.Omitted = append(res.Omitted, rest.Key) - } - break + switch { + case len(headerLine)+len(block) > rules.InjectionBudgetBytes: + res.TooLarge = append(res.TooLarge, it.Key) + case stopped || size+len(block) > req.Budget: + stopped = true + res.Omitted = append(res.Omitted, it.Key) + default: + b.WriteString(block) + size += len(block) + ledger[it.Key] = signature(it) + res.Recalled = append(res.Recalled, it.Key) } - b.WriteString(block) - size += len(block) - ledger[it.Key] = signature(it) - res.Recalled = append(res.Recalled, it.Key) } if len(res.Recalled) > 0 { res.Text = headerLine + b.String() diff --git a/internal/core/recall/recall_test.go b/internal/core/recall/recall_test.go index 644438d8f1..764d5066ee 100644 --- a/internal/core/recall/recall_test.go +++ b/internal/core/recall/recall_test.go @@ -83,7 +83,7 @@ func TestANoteInTheProjectsFileIsRecalled(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, ".abcd", "memory.json"), []byte(project), 0o644); err != nil { t.Fatal(err) } - items, refused := ReadNotes(t.TempDir(), repo) + items, refused := readNotes(t.TempDir(), repo) if len(refused) != 0 { t.Fatalf("refused = %+v", refused) } @@ -157,39 +157,6 @@ func TestMemoryTakesOnlyWhatTheRulesLeaveOfTheBudget(t *testing.T) { } } -// TestCandidatesThatFitAreTakenInIDOrder: until step 4's ranking, the -// candidates are taken in id order, and when only some fit, the survivors are -// the first in that order — the first that does not fit stops the take, so a -// later item never comes back ahead of an earlier one. -func TestCandidatesThatFitAreTakenInIDOrder(t *testing.T) { - body := strings.TrimSpace(strings.Repeat("word ", 150)) // about 750 bytes - notes := []memnote.Note{ - note("mem-2610111200000003", "Third", body, "", "test"), - note("mem-2610111200000001", "First", body, "", "test"), - note("mem-2610111200000004", "Fourth", "Short.", "", "test"), - note("mem-2610111200000002", "Second", body, "", "test"), - } - items := noteItems(notes, nil) - all := recallText(items, "the tests") - want := []string{"mem-2610111200000001", "mem-2610111200000002", "mem-2610111200000003", "mem-2610111200000004"} - if !slices.Equal(all.Recalled, want) { - t.Fatalf("recalled %v, want id order %v", all.Recalled, want) - } - if i, j := strings.Index(all.Text, "First"), strings.Index(all.Text, "Second"); i < 0 || j < i { - t.Fatalf("the block is not in id order:\n%s", all.Text) - } - // Room for two of the long ones: the first two in id order survive, and - // the short fourth, which would fit, does not jump the queue. - budget := len(headerLine) + 2*len(renderItem(items[0])) + 10 - some := Recall(Request{Items: items, Prompt: "the tests", Budget: budget}) - if !slices.Equal(some.Recalled, want[:2]) || !slices.Equal(some.Omitted, want[2:]) { - t.Fatalf("recalled %v omitted %v", some.Recalled, some.Omitted) - } - if len(some.Text) > budget { - t.Fatalf("the block is over its budget: %d > %d", len(some.Text), budget) - } -} - // TestProjectNoteSuppressesTheMachineCopy: an id in both layers (copied by // hand) is the project's. func TestProjectNoteSuppressesTheMachineCopy(t *testing.T) { @@ -348,7 +315,10 @@ func TestRootIsAskedOnlyForAMatchedTiedNote(t *testing.T) { if calls != 1 { t.Fatalf("a prompt matching three tied notes asked for the root %d times, want 1", calls) } - if !slices.Equal(res.Recalled, []string{"mem-2610111200000001", "mem-2610111200000002"}) { + // The ranking orders them (the second matches two terms); the test + // judges which came back, not their order. + got := slices.Sorted(slices.Values(res.Recalled)) + if !slices.Equal(got, []string{"mem-2610111200000001", "mem-2610111200000002"}) { t.Fatalf("recalled %v, want the two tied to this repository", res.Recalled) } } @@ -356,7 +326,7 @@ func TestRootIsAskedOnlyForAMatchedTiedNote(t *testing.T) { // TestReadLayersIsTheOneReader (F3): ReadLayers reads both note layers as // they stand — the notes in full, for the memory verb's list — and names a // layer it could not read, home-redacted, while still reading the other. -// ReadNotes is the same read turned into items. +// readNotes is the same read turned into items. func TestReadLayersIsTheOneReader(t *testing.T) { home, repo := t.TempDir(), t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { @@ -382,8 +352,8 @@ func TestReadLayersIsTheOneReader(t *testing.T) { if len(refused) != 1 || refused[0].File != memnote.MachineDisplayPath || strings.Contains(refused[0].Reason, home) { t.Fatalf("refused = %+v", refused) } - items, again := ReadNotes(home, repo) + items, again := readNotes(home, repo) if len(items) != 1 || items[0].Key != "mem-2610111200004321" || !slices.Equal(again, refused) { - t.Fatalf("ReadNotes = %+v, %+v", items, again) + t.Fatalf("readNotes = %+v, %+v", items, again) } } diff --git a/internal/core/recall/render.go b/internal/core/recall/render.go index 80b707ff46..a556f718d0 100644 --- a/internal/core/recall/render.go +++ b/internal/core/recall/render.go @@ -15,20 +15,31 @@ const headerLine = "## MEMORY\n" // the repository's authors committed, so a cloned or compromised repository // controls it; a note in the machine's file was written by an agent on this // account through the write gate. +// A page is labelled as a page of its scope: the project's .abcd/memory/ is +// committed like the project's file, and the machine's ~/.abcd.noindex/memory/ +// is this account's store, which no agent writes through the note gate. const ( - labelProject = "project file, committed in this repository" - labelMachine = "personal note, agent-written" + labelProject = "project file, committed in this repository" + labelMachine = "personal note, agent-written" + labelPageProject = "project page, committed in this repository" + labelPageMachine = "machine page, this account's memory store" ) -// layerLabel is the provenance an item's head carries: "project file, -// committed in this repository" for the project's file, "personal note, -// agent-written" for the machine's. An unknown layer is named as itself, so -// a new layer is never rendered as one of these by default. -func layerLabel(layer string) string { - switch layer { - case LayerProject: +// layerLabel is the provenance an item's head carries: for a note, "project +// file, committed in this repository" for the project's file and "personal +// note, agent-written" for the machine's; for a page, "project page, +// committed in this repository" or "machine page, this account's memory +// store". An unknown layer is named as itself, so a new layer is never +// rendered as one of these by default. +func layerLabel(kind, layer string) string { + switch { + case kind == KindPage && layer == LayerProject: + return labelPageProject + case kind == KindPage && layer == LayerMachine: + return labelPageMachine + case layer == LayerProject: return labelProject - case LayerMachine: + case layer == LayerMachine: return labelMachine } return layer @@ -44,7 +55,7 @@ func layerLabel(layer string) string { // every line sits at the bullet's content column. The head line, a one-line // title, takes the rule body's mask (rules.SanitizeRuleBody). func renderItem(it Item) string { - head := rules.SanitizeRuleBody(it.Key + " (" + layerLabel(it.Layer) + "): " + it.Title) + head := rules.SanitizeRuleBody(it.Key + " (" + layerLabel(it.Kind, it.Layer) + "): " + it.Title) var b strings.Builder b.WriteString("- ") b.WriteString(head) diff --git a/internal/core/recall/testdata/ranking.golden b/internal/core/recall/testdata/ranking.golden new file mode 100644 index 0000000000..b52ab00421 --- /dev/null +++ b/internal/core/recall/testdata/ranking.golden @@ -0,0 +1,44 @@ +prompt: the auth tokens expire before the deploy + mem-2610111200000001 2.310254 + the recall=0 title=0 body=2 0.055313 + auth recall=1 title=1 body=0 0.712209 + token recall=1 title=1 body=1 0.735054 + expire recall=0 title=1 body=0 0.556533 + before recall=0 title=0 body=1 0.251145 + page:fact_eng_auth-flow.md 1.952974 + the recall=0 title=0 body=2 0.052127 + auth recall=1 title=1 body=0 0.690600 + token recall=0 title=1 body=1 0.605377 + expire recall=0 title=0 body=1 0.374378 + deploy recall=0 title=0 body=1 0.230492 + page:fact_eng_deploy-window.md 0.776718 + the recall=0 title=0 body=1 0.043674 + before recall=0 title=0 body=1 0.270539 + deploy recall=1 title=1 body=1 0.462505 + mem-2610111200000002 0.512505 + the recall=0 title=1 body=1 0.062379 + deploy recall=1 title=1 body=1 0.450126 +prompt: deploy the release + page:fact_eng_deploy-window.md 1.547903 + deploy recall=1 title=1 body=1 0.462505 + the recall=0 title=0 body=1 0.043674 + release recall=1 title=0 body=0 1.041724 + mem-2610111200000002 0.512505 + deploy recall=1 title=1 body=1 0.450126 + the recall=0 title=1 body=1 0.062379 +prompt: run the tests with the race detector + mem-2610111200000003 2.709912 + run recall=0 title=0 body=1 0.374378 + the recall=0 title=2 body=1 0.068638 + test recall=1 title=1 body=1 1.133448 + race recall=1 title=1 body=1 1.133448 +prompt: login fails after the token refresh + page:fact_eng_auth-flow.md 2.208935 + login recall=1 title=0 body=0 0.958608 + after recall=0 title=0 body=1 0.592823 + the recall=0 title=0 body=2 0.052127 + token recall=0 title=1 body=1 0.605377 + mem-2610111200000001 1.436311 + the recall=0 title=0 body=2 0.055313 + token recall=1 title=1 body=1 0.735054 + refresh recall=0 title=0 body=1 0.645944 diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index dcd7239831..f3edd2df82 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -707,6 +707,19 @@ type promptIndex struct { padded string // " tok tok " for boundary/phrase matching stemmedPadded string // " stem stem " for stemmed phrase matching stems map[string]bool // candidate stems of the single tokens + terms []PromptTerm // each distinct word, with its candidate roots +} + +// PromptTerm is one distinct word of a prompt, as the memory recall's +// ranking weighs it (spc-2610111627213138 step 4): Stem is the root stem +// gives the token that first used it, and Roots every candidate root the +// term rule matches its tokens by (stemVariants), so a word whose stem is +// among them is the term found exactly where termHit would hit it. Tokens +// whose roots overlap are one term ("merge" and "merging"), so no root +// belongs to two terms and a word in an item is found under one term at most. +type PromptTerm struct { + Stem string + Roots []string } // indexPrompt lowercases, collapses non-alphanumeric runs to single spaces, and @@ -717,16 +730,73 @@ func indexPrompt(s string) promptIndex { idx := promptIndex{padded: " " + collapsed + " ", stems: map[string]bool{}} tokens := strings.Fields(collapsed) stemmed := make([]string, 0, len(tokens)) + var terms promptTerms for _, tok := range tokens { - for _, v := range stemVariants(tok) { + variants := stemVariants(tok) + for _, v := range variants { idx.stems[v] = true } - stemmed = append(stemmed, stem(tok)) + s := stem(tok) + stemmed = append(stemmed, s) + terms.add(s, variants) } + idx.terms = terms.list() idx.stemmedPadded = " " + strings.Join(stemmed, " ") + " " return idx } +// promptTerms collects a prompt's terms, keeping each root in one term. +type promptTerms struct { + terms []PromptTerm + owner map[string]int // root -> index of the term it belongs to +} + +// add adds one token, its stem s and its candidate roots: into the earliest +// term any of its roots already belongs to, merging in every other term +// those roots belong to, or as a new term when none does. Each root is +// looked up once, so a long prompt costs no more than its tokens. +func (pt *promptTerms) add(s string, roots []string) { + if pt.owner == nil { + pt.owner = map[string]int{} + } + target := -1 + for _, r := range roots { + if i, ok := pt.owner[r]; ok && (target < 0 || i < target) { + target = i + } + } + if target < 0 { + target = len(pt.terms) + pt.terms = append(pt.terms, PromptTerm{Stem: s}) + } + for _, r := range roots { + i, ok := pt.owner[r] + switch { + case !ok: + pt.owner[r] = target + pt.terms[target].Roots = append(pt.terms[target].Roots, r) + case i != target: + for _, moved := range pt.terms[i].Roots { + pt.owner[moved] = target + } + pt.terms[target].Roots = append(pt.terms[target].Roots, pt.terms[i].Roots...) + pt.terms[i].Roots = nil + } + } +} + +// list is the terms in the order the prompt first used them, less every +// term merged into an earlier one. +func (pt *promptTerms) list() []PromptTerm { + var out []PromptTerm + for _, t := range pt.terms { + if len(t.Roots) > 0 { + out = append(out, t) + } + } + return out +} + // hit reports whether any of a domain's recall keywords or aliases match. func (idx promptIndex) hit(d Domain) bool { for _, term := range d.Recall { @@ -1109,6 +1179,26 @@ func TermMatcher(prompt string) func(term string) bool { return idx.termHit } +// PromptTerms is the prompt index's query terms for the memory recall's +// ranking: each distinct word of the prompt, in the order the prompt first +// uses it, with its candidate roots. It reads the bytes TermMatcher +// reads, from the same index. +func PromptTerms(prompt string) []PromptTerm { + return indexPrompt(CapPrompt(prompt)).terms +} + +// TextStems is the stem of each token of text, in order — lower-cased, split +// on every run of characters that is not a letter or a digit, each token +// stemmed as termHit stems a recall keyword's token — so the ranking counts a +// note's or page's words in the form the term rule compares them in. +func TextStems(text string) []string { + tokens := strings.Fields(nonAlnumRe.ReplaceAllString(strings.ToLower(text), " ")) + for i, tok := range tokens { + tokens[i] = stem(tok) + } + return tokens +} + // Hits names what made d match prompt, for the `rules --prompt` diagnostic: // "*NAME" when a star-command activated it, otherwise each recall word and // alias that hits, in the order the domain declares them. It says nothing of diff --git a/internal/fsutil/home.go b/internal/fsutil/home.go index e8602feefb..e4fc384dbb 100644 --- a/internal/fsutil/home.go +++ b/internal/fsutil/home.go @@ -441,6 +441,18 @@ func OpenHomeScope(home, dir string) (*os.Root, error) { return openHomeScope(home, dir, false, 0, nil) } +// OpenHomeDeclarationScope is OpenHomeScope for a reader that takes every +// file below dir as the caller's word, as ReadHomeDeclaration takes one: +// each directory level below home is also judged on its own descriptor, and +// one every account can write, or one owned by an account that is neither +// this uid nor root, is refused with a *HomeScopeExposedError naming it +// (ErrHomeScopeExposed), because whoever can change the directory can put a +// file of the caller's own shape in it. The files read through the root +// still need their own guards (ReadHomeDeclarationDenyingIn). +func OpenHomeDeclarationScope(home, dir string) (*os.Root, error) { + return openHomeScope(home, dir, false, 0, vetDeclarationDir) +} + // EnsureHomeScope is OpenHomeScope for a writer: each missing level is created // at perm (a single mkdir relative to the level above, which never follows a // link at the name it creates) before it is judged and opened, so the walk diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index b823518f5e..c5775be57a 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -1712,20 +1712,21 @@ func newHookCommand(asJSON *bool) *cobra.Command { // prompt-router — UserPromptSubmit: recall-match, dedup, inject. hookCmd.AddCommand(&cobra.Command{ Use: "prompt-router", - Short: "UserPromptSubmit: inject the rules and memory notes matching the prompt", + Short: "UserPromptSubmit: inject the rules, memory notes and pages matching the prompt", Long: `Plain, stdout is the rendered rule block the host injects, followed by a -"## MEMORY" block of the memory notes the prompt recalls, and nothing on a -turn with nothing new. With --json, stdout is one envelope in place of the +"## MEMORY" block of the memory notes and pages the prompt recalls, and nothing +on a turn with nothing new. With --json, stdout is one envelope in place of the generic machine-reader shape: {"text": ..., "injected": [...], "recalled": [...], "active": [...], "error": ...} text is byte for byte what the plain form writes; injected names the domains -text carries, and recalled the notes; active is the full set of domains in +text carries, and recalled the notes and pages (a note by its id, a page as +page:); active is the full set of domains in force this turn, so a name missing from it has stopped. error is present only when the prompt could not be evaluated, and then active is absent: the set is -unknown. A memory.json that cannot be read is named on stderr and recalls -nothing, and the rules still inject. Every outcome, an error included, +unknown. A memory.json, page store or page that cannot be read is named on +stderr and recalls nothing, and the rules still inject. Every outcome, an error included, exits 0, so the hook can never wedge a session. After them comes a "## STATUS" block when the last fetch of the status @@ -1859,17 +1860,21 @@ envelope carries it as system_message.`, // whose words went into the context (GHSA-22f8-qf5r-gjgq). fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: turn %d, injected %d domain(s) %v, %d bytes\n", res.State.Count, len(res.Injected), res.Labels(), len(res.Text)) - // Each note id carries its layer ("mem-… (project)"), as the - // rules line's names carry theirs, so the log says whether the - // words came from this repository's committed file or from the - // person's own notes. - if len(mem.Recalled) > 0 || len(mem.Omitted) > 0 { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd memory: recalled %d note(s) [%s], %d bytes", len(mem.Recalled), + // Each item carries its layer ("mem-… (project)", "page:… (machine)"), + // as the rules line's names carry theirs, so the log says whether + // the words came from this repository's committed files or from + // the person's own. + if len(mem.Recalled) > 0 || len(mem.Omitted) > 0 || len(mem.TooLarge) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd memory: recalled %d item(s) [%s], %d bytes", len(mem.Recalled), strings.Join(mem.Labels(mem.Recalled), ", "), len(mem.Text)) if len(mem.Omitted) > 0 { - fmt.Fprintf(cmd.ErrOrStderr(), "; %d matched note(s) [%s] did not fit this turn's budget and are tried again on the next", + fmt.Fprintf(cmd.ErrOrStderr(), "; %d matched item(s) [%s] did not fit this turn's budget and are tried again on the next", len(mem.Omitted), strings.Join(mem.Labels(mem.Omitted), ", ")) } + if len(mem.TooLarge) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "; %d matched item(s) [%s] are larger than the whole per-turn budget and do not come back in full", + len(mem.TooLarge), strings.Join(mem.Labels(mem.TooLarge), ", ")) + } fmt.Fprintln(cmd.ErrOrStderr()) } text := res.Text + mem.Text @@ -2630,8 +2635,14 @@ its .abcd/rules.json. --prompt "" shows what that one prompt would bring back to a fresh session: each rule domain it matches, with the recall words that hit, and each memory note it recalls from ` + abcdhome.Display("memory.json") + ` and .abcd/memory.json, -with its layer and the recall words that hit, each in full or as not fitting -the per-turn budget. It reads against an empty ledger and writes nothing. +and each memory page from .abcd/memory/ and ` + abcdhome.Display("memory") + `/, with its +layer and the recall words that hit, each in full or as not fitting the +per-turn budget, or as larger than the whole budget, which no turn can +carry in full. Notes and pages share one ranked order, a BM25F relevance +score that decides which of them fit the budget: each shows its rank and +score and, under it, every matched term with the fields it was found in +(recall words, title, body) and what it added to the score. It reads +against an empty ledger and writes nothing. --prompt and a DOMAIN are two forms and are refused together, exit 2. Read-only.`, Args: cobra.MaximumNArgs(1), @@ -2689,7 +2700,7 @@ Read-only.`, }) }, } - cmd.Flags().StringVar(&prompt, "prompt", "", "show what this one prompt would bring back to a fresh session: the domains it matches and the memory notes it recalls") + cmd.Flags().StringVar(&prompt, "prompt", "", "show what this one prompt would bring back to a fresh session: the domains it matches and the memory notes and pages it recalls") return cmd } diff --git a/internal/surface/cli/memory_recall_provenance_test.go b/internal/surface/cli/memory_recall_provenance_test.go index fa597b0f33..f90af85177 100644 --- a/internal/surface/cli/memory_recall_provenance_test.go +++ b/internal/surface/cli/memory_recall_provenance_test.go @@ -41,7 +41,8 @@ func TestPromptRouterNamesEachNotesLayer(t *testing.T) { mine := addRaceNote(t, "session-one") env, errlog := recallRouterJSON(t, "session-two", repo, "why are the tests failing?") - if env.Recalled == nil || !slices.Equal(*env.Recalled, []string{projectNoteID, mine}) { + // Both come back, in the ranking's order, which this test does not judge. + if env.Recalled == nil || !slices.Equal(slices.Sorted(slices.Values(*env.Recalled)), slices.Sorted(slices.Values([]string{projectNoteID, mine}))) { t.Fatalf("recalled = %v\n%s", env.Recalled, env.Text) } for _, want := range []string{ @@ -52,7 +53,12 @@ func TestPromptRouterNamesEachNotesLayer(t *testing.T) { t.Fatalf("the injected block does not carry %q:\n%s", want, env.Text) } } - want := "abcd memory: recalled 2 note(s) [" + projectNoteID + " (project), " + mine + " (machine)]" + layer := map[string]string{projectNoteID: " (project)", mine: " (machine)"} + var labels []string + for _, key := range *env.Recalled { + labels = append(labels, key+layer[key]) + } + want := "abcd memory: recalled 2 item(s) [" + strings.Join(labels, ", ") + "]" if !strings.Contains(errlog, want) { t.Fatalf("stderr does not name each note's layer, want %q:\n%s", want, errlog) } diff --git a/internal/surface/cli/memory_recall_root_test.go b/internal/surface/cli/memory_recall_root_test.go index 927da11939..daca78c5c2 100644 --- a/internal/surface/cli/memory_recall_root_test.go +++ b/internal/surface/cli/memory_recall_root_test.go @@ -184,7 +184,7 @@ func TestARecallPanicKeepsTheRules(t *testing.T) { if res.Text != "" || len(res.Recalled) != 0 || !maps.Equal(res.Ledger, ledger) { t.Fatalf("a recovered recall recalled or moved the ledger: %+v", res) } - if !strings.Contains(errlog.String(), "abcd memory: the recall failed (boom in the recall); recalling no notes this turn") { + if !strings.Contains(errlog.String(), "abcd memory: the recall failed (boom in the recall); recalling no notes or pages this turn") { t.Fatalf("stderr does not name the failed recall:\n%s", errlog.String()) } } diff --git a/internal/surface/cli/memory_recall_test.go b/internal/surface/cli/memory_recall_test.go index 0fcd9def26..cd76a20884 100644 --- a/internal/surface/cli/memory_recall_test.go +++ b/internal/surface/cli/memory_recall_test.go @@ -295,3 +295,239 @@ func TestRulesPromptRefusesAPositionalDomain(t *testing.T) { t.Fatalf("a refused form rendered:\n%s", out) } } + +// writeMemoryPage writes one memory page into dir, with a recall: list when +// recall is given. +func writeRecallPage(t *testing.T, dir, name, body string, mode os.FileMode, recall ...string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + fm := "---\n" + if len(recall) > 0 { + fm += "recall: [" + strings.Join(recall, ", ") + "]\n" + } + fm += "source:\n class: session_memory\n---\n" + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(fm+body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } +} + +// TestANoteAndAPageComeBackTogether (N2): a note and a page that both match +// a prompt about tests come back together, in one block, through the one +// recall the prompt router runs. +func TestANoteAndAPageComeBackTogether(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + _, repo := memoryFixture(t) + id := addRaceNote(t, "session-one") + writeRecallPage(t, filepath.Join(repo, ".abcd", "memory"), "fact_eng_test-layout.md", + "# The tests live beside their package\n\nTable tests, one file per verb.\n", 0o644, "test") + + env, _ := recallRouterJSON(t, "session-two", repo, "why are the tests failing?") + if env.Recalled == nil || !slices.Equal(slices.Sorted(slices.Values(*env.Recalled)), []string{id, "page:fact_eng_test-layout.md"}) { + t.Fatalf("recalled = %v, want the note and the page\n%s", env.Recalled, env.Text) + } + if strings.Count(env.Text, "## MEMORY") != 1 { + t.Fatalf("the note and the page did not come back in one block:\n%s", env.Text) + } + for _, want := range []string{"The tests need the race flag", "The tests live beside their package", "Table tests, one file per verb."} { + if !strings.Contains(env.Text, want) { + t.Fatalf("the block does not carry %q:\n%s", want, env.Text) + } + } +} + +// TestRulesPromptShowsMatchedPages: `rules --prompt` lists each page the +// prompt recalls, from either scope, with its key, layer and hitting words, +// and names no page the prompt does not match. +func TestRulesPromptShowsMatchedPages(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + home, repo := memoryFixture(t) + workspace := filepath.Join(repo, ".abcd", "memory") + writeRecallPage(t, workspace, "fact_eng_auth-flow.md", "# Auth uses short-lived tokens\n\nThe page's own words.\n", 0o644, "auth", "login") + writeRecallPage(t, workspace, "fact_eng_billing.md", "# Billing runs nightly\n", 0o644, "billing") + writeRecallPage(t, abcdhome.Path(home, "memory"), "fact_eng_auth-keys.md", "# Auth keys live in the keychain\n", 0o600, "auth") + + out, stderr, err := runCLISplit(t, "rules", "--prompt", "the auth login is broken") + if err != nil { + t.Fatalf("rules --prompt: %v\n%s", err, stderr) + } + for _, want := range []string{"Pages:", "page:fact_eng_auth-flow.md Auth uses short-lived tokens (project layer) — hit by auth, login", + "page:fact_eng_auth-keys.md Auth keys live in the keychain (machine layer) — hit by auth"} { + if !strings.Contains(out, want) { + t.Fatalf("rules --prompt does not show %q:\n%s", want, out) + } + } + for _, not := range []string{"Billing", "The page's own words."} { + if strings.Contains(out, not) { + t.Fatalf("rules --prompt shows %q:\n%s", not, out) + } + } + + jout, stderr, err := runCLISplit(t, "rules", "--prompt", "the auth login is broken", "--json") + if err != nil { + t.Fatalf("rules --prompt --json: %v\n%s", err, stderr) + } + var env struct { + Pages []struct { + Key string `json:"key"` + Filename string `json:"filename"` + Title string `json:"title"` + Layer string `json:"layer"` + Hits []string `json:"hits"` + Form string `json:"form"` + } `json:"pages"` + } + if err := json.Unmarshal([]byte(jout), &env); err != nil { + t.Fatalf("rules --prompt --json is not one document: %v\n%s", err, jout) + } + if len(env.Pages) != 2 { + t.Fatalf("pages = %+v", env.Pages) + } + p := env.Pages[0] + if p.Key != "page:fact_eng_auth-flow.md" || p.Filename != "fact_eng_auth-flow.md" || p.Title != "Auth uses short-lived tokens" || + p.Layer != "project" || !slices.Equal(p.Hits, []string{"auth", "login"}) || p.Form != "full" { + t.Fatalf("pages[0] = %+v", p) + } + if env.Pages[1].Key != "page:fact_eng_auth-keys.md" || env.Pages[1].Layer != "machine" { + t.Fatalf("pages[1] = %+v", env.Pages[1]) + } +} + +// TestARefusedPageIsNamedAndTheRestComeBack: a machine page others can write +// is refused and named on stderr, in tilde form; the scope's other pages and +// the rules still come back. +func TestARefusedPageIsNamedAndTheRestComeBack(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + home, repo := memoryFixture(t) + machine := abcdhome.Path(home, "memory") + writeRecallPage(t, machine, "fact_eng_auth-flow.md", "# Kept to this account\n", 0o600, "commit") + writeRecallPage(t, machine, "fact_eng_auth-keys.md", "# Anyone can rewrite this\n", 0o666, "commit") + + env, errlog := recallRouterJSON(t, "refused-page", repo, "commit the fix") + if !slices.Contains(env.Injected, "COMMITTING") || env.Recalled == nil || !slices.Equal(*env.Recalled, []string{"page:fact_eng_auth-flow.md"}) { + t.Fatalf("injected %v recalled %v:\n%s\nstderr:\n%s", env.Injected, env.Recalled, env.Text, errlog) + } + if strings.Contains(env.Text, "Anyone can rewrite this") { + t.Fatalf("a page others can write came back:\n%s", env.Text) + } + if !strings.Contains(errlog, abcdhome.Display("memory", "fact_eng_auth-keys.md")) || !strings.Contains(errlog, "recalling nothing from it") { + t.Fatalf("stderr does not name the refused page:\n%s", errlog) + } + if strings.Contains(errlog, home) { + t.Fatalf("stderr carries the expanded home:\n%s", errlog) + } +} + +// TestRulesPromptShowsEachTermsContribution: `rules --prompt` ranks the +// notes and pages the prompt recalls, one order across both, and shows each +// one's score and, for each matched term, the fields it was found in and +// what it contributed; --json carries the same, the contributions summing to +// the score. +func TestRulesPromptShowsEachTermsContribution(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + _, repo := memoryFixture(t) + id := addRaceNote(t, "session-one") + writeRecallPage(t, filepath.Join(repo, ".abcd", "memory"), "fact_eng_test-layout.md", + "# Where the tests live\n\nBeside their package.\n", 0o644, "test") + + out, stderr, err := runCLISplit(t, "rules", "--prompt", "run the race tests") + if err != nil { + t.Fatalf("rules --prompt: %v\n%s", err, stderr) + } + for _, want := range []string{"#1 " + id, "#2 page:fact_eng_test-layout.md", "score ", + "race — recall 1, title 1", "test — recall 1, title 1, body 1 — "} { + if !strings.Contains(out, want) { + t.Fatalf("rules --prompt does not show %q:\n%s", want, out) + } + } + + jout, stderr, err := runCLISplit(t, "rules", "--prompt", "run the race tests", "--json") + if err != nil { + t.Fatalf("rules --prompt --json: %v\n%s", err, stderr) + } + type ranked struct { + Rank int `json:"rank"` + Score float64 `json:"score"` + Terms []struct { + Term string `json:"term"` + Recall int `json:"recall"` + Title int `json:"title"` + Body int `json:"body"` + Contribution float64 `json:"contribution"` + } `json:"terms"` + } + var env struct { + Notes []ranked `json:"notes"` + Pages []ranked `json:"pages"` + } + if err := json.Unmarshal([]byte(jout), &env); err != nil { + t.Fatalf("rules --prompt --json is not one document: %v\n%s", err, jout) + } + if len(env.Notes) != 1 || len(env.Pages) != 1 || env.Notes[0].Rank != 1 || env.Pages[0].Rank != 2 || + env.Notes[0].Score <= env.Pages[0].Score { + t.Fatalf("notes %+v pages %+v", env.Notes, env.Pages) + } + for _, r := range append(env.Notes, env.Pages...) { + sum := 0.0 + for _, term := range r.Terms { + if term.Recall+term.Title+term.Body == 0 || term.Contribution <= 0 { + t.Fatalf("a term with no field or no contribution: %+v", term) + } + sum += term.Contribution + } + if len(r.Terms) == 0 || sum < r.Score-1e-9 || sum > r.Score+1e-9 { + t.Fatalf("the terms %+v do not add up to the score %v", r.Terms, r.Score) + } + } +} + +// TestAPageLargerThanTheBudgetIsNamedAndTheRestComeBack: a page whose block +// is larger than the whole per-turn budget is named on stderr as never +// coming back in full, and does not hold back the page ranked below it; +// `rules --prompt` gives it the form too-large. +func TestAPageLargerThanTheBudgetIsNamedAndTheRestComeBack(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + _, repo := memoryFixture(t) + workspace := filepath.Join(repo, ".abcd", "memory") + huge := strings.Repeat("Every kerberos deploy ever written down.\n", 2000) // about 80 KiB + writeRecallPage(t, workspace, "fact_eng_huge.md", "# Every kerberos deploy\n\n"+huge, 0o644, "deploy", "kerberos") + writeRecallPage(t, workspace, "fact_eng_dry-run.md", "# Deploys need the dry-run flag\n\nPass it first.\n", 0o644, "deploy") + + env, errlog := recallRouterJSON(t, "too-large", repo, "deploy with kerberos") + if env.Recalled == nil || !slices.Equal(*env.Recalled, []string{"page:fact_eng_dry-run.md"}) { + t.Fatalf("recalled %v:\n%s\nstderr:\n%s", env.Recalled, env.Text, errlog) + } + if !strings.Contains(errlog, "[page:fact_eng_huge.md (project)] are larger than the whole per-turn budget") { + t.Fatalf("stderr does not name the page too large to come back:\n%s", errlog) + } + + jout, _, err := runCLISplit(t, "rules", "--prompt", "deploy with kerberos", "--json") + if err != nil { + t.Fatalf("rules --prompt --json: %v", err) + } + var v struct { + Pages []struct { + Key string `json:"key"` + Form string `json:"form"` + } `json:"pages"` + } + if err := json.Unmarshal([]byte(jout), &v); err != nil { + t.Fatal(err) + } + forms := map[string]string{} + for _, p := range v.Pages { + forms[p.Key] = p.Form + } + if forms["page:fact_eng_huge.md"] != "too-large" || forms["page:fact_eng_dry-run.md"] != "full" { + t.Fatalf("forms = %v", forms) + } + out, _, _ := runCLISplit(t, "rules", "--prompt", "deploy with kerberos") + if !strings.Contains(out, "larger than the whole per-turn budget") { + t.Fatalf("rules --prompt does not say the page is too large:\n%s", out) + } +} diff --git a/internal/surface/cli/rules_prompt.go b/internal/surface/cli/rules_prompt.go index 39d6db48fa..2ab1ad1538 100644 --- a/internal/surface/cli/rules_prompt.go +++ b/internal/surface/cli/rules_prompt.go @@ -18,21 +18,22 @@ import ( ) // rules_prompt.go — the memory recall's two front doors -// (spc-2610111627213138 step 2): the prompt router's recall after the rules, -// and the `rules --prompt ""` diagnostic that shows what one prompt -// brings back. +// (spc-2610111627213138 steps 2 to 4): the prompt router's recall after the +// rules, and the `rules --prompt ""` diagnostic that shows what one +// prompt brings back. // recallMemory is the prompt router's recall: the notes both memory layers -// hold, matched against prompt and taken within budget, what the rules left -// of the loader's per-turn budget. The machine layer is read under the home -// the rules user layer was (rules.UserHome), the project's under root, the -// root the loader resolved. A layer that cannot be read is named on stderr -// and recalls nothing, while the rules, already rendered, still inject; so -// is a root-commit lookup git did not answer in time, which leaves the tied -// notes out this turn. The -// loader's kill switch silences the recall: nothing is read, nothing comes -// back, and the ledger is kept as it was. here answers the repository's root -// commit for the notes tied to one, asked only once a tied note matched. +// hold and the pages both page scopes hold, matched against prompt and taken +// within budget, what the rules left of the loader's per-turn budget. The +// machine's are read under the home the rules user layer was +// (rules.UserHome), the project's under root, the root the loader resolved. A +// layer, scope or page that cannot be read is named on stderr and recalls +// nothing, while the rules, already rendered, still inject; so is a +// root-commit lookup git did not answer in time, which leaves the tied notes +// out this turn. The loader's kill switch silences the recall: nothing is +// read, nothing comes back, and the ledger is kept as it was. here answers +// the repository's root commit for the notes tied to one, asked only once a +// tied note matched. // // It runs after the rules are rendered, so a panic inside it is recovered // here: that prompt recalls nothing, the ledger is kept as it was, stderr @@ -40,16 +41,16 @@ import ( func recallMemory(w io.Writer, rs rules.RuleSet, root, prompt string, ledger map[string]string, budget int, here recall.RootFunc) (res recall.Result) { defer func() { if p := recover(); p != nil { - diagnosticLine(w, "abcd memory: the recall failed (%s); recalling no notes this turn", fsutil.RedactHome(fmt.Sprint(p))) + diagnosticLine(w, "abcd memory: the recall failed (%s); recalling no notes or pages this turn", fsutil.RedactHome(fmt.Sprint(p))) res = recall.Result{Ledger: ledger} } }() if rs.Disabled { return recall.Result{Ledger: ledger} } - items, refused := recall.ReadNotes(rules.UserHome(), root) + items, refused := recall.ReadItems(rules.UserHome(), root) for _, r := range refused { - diagnosticLine(w, "abcd memory: %s; recalling no notes from it", refusalText(r)) + diagnosticLine(w, "abcd memory: %s; recalling %s from it", refusalText(r), refusalLoss(r)) } res = recall.Recall(recall.Request{Items: items, Prompt: prompt, Ledger: ledger, Budget: budget, Root: here}) if res.RootErr != nil { @@ -129,10 +130,20 @@ func refusalText(r recall.Refusal) string { return r.File + ": " + why } -// The forms the diagnostic gives a matched domain or note. +// refusalLoss is what a refused file costs the recall: a memory.json's notes, +// or whatever a page store or a page would have brought back. +func refusalLoss(r recall.Refusal) string { + if r.Kind == recall.KindPage { + return "nothing" + } + return "no notes" +} + +// The forms the diagnostic gives a matched domain, note or page. const ( promptFormFull = "full" promptFormOverBudget = "over-budget" + promptFormTooLarge = "too-large" ) // rulesPromptDomain is one matched domain in the diagnostic. @@ -144,13 +155,34 @@ type rulesPromptDomain struct { Form string `json:"form"` } -// rulesPromptNote is one matched note in the diagnostic. +// rulesPromptNote is one matched note in the diagnostic. Rank is its place in +// the one ranked order notes and pages share (1 first), Score its BM25F +// score, and Terms each matched term with the fields it was found in and its +// contribution (recall.Rank). type rulesPromptNote struct { - ID string `json:"id"` - Title string `json:"title"` - Layer string `json:"layer"` - Hits []string `json:"hits"` - Form string `json:"form"` + ID string `json:"id"` + Title string `json:"title"` + Layer string `json:"layer"` + Hits []string `json:"hits"` + Rank int `json:"rank"` + Score float64 `json:"score"` + Terms []recall.TermScore `json:"terms"` + Form string `json:"form"` +} + +// rulesPromptPage is one matched page in the diagnostic: its key is the one +// the router's envelope names it by (page:); Rank, Score and Terms +// are a note's. +type rulesPromptPage struct { + Key string `json:"key"` + Filename string `json:"filename"` + Title string `json:"title"` + Layer string `json:"layer"` + Hits []string `json:"hits"` + Rank int `json:"rank"` + Score float64 `json:"score"` + Terms []recall.TermScore `json:"terms"` + Form string `json:"form"` } // rulesPromptView is `rules --prompt`'s --json envelope. @@ -158,6 +190,7 @@ type rulesPromptView struct { Disabled bool `json:"disabled"` Domains []rulesPromptDomain `json:"domains"` Notes []rulesPromptNote `json:"notes"` + Pages []rulesPromptPage `json:"pages"` Unreadable []recall.Refusal `json:"unreadable"` } @@ -166,9 +199,10 @@ type rulesPromptView struct { // empty ledger, then the recall within what the rules left — and writes // nothing: no session state is loaded or saved. A root-commit lookup git did // not answer in time leaves the tied notes out of the view, and is named on -// stderr (w), once, as the hook names it. +// stderr, once, as the hook names it. func rulesPrompt(stderr io.Writer, rs rules.RuleSet, root, prompt string) rulesPromptView { - v := rulesPromptView{Disabled: rs.Disabled, Domains: []rulesPromptDomain{}, Notes: []rulesPromptNote{}, Unreadable: []recall.Refusal{}} + v := rulesPromptView{Disabled: rs.Disabled, Domains: []rulesPromptDomain{}, Notes: []rulesPromptNote{}, Pages: []rulesPromptPage{}, + Unreadable: []recall.Refusal{}} if rs.Disabled { return v } @@ -187,7 +221,7 @@ func rulesPrompt(stderr io.Writer, rs rules.RuleSet, root, prompt string) rulesP v.Domains = append(v.Domains, rulesPromptDomain{Name: d.Name, Label: rules.Label(d.Name, d.Source), Source: d.Source, Hits: rules.Hits(d, prompt), Form: form}) } - items, refused := recall.ReadNotes(rules.UserHome(), root) + items, refused := recall.ReadItems(rules.UserHome(), root) v.Unreadable = append(v.Unreadable, refused...) here := promptRoot(rootLookup{}, root) mem := recall.Recall(recall.Request{Items: items, Prompt: prompt, Budget: rules.InjectionBudgetBytes - len(res.Text), Root: here}) @@ -195,18 +229,35 @@ func rulesPrompt(stderr io.Writer, rs rules.RuleSet, root, prompt string) rulesP for _, key := range mem.Recalled { recalled[key] = true } - matched, matchErr := recall.Matching(items, prompt, here) - // here is memoised, so the recall and the matching share one lookup and + tooLarge := map[string]bool{} + for _, key := range mem.TooLarge { + tooLarge[key] = true + } + ranked, rankErr := recall.Rank(items, prompt, here) + // here is memoised, so the recall and the ranking share one lookup and // one error: it is named once. - if rootErr := cmp.Or(mem.RootErr, matchErr); rootErr != nil { + if rootErr := cmp.Or(mem.RootErr, rankErr); rootErr != nil { rootErrLine(stderr, rootErr) } - for _, c := range matched { + for i, c := range ranked { form := promptFormOverBudget - if recalled[c.Item.Key] { + switch { + case recalled[c.Item.Key]: form = promptFormFull + case tooLarge[c.Item.Key]: + form = promptFormTooLarge + } + terms := c.Terms + if terms == nil { + terms = []recall.TermScore{} + } + if c.Item.Kind == recall.KindPage { + v.Pages = append(v.Pages, rulesPromptPage{Key: c.Item.Key, Filename: strings.TrimPrefix(c.Item.Key, recall.PageKeyPrefix), + Title: c.Item.Title, Layer: c.Item.Layer, Hits: c.Hits, Rank: i + 1, Score: c.Score, Terms: terms, Form: form}) + continue } - v.Notes = append(v.Notes, rulesPromptNote{ID: c.Item.Key, Title: c.Item.Title, Layer: c.Item.Layer, Hits: c.Hits, Form: form}) + v.Notes = append(v.Notes, rulesPromptNote{ID: c.Item.Key, Title: c.Item.Title, Layer: c.Item.Layer, Hits: c.Hits, + Rank: i + 1, Score: c.Score, Terms: terms, Form: form}) } return v } @@ -218,6 +269,9 @@ func renderRulesPrompt(w io.Writer, rs rules.RuleSet, v rulesPromptView) { return } fmt.Fprintln(w, "abcd rules --prompt — what this prompt brings back to a fresh session (nothing written)") + if len(v.Notes)+len(v.Pages) > 0 { + fmt.Fprintln(w, "Notes and pages share one ranked order (#1 first), by relevance score (BM25F); under each, every matched term, the fields it was found in and what it added.") + } if len(v.Domains) == 0 { fmt.Fprintln(w, "Domains: none matched") } else { @@ -231,19 +285,50 @@ func renderRulesPrompt(w io.Writer, rs rules.RuleSet, v rulesPromptView) { } else { fmt.Fprintln(w, "Notes:") for _, n := range v.Notes { - fmt.Fprintf(w, " %s %s (%s layer) — hit by %s — %s\n", n.ID, termsafe.Sanitize(n.Title), n.Layer, - termsafe.Sanitize(strings.Join(n.Hits, ", ")), promptFormWords(n.Form)) + fmt.Fprintf(w, " #%d %s %s (%s layer) — hit by %s — score %.4f — %s\n", n.Rank, n.ID, termsafe.Sanitize(n.Title), n.Layer, + termsafe.Sanitize(strings.Join(n.Hits, ", ")), n.Score, promptFormWords(n.Form)) + renderTermScores(w, n.Terms) + } + } + if len(v.Pages) == 0 { + fmt.Fprintln(w, "Pages: none matched") + } else { + fmt.Fprintln(w, "Pages:") + for _, p := range v.Pages { + fmt.Fprintf(w, " #%d %s %s (%s layer) — hit by %s — score %.4f — %s\n", p.Rank, p.Key, termsafe.Sanitize(p.Title), p.Layer, + termsafe.Sanitize(strings.Join(p.Hits, ", ")), p.Score, promptFormWords(p.Form)) + renderTermScores(w, p.Terms) } } for _, u := range v.Unreadable { - fmt.Fprintf(w, "unreadable: %s (no notes recalled from it)\n", refusalText(u)) + fmt.Fprintf(w, "unreadable: %s (%s recalled from it)\n", termsafe.Sanitize(refusalText(u)), refusalLoss(u)) + } +} + +// renderTermScores writes, under a ranked note or page, each matched term +// with the fields it was found in and what it contributed to the score. +func renderTermScores(w io.Writer, terms []recall.TermScore) { + for _, ts := range terms { + var found []string + for _, f := range []struct { + name string + n int + }{{recall.FieldRecall, ts.Recall}, {recall.FieldTitle, ts.Title}, {recall.FieldBody, ts.Body}} { + if f.n > 0 { + found = append(found, fmt.Sprintf("%s %d", f.name, f.n)) + } + } + fmt.Fprintf(w, " %s — %s — %.4f\n", ts.Term, strings.Join(found, ", "), ts.Contribution) } } // promptFormWords is a form as the plain diagnostic says it. func promptFormWords(form string) string { - if form == promptFormFull { + switch form { + case promptFormFull: return "in full" + case promptFormTooLarge: + return "larger than the whole per-turn budget, so it does not come back in full" } return "does not fit this turn's budget" }