---
title: "Build a Memory Layer for Your Coding Agent (150 Lines, No Service)"
description: "Your agent forgets everything between sessions and repeats the same mistakes. Here is a working trigger-indexed memory layer, the matching logic, and the Claude Code hook that fires it automatically before an edit."
date: "2026-08-25"
tags: [ai, agents, developer-tools, claude-code, typescript, tutorial]
sources:
  - title: "Claude Code — hooks reference"
    url: "https://code.claude.com/docs/en/hooks"
  - title: "Claude Code — memory (CLAUDE.md)"
    url: "https://code.claude.com/docs/en/memory"
  - title: "Model Context Protocol"
    url: "https://modelcontextprotocol.io/"
  - title: "picomatch — glob matching"
    url: "https://github.com/micromatch/picomatch"
---

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.

## The File Format {#file-format}

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

```markdown
---
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
```

## Index by Trigger, Not by Topic {#triggers}

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**:

```ts
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.

## Parsing, and Rejecting Memories That Cannot Fire {#parsing}

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

```ts
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.

## Matching: Globs, Not Substrings {#matching}

```ts
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](https://github.com/micromatch/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:

```ts
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.

## Two Layers, and the Bug a Test Caught {#two-layers}

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.

```ts
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.

## The Hook That Makes It Automatic {#the-hook}

None of this matters if you have to remember to ask. Claude Code's [PreToolUse hook](https://code.claude.com/docs/en/hooks) receives the pending tool call on stdin and can return context that gets injected before the call runs.

```js
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:

```json
{
  "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:

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

## TL;DR

| Decision | Why |
|---|---|
| Index by **trigger**, not topic | Similarity search needs you to already suspect the fact exists |
| Globs, not substrings | `package.json` must not fire on `my-package.json.bak`. Noise kills these tools. |
| No triggers is a **hard error** | It looks like coverage while providing none |
| Local wins content, triggers **union** | Overriding advice must not shrink coverage |
| `additionalContext`, never `deny` | A layer that blocks you gets uninstalled |
| Fail open on every error | A crashing hook is worse than a silent one |

The idea worth stealing even if you build none of this: **a memory nobody retrieves at the right moment is worth nothing.** Optimise for the moment of retrieval, not the quality of the writing.

Your `CLAUDE.md` is already a memory system. It just has no retrieval logic.
