Skip to content

feat: keep memory notes with the memory verb and recall them through the prompt hook - #1003

Merged
REPPL merged 36 commits into
mainfrom
feat/memory-recall-notes
Oct 15, 2026
Merged

REPPL merged 36 commits into
mainfrom
feat/memory-recall-notes

Conversation

@REPPL

@REPPL REPPL commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

This builds steps 1 and 2 of spc-2610111627213138 for itd-2610091918433290. Steps 3 and 4, memory pages in the recall ranked by BM25F, are #982, which rebases onto this branch once it lands. The spec stays open and the intent stays planned, so this PR carries no Delivers trailer.

  • Step 1: abcd memory keeps memory notes in a guarded store: the machine file under the person's home, and a project file .abcd/memory.json. Reads refuse symlinks, FIFOs and files another account can write. Writes go through a gate that fails closed when the secret scanner can't run.
  • Step 2: matching notes are recalled through the prompt hook and abcd rules --prompt, inside the same per-turn budget as the rules. A note's id counts as a record handle in running text.
  • Provenance: a recalled note's head names its layer, "(project file, committed in this repository)" or "(personal note, agent-written)". Its body is fenced with the same fence repository rule overrides use (now exported as rules.FenceRuleBody), so a cloned repository's notes can't pass off text as abcd's own or as the person's.
  • Failure modes: the hook's root-commit lookup is bounded at 2 seconds and shared between the drain and the recall. A timeout is named on stderr. A panic in the recall is recovered, and the rules still inject.

Review

  • An Opus and Sonnet security pair reviewed the stack as it lands on today's main. Both blocked on one major: recalled notes were unlabelled and unfenced. That is fixed, and the final pair approved, with an 11-body forge test confirming a markdown reader sees only the item heads as live.
  • Four follow-ups are captured, each as its own record:
    • the --json recalled list carries no layer;
    • the agent-written label covers notes moved in from the host;
    • a project note's title renders inline markdown outside the fence;
    • the root lookup could exceed its cap on a very large repository with no commit-graph.

Refs: spc-2610111627213138
Refs: itd-2610091918433290
Refs: iss-2609020219265817
Refs: iss-2609290300313698
Refs: iss-2610142220581750
Refs: iss-2610142221073140
Refs: iss-2610142221086043
Refs: iss-2610142227520380
Assisted-by: Claude:claude-opus-5-5

REPPL added 30 commits October 11, 2026 19:13
The user layer's home-scoped read and the repository layer's guarded read
move into two exported helpers, ReadUserLayerFile and ReadRepoLayerFile,
which the rules loader now reads its own two files through. The refusals
keep their wording for rules.json. The memory-notes store reads its
memory.json layers through the same helpers, so the two files beside each
other are held to one set of checks.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
An agent can now keep short notes it would otherwise relearn. `abcd memory
add` writes one note, its body on stdin, to the machine layer
~/.abcd.noindex/memory.json and nowhere else. Each note is stamped with who
wrote it, the clock's time and the host's session id, or records the session
as unknown and says so. A gate refuses a note that would hold a secret, a
network address or a private name before anything is written, naming the
field and never the span: it runs the memory store's own redactor and the
private-names banlist, the primary checkout's too in a linked worktree. A
note is tied to its repository's root commit unless --everywhere, which says
that only this checkout's private names were checked.

Bare `abcd memory` counts the notes and names a layer it cannot read; `list`
shows every note from both layers with its body, stamps and reach; `remove`
takes any personal note out and refuses one in the project's
.abcd/memory.json, which is read with the repository layer's checks and
never written. `add` takes no path, so it never opens the host's memory
files.

