Contents

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} nests items inside event, 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 → context

Open 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

beforeafter
session-start payload52,068 B~10,700 B
notes loaded up front518
notes loaded on demand43
orphaned, reachable never0 (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.