Memory that arrives on time
Written for developers running coding agents on a real
codebase. The implementation lives in lage;
the operating notes are in .claude/memory-scoping.md.
I give my coding agents a memory. Mine is beads,
where bd remember stores a durable note and a SessionStart hook replays them
into every new session. After a year of use I had 51 notes: which endpoint
nests items inside event, why the Fahrradmessstellen CSV is a rolling
two-day window, that the Bonn council’s meeting links are not unique per meeting
and keying a Svelte {#each} on one blanks the live site.
Hard-won things. All of them true. And every session began by pasting all of them into the context window: 52,068 bytes, of which 47,627 were the memory dump.
The obvious complaint is cost. That is not the interesting one.
Presence is not salience
Consider the meeting-links note. It exists because I shipped that bug twice. It is precise, it names the file, it explains the failure mode — a duplicate key throws on client hydration, which server-side rendering never catches, so the whole app unmounts and the site goes blank.
Now consider when the agent reads it: at position ~30,000 of a session that is about to be spent on Tekton templates. It scrolls past forty-nine unrelated warnings, tens of thousands of tokens before anyone opens a Svelte file — if anyone opens one at all that day.
The note is present. It is not loud. And presence is not what makes a model act on something; proximity to the decision is. I had built a system that guaranteed the first property and quietly destroyed the second.
This is the same lesson every developer learns about a wall of compiler
warnings, or a CONTRIBUTING.md that grew to nine screens. Information delivered
indiscriminately gets filtered indiscriminately.
Two kinds of memory
Reading through the 51, they split cleanly:
- Ambient policy. Never
dolt push— the issue database is local by choice. Always work in a git worktree. Nothing anchors these to a file; they govern the session’s behaviour from its first move. They have to be present up front, because by the time you can tell they were needed, they have been violated. - File-local knowledge.
src/lage/api.py’s/api/events/urn/{slug}nestsitemsinsideevent, unlike/api/events/{id}. This is about specific files, and it is inert until one of them is opened.
The first class is eight of my notes. The second is forty-three. I was paying the full price of the second class on every session in order to deliver the first.
So: keep the eight always-on, and make the forty-three arrive the moment a tool touches a file the note is about.
The mechanism
Coding agents expose lifecycle hooks. In Claude Code, SessionStart seeds
context and PreToolUse fires before every tool call, with the tool’s arguments
on stdin and the ability to return extra context. That is all I needed:
bd memory store
│
│ build-memory-index.py ← extracts paths from each memory's text,
▼ validates them against `git ls-files`
memory-index.json {glob → [memory keys]}
│
├── bd-prime-lean.py SessionStart: workflow rules + 8 core notes
│
└── recall-for-path.py PreToolUse (Read|Edit|Write):
file_path → matching notes → contextOpen apps/web/src/lib/panels/Sitzungen.svelte and the each-key warning arrives
attached to that read, in the sentence before the agent decides what to change.
Open a Tekton template and it does not.
Matching on Read matters more than on Edit. An agent reads a file before
deciding how to change it. A warning delivered at read time can still change the
plan; one delivered at edit time is arguing with a decision already made.
The trick: let the repository validate its own index
The interesting problem is deciding what a memory is about. The tempting answers are embeddings or asking a model — both probabilistic, both requiring infrastructure, both hard to debug at 2am.
I did something dumber. My notes already name their files, because that is how
you write a useful note. So: extract every path-shaped token, and keep only
the ones that appear in git ls-files.
The repository becomes the validator. apps/web/src/app.html indexes.
refs/dolt/data does not — no such file is tracked. Neither does pytest/tsc/etc.,
nor IP/browser-data, nor any of the other slash-separated English that a naive
regex happily mistakes for a path. No model, no embedding, no tuning: one
git ls-files call and a set membership test.
Three guards keep the globs honest. Single-segment directories (src, data,
tests) are rejected — in prose those are words far more often than paths. A
directory glob covering more than 150 tracked files is dropped, because
apps/web/** at 389 files is not scoping, it is the dump again with extra
steps. And generic basenames (spec.md, package.json) are refused: a human
writing **/spec.md into the config is making a decision, an extractor guessing
it is making noise on every commit.
The failure mode I designed for
Here is the part worth stealing, independent of any of the above.
A memory whose text names no tracked path anchors nowhere. It is not injected on
SessionStart any more, and no file will ever trigger it. It is injected
never — and nothing errors. That is strictly worse than the dump I started
from, and it is invisible.
So the index builder ends every run with an orphan report: every note that is neither ambient policy nor anchored to a path. When I first ran it, fourteen notes were listed — a quarter of the corpus, all of them about real subsystems, all of them silently unreachable. Each got an explicit glob, or a promotion to always-on.
The rule generalises: when you replace an eager system with a lazy one, the lazy one must be able to say what it has decided not to do. Otherwise you have not built a filter, you have built a leak.
Where it landed
| before | after | |
|---|---|---|
| session-start payload | 52,068 B | ~10,700 B |
| notes loaded up front | 51 | 8 |
| notes loaded on demand | — | 43 |
| orphaned, reachable never | — | 0 (reported every build) |
Five files, about 700 lines including the docs, and 45 tests covering the extraction rules, the ranking, the once-per-session dedupe and — most importantly — that a broken index exits 0 and injects nothing rather than blocking an edit. Memory injection must never be able to stop work.
Two honest caveats. First, this is days old; the numbers are context size, not a
measured change in agent behaviour, and I should be sceptical of my own
enthusiasm until I have watched it for a month. Second, path-scoping cannot
generalise: a note anchored to Sitzungen.svelte will not fire while writing a
new component that walks into the same trap. Only the always-on set
generalises, which means the split between the two classes is a judgement call
that stays a judgement call.
But the shape feels right, and it is the opposite of what the RAG reflex would
have built. No vector store, no retrieval model, no similarity threshold to tune.
A hook, a JSON file, and git ls-files — because the thing that knows which
files exist is the repository, and the thing that knows when they matter is the
agent’s own next tool call.
Follow the project at @lage@bonn.social.