The verb sits with the agents, naming its new page commands/memory.md, which
is hidden from a person's menu. The memory name is no longer a retired token,
since the verb split gave it to this verb. The brief gains the memory
chapter and its register row, the release-gate pin names both, and the
DOGFOODING domain recalls on the word.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The memory verb resolved its repository root through rules.ResolveRoot,
which drops the resolution's notes, so a checkout the resolver refused as
another account's was used as a plain directory without a word: `memory
add` then said only that there was no root commit. The verb now resolves
through rulesRoot, which prints those notes to stderr first, as `abcd
rules` does.

The test stages the refusal through git's ownership check and a new
rules.SwapOwnerUIDForTest, since a second uid cannot be created in a test.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
A memory write opened ~/.abcd.noindex through its descriptor and wrote
through it, but re-read the file under the lock by walking the path
again. A same-account rename of the directory between the two let the
write read whatever now stood at the name and replace the old
directory's notes with a one-note file.

The re-read now goes through the descriptor the walk opened, with a new
rules.ReadUserLayerFileIn built on fsutil.ReadHomeDeclarationDenyingIn,
so the read keeps the user layer's guards and wording with no third copy
of a guarded read. The lock stays on the directory at its path, as
fsutil.WithDirLock allows, and the spec says exactly what is locked and
what is read. In the rename race the shared read now refuses, so nothing
is written.

Refs: itd-2610091918433290, itd-39
Refs: iss-2609290300313698
Assisted-by: Claude:claude-opus-5-5
A memory write that could not create ~/.abcd.noindex, or could not
write memory.json into it, said only that it could not, so a full disk
and a permission refusal read the same. Both refusals now carry the
cause, with the home shown as a tilde.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The memory chapter says a machine file the read refuses exits 1, and a
file failing a trust check did, but one that failed to parse exited 2
beside the field and gate refusals. It now exits 1 too, as `abcd rules`
does for a rules.json layer that fails to load. The agents' page says
so.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The v0.14.0 upgrade guide sends `abcd memory` and `/abcd:memory` to
`library`, which stays true of v0.14.0. Once a later release gives the
name to memory notes, those spellings run instead of refusing, so a
reader relying on the refusal would be misled. One paragraph after the
table says the name is reused after v0.14.0 and to change the spellings
now; the guide's v0.14.0 claims are left as they are.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The memory-recall spec now records, as one decision of 2026-10-11, the
limits step 1 set without a ruling: recall words, the trimmed body, the
session id's shape, where the no-root-commit refusal points, how list
names another repository's note, and that `mem` joins the in-text handle
pattern in step 2.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The first full preflight of the memory notes step tripped three gates
on the step's own code. A gate test ran git with a hand-built
environment instead of gittest.Env; two tests spelled the abcd home's
folder name instead of building it through abcdhome; and the field
checks and the id check were exported though nothing outside their
package calls them, so they are unexported. The gate's use of
banlist.PrimaryWorktreeRoot takes that function off the unreached
baseline.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The release-gate manifest gained the memory chapter, so the example
receipt's manifest hash no longer matched it. The example now carries
the committed manifest's hash.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The memory recall needs to match a note's recall words exactly as a rule
domain's are matched, and to remember per session what it already sent.
The rules package now exports TermMatcher, its one term rule over an
indexed prompt, and Hits, which names the words that made a domain match.
SessionState gains a recalled map that Inject carries through untouched,
since the rules ledger is pruned to active domains every turn. The reset
and the fixed backstop clear both maps together. The per-turn budget,
the user-layer home and the rule body's render-safety pass are exported
for the recall to share rather than copy.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
A note one session writes now comes back in a later session when a
prompt matches its recall words. The prompt router calls the new recall
package after the rules, with the notes both memory.json layers hold: a
personal note only in the repository it is tied to, or everywhere, and
every note in the project's file. Matched notes come back once per
session in a "## MEMORY" block after the rules, within what the rules
left of the one per-turn budget, taken in id order until the ranking
lands. The --json envelope names them in a new recalled list. The kill
switch silences the recall, and a memory.json that cannot be read is
named on stderr while the rules still inject.

`abcd rules --prompt "<text>"` shows what one prompt would bring back to
a fresh session: each matched domain with the words that hit, and each
matched note with its layer, in full or over the budget. It writes
nothing, and refuses a positional domain beside it. commands/ahoy.md
documents it, and the CLI reference and surface pin are regenerated.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
Notes now come back into running text through the recall, so a mem id
written in a sentence is a pointer into the record like any other
family's. Step 1 left this to the step that first recalls notes.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The exported-reach audit refused recall.NoteItems, which only the
package itself calls: ReadNotes is the front doors' way in. It is now
unexported. The audit also found rules.Label reached from outside its
package by the rules --prompt diagnostic, so its baseline line goes.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
…tied note

The recall asked git for the repository's root commit on every prompt
whenever any machine note carried a root, before matching, while the
live drain had already asked the same question for that prompt and
thrown the answer away. A note's tie is now judged after matching,
against a lookup the recall asks at most once and only when a tied note
matched; the prompt router hands it the drain's own answer, so a prompt
spawns that full-history rev-list once.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The recall's root lookup dropped git's timeout, so a slow git left out
every note tied to the repository that turn and said nothing. The
lookup's error now reaches the recall's result, and the prompt router
names it on stderr in the shape it already uses for an unreadable
layer. The rules still inject, and the tied notes, unrecorded, are
tried again on the next turn.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The memory verb kept its own copy of the recall's layer reader, its
refusal type and its reason text. The verb now reads through the
recall's ReadLayers, whose Refusal and Reason it shares, and the copy
is gone. The verb's output is unchanged, and its tests pass as they
were.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
No hook test had rules and a note competing for the per-turn budget.
This one sizes the rules to leave a little of it: the rules block and
a memory block come back in one output, the note that does not fit is
named on stderr and left unrecorded, and the turn stays within the
budget. Handing the recall the whole budget in place of what the rules
left makes it fail.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
The rules --prompt diagnostic matched domains against the whole prompt,
while the hook scans only its leading bytes, so a recall word past the
cap showed a domain the hook would never inject. One helper now caps
every reader of a prompt, the diagnostic included.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
Once the rules --prompt diagnostic judged ties through Matching, the
plain match had no caller outside the recall, which the exported-reach
audit refuses. It is now the package's own, and Matching is the one
the front doors use.

Refs: itd-2610091918433290, itd-39
Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
A memory note went into the prompt hook's output under a bare "## MEMORY"
with no provenance and no fence. A note from a checkout's committed
.abcd/memory.json, which a cloned or compromised repository controls,
looked exactly like the person's own, and its body could make a heading a
CommonMark reader sees: the continuation-line indent only defends the
flush-left contract, and a two-space "## COMMITTING" still reads as one.

Each recalled note's head now names its layer, "(project file, committed
in this repository)" or "(personal note, agent-written)", as an override's
rule heading says "(repo override)" (GHSA-22f8-qf5r-gjgq). Every body is
fenced through the rules package's one fence, now exported as
rules.FenceRuleBody rather than copied (iss-2609020219265817). The hook's
stderr line names each id with its layer, and the layer joins the item's
ledger signature, so a promoted note is sent again under its new label.

The forge test now asserts, under every fence reading mdrecord knows,
that the only live lines are the block heading and each item's head:
indented headings, setext underlines, list items and fence-closing runs
of tildes or backticks all stay inside the fence.

Refs: spc-2610111627213138
Refs: iss-2609020219265817
Assisted-by: Claude:claude-opus-5-5
When the live drain made no root-commit lookup (rules --prompt, or a hook
with no cwd), promptRoot asked git with context.Background(), so a git
that never answered held the prompt for as long as it hung. The lookup it
makes itself now runs under a two-second deadline. A lookup past it is a
timeout wrapping gitutil.ErrGitTimedOut: the tied notes are left out this
turn, unrecorded, and the recall's diagnostic names it on stderr.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
recallMemory runs after the rules are rendered and had no recover, so a
panic inside the recall dropped the rendered rules for that prompt with
it. It now recovers inside itself: that prompt recalls nothing, the
session's ledger is kept as it was, stderr names the failure, and the
rules still inject.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
drainWhileLive asked git for the root commit with context.Background() on
the hook's main path, every prompt with a cwd, so a git that never
answered held the prompt for as long as it hung. The drain and the
recall now share one lookup, boundedRootCommit, under the same
two-second deadline. On a timeout the drain drains nothing this turn and
says so on stderr, and the recall, which reuses the answer, leaves the
tied notes out and names it as before.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
rules --prompt dropped the recall's RootErr and the error recall.Matching
returned, so when git did not answer the root-commit lookup in time, the
tied notes were missing from the diagnostic with nothing to say why. It
now names the timeout on stderr, once, in the hook's own words (one
helper, rootErrLine, serves both), and stdout stays one document.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
Three findings from the round that labelled and fenced recalled notes,
each left as is and filed so it is decided on its own:

- the hook's --json recalled list carries bare ids with no layer, since
  adding it changes the envelope's schema (nitpick);
- the "(personal note, agent-written)" label also covers notes moved in
  from the host's memory with --moved-from-host (nitpick);
- a recalled note's title sits on the head line outside the fence, so
  inline markdown in a project note's title still renders (minor).

Refs: spc-2610111627213138
Refs: iss-2610142220581750
Refs: iss-2610142221073140
Refs: iss-2610142221086043
Assisted-by: Claude:claude-opus-5-5
…gnature

The layer is now part of a recalled item's ledger signature, so a session
that recalled notes before the change gets each once more, labelled, on
its next matching prompt. The spec's Provenance paragraph now says that
this one-time re-send is expected, as it was for the rules' override
label.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
The prompt router's root-commit lookup is a full-history rev-list under a
2 s cap. On a very large repository without a commit-graph it could pass
the cap on every prompt, so the live drain and the tied-note recall would
skip every prompt with a stderr line each time. It measured 0.01 s here
(7,873 commits). Filed with the remedy of caching the root commit per
checkout, or reading it from the commit-graph.

Refs: iss-2610142227520380
Assisted-by: Claude:claude-opus-5-5
REPPL added 3 commits October 14, 2026 23:28
Assisted-by: Claude:claude-opus-5-5
Only render.go calls it, and the exported-reach audit refuses an exported
function no production code outside its package reaches.

Refs: spc-2610111627213138
Assisted-by: Claude:claude-opus-5-5
The example receipt keeps main's closed-disposition example and takes the
hash of the merged release-gate manifest.

Assisted-by: Claude:claude-opus-5-5

# Conflicts:
#	.abcd/development/release-gate/receipt.example.json
@REPPL
REPPL added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 15, 2026
The example receipt takes the hash of the merged release-gate manifest.

Assisted-by: Claude:claude-opus-5-5
Brings in the CI speed lane (#1014), which brings the race steps back
under their job limits, and the stale-usage notes (#1008).

Refs: itd-2610091918433290
Refs: itd-39
Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL enabled auto-merge October 15, 2026 10:49
Main gained the embargo verb (#1015). Its brief chapter and memory's both
take a row and an index entry in the surfaces register, kept side by side;
the release-gate manifest takes main's copy plus memory's chapter and
verb, with checkerCount (44) and promptHash recomputed, and the example
receipt's manifestHash is the merged manifest's sha256. Three chapters
now share number 37, as iss-2610151050383937 records.

Refs: itd-2610091918433290
Refs: itd-39
Refs: iss-2610151050383937
Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 3a7e1ed Oct 15, 2026
14 checks passed
@REPPL
REPPL deleted the feat/memory-recall-notes branch October 15, 2026 12:44
REPPL added a commit that referenced this pull request Oct 15, 2026
Brings in v0.14.2 (#1021, #1022), embargo (#1015), memory (#1003), the
RepoPrompt reviewer route (#976) and the CI speed lane (#1014), with no
conflicts.

Refs: itd-2609091034175565
Assisted-by: Claude:claude-opus-5-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant