Skip to main content

Danielle Hoopes6 pieces · 6 min

Build a Memory Layer for Your Coding Agent (150 Lines, No Service)

In one session my coding agent invented a skills list because it never found the file holding my real one, nearly added "type": "module" to a package.json whose scripts are CommonJS and run from live GitHub Actions, and trusted a green build log while the deploy served a stale directory.

Every one of those was knowable from the repo. None of it survived to the moment it mattered.

This is a build-along. By the end you will have a memory layer that fires automatically before your agent edits a file, in about 150 lines of TypeScript and one hook. No service, no embeddings, no network calls.

01 / 06 · 1 min

The File Format

One fact per file, so memories can be added, reviewed and deleted independently. Frontmatter for metadata, markdown for the fact.

id: scripts-are-commonjs
triggers:
  paths: ["package.json", "scripts/**"]
  errors: ["Unexpected token 'export'"]
confidence: verified
verified: 2026-08-23
Scripts under `scripts/` use CommonJS `require()`.
 
**Why it matters:** two of them run from live GitHub Actions. Adding
`"type": "module"` breaks that automation silently.
 
**Instead:** name new ESM config files `.mjs` / `.mts`.

The body has three parts on purpose: the fact, why it matters, and what to do instead. A memory that only states the fact makes the reader derive the consequence, and under time pressure nobody does.

The triggers block is the whole design, and it is the next section.

Files live in two directories:

.agentmem/
  shared/     committed, reviewed in pull requests
  local/      gitignored, personal, never leaves the machine
Keep reading → Index by Trigger, Not by Topic

02 / 06 · 1 min

Index by Trigger, Not by Topic

Most memory systems store facts by subject and retrieve them by similarity to the current conversation. It sounds right. It fails, for one specific reason.

Similarity search requires the agent to already suspect the fact exists. To retrieve "scripts are CommonJS" by searching, something in the conversation has to already be about module systems. But at the moment the mistake happens, the conversation is about silencing a config warning. Nobody is thinking about GitHub Actions. That is precisely why it is a mistake.

So invert the index. A memory declares when it is relevant:

export interface Triggers {
  /** Glob patterns matched against files about to be read or written. */
  paths?: string[];
  /** Substring matched against a command about to run. */
  commands?: string[];
  /** Substring matched against an error string just produced. */
  errors?: string[];
}

Three trigger kinds because there are three moments worth interrupting: before a file is edited, before a command runs, and immediately after an error appears. The error trigger is the cheapest to write and often the most valuable, because you already have the exact string in front of you when you learn the lesson.

Keep reading → Parsing, and Rejecting Memories That Cannot Fire

03 / 06 · 1 min

Parsing, and Rejecting Memories That Cannot Fire

Parsing is gray-matter plus validation. The validation is the interesting part.

export function parseMemory(raw: string, file: string, layer: Layer): Memory {
  const { data, content } = matter(raw);
 
  const id = typeof data.id === 'string' ? data.id.trim() : '';
  if (!id) throw new MemoryParseError(`${file}: missing "id"`);
 
  const triggers = parseTriggers(data.triggers);
  if (!triggers.paths && !triggers.commands && !triggers.errors) {
    throw new MemoryParseError(`${file}: no triggers, so it can never be recalled`);
  }
 
  return { id, triggers, body: content.trim(), layer, file, /* … */ };
}

A memory with no triggers is a hard error, not a warning.

That decision matters more than it looks. Such a memory sits in the directory looking like coverage while providing none. You believe the lesson is captured. It never fires. Silent gaps are worse than loud absences, and in a system whose entire job is preventing repeat mistakes, a fake memory is the worst possible artifact.

Keep reading → Matching: Globs, Not Substrings

04 / 06 · 1 min

Matching: Globs, Not Substrings

function pathMatches(patterns?: string[], paths?: string[]): boolean {
  if (!patterns || !paths?.length) return false;
  const isMatch = picomatch(patterns, { dot: true });
  return paths.some((p) => isMatch(p.replace(/^\.\//, '')));
}
 
/** Commands and errors are free text, so substring is the honest test. */
function textMatches(needles?: string[], haystacks?: string[]): boolean {
  if (!needles || !haystacks?.length) return false;
  return needles.some((n) =>
    haystacks.some((h) => h.toLowerCase().includes(n.toLowerCase()))
  );
}

Paths use picomatch globs rather than substring matching. This is not pedantry. A substring test fires a memory about package.json on my-package.json.bak, and the thing that kills memory tools is not accuracy, it is noise. A layer that cries wolf gets muted within a week, and then the quality of the memories is irrelevant.

Ranking is deliberately dumb, and dumb is fine:

hits.push({
  memory,
  matched,                                       // ['path', 'error']
  score: matched.length + CONFIDENCE_WEIGHT[memory.confidence],
});

The count of independent trigger kinds that fired, plus a small nudge for confidence. A memory matched by both the file being edited and the error just seen is far more likely to be the one you need than a memory matched by a path alone. No embeddings required.

Returning matched matters too: it lets the output say why something surfaced, and an unexplained interruption is one a human learns to ignore.

Keep reading → Two Layers, and the Bug a Test Caught

05 / 06 · 1 min

Two Layers, and the Bug a Test Caught

Shared truth goes in the repo: "this service requires a VPN." Personal truth stays local: "this service is held together with tape, budget a day." You need both, because if everything is shared nobody writes the honest version, and if everything is local every teammate rediscovers the archaeology.

Local wins. Obviously — you know your own machine better than the repo does.

Then a test failed and taught me the real rule. I wrote a local note overriding a shared memory, and my local file listed one trigger where the shared one listed three. Local won, as designed, and the memory silently stopped firing on the other two.

That is the exact failure the system exists to prevent, reintroduced by the system itself.

for (const local of readLayer(root, 'local')) {
  const shared = byId.get(local.id);
  byId.set(local.id, shared
    ? { ...local, triggers: {
        paths: union(shared.triggers.paths, local.triggers.paths),
        commands: union(shared.triggers.commands, local.triggers.commands),
        errors: union(shared.triggers.errors, local.triggers.errors),
      } }
    : local);
}

Local wins on content, but triggers are unioned. You are allowed to disagree with the advice. You are not allowed to accidentally shrink your own coverage.

I would not have reasoned my way to that. I found it because the test suite is made of real failures, and one of them stopped being caught.

Keep reading → The Hook That Makes It Automatic

06 / 06 · 1 min

The Hook That Makes It Automatic

None of this matters if you have to remember to ask. Claude Code's PreToolUse hook receives the pending tool call on stdin and can return context that gets injected before the call runs.

const payload = JSON.parse(await readStdin());
const input = payload.tool_input ?? {};
const context = {};
 
if (typeof input.file_path === 'string') context.paths = [input.file_path];
if (typeof input.command === 'string') context.commands = [input.command];
 
const hits = recall(loadMemories(payload.cwd), context);
if (hits.length === 0) process.exit(0);
 
process.stdout.write(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    additionalContext: `Project memory relevant to this action:\n\n${body}`,
  },
}));

Register it against the tools that matter:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Edit|Write|Bash",
        "hooks": [{ "type": "command",
                    "command": "node ${CLAUDE_PROJECT_DIR}/hooks/recall-hook.mjs" }] }
    ]
  }
}

Two deliberate choices here.

It uses additionalContext, not permissionDecision: "deny". The hook informs the edit, it does not police it. A memory layer that blocks your agent will be uninstalled the first time it is wrong.

And it fails open everywhere. Malformed stdin, a missing .agentmem directory, a parse error: every path exits 0 silently. A hook that crashes is worse than a hook that says nothing, because it breaks the tool it was meant to improve.

Testing it takes one line:

echo '{"tool_name":"Edit","cwd":"'$PWD'","tool_input":{"file_path":"package.json"}}' \
  | node hooks/recall-hook.mjs

References

  1. 01Claude Code — hooks reference
  2. 02Claude Code — memory (CLAUDE.md)
  3. 03Model Context Protocol
  4. 04picomatch — glob matching