Type something to search...

The memory remembers

Part 2: memory that lives in git beside the code, keeps private notes private, fits a budget, turns a repeated failure into a lesson, and only ever advises.

Warning

Viewing a draft version — this page is not published.
The memory remembers

Here is the thing we want. An agent finishes a task, and what it learned does not vanish with the session. The next session, on my machine or a colleague’s, starts knowing that the tests need a clock injected, that releases are tagged from main, that this has now failed three times.

Nobody pasted it into a prompt. It lives with the code, travels with git push, and when it is wrong it is corrected rather than erased.

That is memory, and in this part we run it on its own: no harness, no factory, no agent. It stays out of your code, keeps your private notes private while the team’s travel, hands a session only what fits, turns “this keeps happening” into a lesson, and never gives an order. The tool is ynm, an experiment built to prove the idea rather than a product. The ideas are what you take with you.

Memory that never touches your code

Most tools that remember for an agent do it with a file in your tree, which someone then has to review, merge and fight over. Here a memory is a line of JSON in a git note on the repository’s root commit. I stored one and checked what changed:

ynm remember, then look at git

remembered  in project

48a71e0… commit  refs/notes/ynm/distributed/common/procedural/2026-10d4d82aa Add ynm memory51090bc target as received
the memory is a note. the tree is clean, two commits, no branch moved.
  CODE (all that diffs and the working tree ever see)

  51090bc ──▶ d4d82aa ──▶ new ──▶ new ──▶ new        main
  (root)         │
     │           └──▶ new ──▶ new                    a branch
     │
     │  the note stays on the root, however far main moves
     ▼
  ┌──────────────────────────────────────────────────────┐
  │ MEMORY (ynm): a git note on the root commit          │
  │ refs/notes/ynm/distributed/common/procedural/2026-10 │
  └──────────────────────────────────────────────────────┘

Memory hangs off the first commit, so no diff carries it and no rebase moves it.

What this makes possible. Memory that is not in the diff, so there is nothing to review, merge or let go stale, and your history stays yours. The only thing the team reviews is the setup (a small config, a server registration and a block telling the agent how to use memory), and that is reviewed the way everything else is: in a pull request.

A correction works the same way. It is a new record that points at the old one, so the log keeps both versions and the current view shows one. In our run, three records became two memories. An audit trail for what an agent believed before it was corrected comes free, and two clones that both correct the same memory both keep theirs.

Two stores, not two flags

Some things are mine: “I find the store package hardest to review.” Some are the team’s: “run the linter before pushing.” Behind a visibility flag in one store, one careless push leaks the first. So they are two git repositories. Personal memory sits in a bare repository in my ynm home, and shared memory sits in the project.

I gave the project a remote, wrote one memory of each kind, synced, then looked at what the remote held:

the remote, after ynm sync
refs/heads/mainrefs/notes/ynm/distributed/common/procedural/2026-10refs/notes/ynm/distributed/common/semantic/2026-10
the code branch and two shared note refs. nothing personal.
  YOUR MACHINE                             THE REMOTE
  ┌─────────────────────────┐
  │ PERSONAL STORE          │   no refspec to the remote
  │ bare repo in ynm home   │ ─ ─ ─ ─ ─ ─ ✗
  └─────────────────────────┘
  ┌─────────────────────────┐  push / sync  ┌──────────────────┐
  │ PROJECT                 │ ────────────▶ │ code branch      │
  │ code + shared notes     │               │ shared note refs │
  └─────────────────────────┘               └────────┬─────────┘
                                                     │ clone / pull
                                            ┌────────▼─────────┐
                                            │ SECOND MACHINE   │
                                            │ shared: arrives  │
                                            │ personal: empty  │
                                            └──────────────────┘

Only the bottom box has a road to the remote. The top one has none.

A clone standing in for a second machine pulled the team’s two memories and found no memories at the personal level. The doctor command agrees: personal refs never pushed, none in the project repository.

What this makes possible. A rule you can state and check: personal memory cannot reach the remote, because no refspec points there. The team’s memory arrives with git clone and git pull, through the access controls you already have, with no memory server to run or secure.

The right memory, in a budget

A session cannot read everything. Recall ranks what is relevant, and context packs the best of it into a token budget. The ranking shows its working, so I asked about a flaky clock:

ynm recall --text "flaky clock" --explain

0.875  semantic  personal  TestSince is flaky: it compares against time.Now(). Fix by injecting a clock; …

rel 1.00  rec 1.00  imp 0.50
0.6 relevance, 0.2 recency, 0.15 importance, 0.05 pinned

Then the block a session would start with, cut to forty tokens:

ynm context --budget-tokens 40
## Memory- (procedural) Tag releases from main only, after the gate is green. [release]
included 2, 43 tokens, truncated true. the flag tells you something was left out.

What this makes possible. Context that costs what you say it costs. A cheap lane gets forty tokens of the best memory, a careful one gets more, and truncated tells you when something was dropped. Because the explanation shows why a memory ranked where it did, a surprising result can be argued with instead of guessed at.

”This keeps happening” turns itself into a lesson

Memory fills with repeats, and a repeat is information. Three episodes from three CI failures shared a subject. Dreaming is the maintenance pass, and its reflect step is the one model call in this whole part: claude drafts a summary, and a judge checks the draft against the three episodes before ynm keeps it.

ynm dream --passes reflect

judge  writer-emulated:claude-cli, calibrated false

reflect  candidates 1, changed personal:flaky-testsince

reflective memory, derives-from links: 3
one lesson, linked back to the three events it came from
  3 episodes (CI failures, one subject)
        │
        ▼
  ┌──────────────────┐   drafts    ┌──────────────────────┐
  │ WRITER (claude)  │ ──────────▶ │ JUDGE                │
  └──────────────────┘             │ checks draft against │
                                   │ its sources          │
                                   └─────┬─────────┬──────┘
                                  fits   │         │  does not fit
                                         ▼         ▼
                        KEEP: reflective memory   WITHHELD
                        derives-from the 3

  Separate branch, no model:
  near-duplicates ──▶ flagged for a person ──▶ never merged
                      (merging waits for a calibrated judge)

Reflecting adds a memory. Nothing in dreaming deletes one.

In our run it wrote: “TestSince failed intermittently in CI on 2026-10-08 (runs 412, 431, 447). Runs 412 and 431 failed at the one-second boundary and passed on rerun. A local -count=50 run reproduced the run 447 failure twice. The test reads time.Now() directly.” Yours will differ. The judge’s scores for our draft were unsupported=0.15 lostFact=0.05 wrongDate=0.04, under the 0.7 at which a draft is withheld.

I would not oversell that judge. It is the same kind of model as the writer and it is uncalibrated, so the model drafts and the check decides, but the check is imperfect. That is why the cautious half of dreaming only flags. It compared fifteen pairs of memories, flagged three near-duplicate greet notes, and merged nothing. Merging and retiring wait for a calibrated judge.

What this makes possible. A team’s repeated failures become a lesson without anyone writing it, one model call per lesson, with the sources kept. Near-duplicates are flagged for a person instead of merged on a guess, so the store gets tidier without getting less true.

Memory advises, it never decides

All of that would be dangerous if memory could steer. So I looked at everything it hands a client at session start, using the JSON that Claude Code would send:

the session-start hook, its keys
hookSpecificOutputhookSpecificOutput.hookEventNamehookSpecificOutput.additionalContext
one string of text. no decision, permission, block or stop.

The prompt hook is a nudge in words, and the stop hook says nothing at all. One caveat: the session text includes my personal memories too. Personal means invisible to other people, not to my own agent.

What this makes possible. You can put memory in front of an agent knowing it cannot turn into a back door. A wrong or poisoned memory costs the agent a bad hint, not a skipped gate. The harness from Part 1 still decides what done means. Memory only helps the agent get there.

The Debrief

TL;DR

This part runs memory on its own, with no agent and no factory. What a session learns outlives it, travels with git push, and is corrected rather than erased.

Memories are git notes on the root commit, so they never show up in a diff. Personal and team memory are two separate stores, and only the team’s has a way to reach the remote. Recall ranks memories and packs the best into a token budget, one model call turns three repeated failures into a lesson, and the only thing memory ever hands an agent is text.

The ideas

  • Memory sits outside the working tree, so there is nothing to review, merge or let go stale.
  • A correction is a new record pointing at the old one, so you keep an audit trail.
  • Two stores, not two flags, makes “personal never leaves my machine” something you can check.
  • Context fits a budget, and says when something was left out.
  • A judge checks every lesson a model writes. Near-duplicates are flagged for a person, never merged on a guess.
  • Memory advises. It never decides.

What to take away

  • Keep memory out of your diffs.
  • Separate personal and shared memory physically, not with a flag.
  • Let memory carry text and nothing else. A bad hint is cheap. A bad decision is not.

Tutorial

You will build the whole of Part 2 from an empty folder: memory in git notes, a correction, personal and shared stores, a budget, a duplicate warning, one dream that writes a lesson, and the hooks. About fifteen minutes. One model call, in step 8, roughly 600 output tokens, so pennies at list price. Everything else is local and free.

1. Install the tools

This part needs only ynm.

brew install eyelock/tap/ynm
ynm --version    # tested with 0.4.1

On Linux, the same version is on the ynm releases page. You also need git with an identity set (git config user.name and user.email), jq, and the claude CLI signed in, which ynm finds on its own for the one model call. Commands are written for bash: on macOS, run bash first.

2. Start clean

Everything lives under one folder, and ynm gets its own home there, so nothing touches your own ~/.ynm. Run this every time you start or restart the part:

export LTL="$HOME/ltl"                    # any folder you like; keep the path short
export YNM_HOME="$LTL/.home/ynm-part2" YNM_USER=ltl
export P="$LTL/part2"
mkdir -p "$LTL"

Remove anything a previous run left, then fetch the target service fresh (it is public, in the ynf repository):

cd "$LTL" && rm -rf "$P" "$YNM_HOME"
mkdir -p "$P" "$YNM_HOME" && cd "$P"
curl -sL https://github.com/eyelock/ynf/archive/refs/tags/v0.2.0.tar.gz | tar xz
cp -R ynf-0.2.0/sandbox/seed work && rm -rf ynf-0.2.0 work/.agents
cd work
git init -q -b main && git add -A
git -c user.name="${YNM_USER}" -c user.email=ltl@example.invalid commit -qm "target as received"
ynm init --personal
command -v ynm

You should see ynm report its personal store under $LTL/.home/ynm-part2, then the path of your ynm. ynm hangs its notes off the repository’s root commit, which is why the work copy needs one commit. Every command below runs from $P/work.

Coming back later: in a fresh shell, re-run the three export lines above, then cd "$P/work". Each step sets any other variable it uses itself, so you can stop between steps.

3. Memory that never touches your code

Set the project up for Claude Code, commit what it leaves, and store the first shared memory:

cd "$P/work"
ynm init --client claude-code
git status --short
git add .ynm .mcp.json CLAUDE.md
git -c user.name=ltl -c user.email=ltl@example.invalid commit -qm "Add ynm memory"
ynm remember --type procedural --level distributed --tags lint \
  --content "Run golangci-lint run ./... before pushing; CI rejects findings."
R=$(git rev-list --max-parents=0 HEAD)
git for-each-ref refs/notes
git notes --ref=ynm/distributed/common/procedural/$(date -u +%Y-%m) show $R
git status --short; git log --oneline; git branch

You should see three untracked paths (.mcp.json, .ynm/, CLAUDE.md) before the commit, then:

remembered 01M4DM7J0X... in project
48a71e0... commit	refs/notes/ynm/distributed/common/procedural/2026-10
{"v":1,"id":"01M4DM7J0X...","memoryId":"01M4DM7J0X...","op":"create","type":"procedural","level":"distributed", ...}
d4d82aa Add ynm memory
51090bc target as received
* main

Ids and hashes differ on your machine. The memory is a note, the tree is clean afterwards, and there are still two commits. If git status lists anything after the commit, the setup did not finish.

4. Nothing is ever lost

Store a fact, then correct it:

cd "$P/work"
ynm remember --type semantic --subject flaky-test \
  --content "TestSince is flaky: it compares against time.Now() with no clock injection."
ID=$(ynm list --json | jq -r '.[]|select(.subject=="flaky-test")|.memoryId')
ynm supersede --memory-id $ID \
  --content "TestSince is flaky: it compares against time.Now(). Fix by injecting a clock; do not widen the tolerance."
ynm export | jq -c --arg id $ID 'select(.memoryId==$id)|{op,content,links}'
ynm list --json | jq -c --arg id $ID '.[]|select(.memoryId==$id)|{versions,content:.current.content}'
echo "records in the log: $(ynm export | wc -l | tr -d ' ')"
echo "memories in the fold: $(ynm list --json | jq length)"

ynm export is the log, ynm list --json is the fold. You should see both versions in the log and one in the fold:

{"op":"create","content":"TestSince is flaky: it compares against time.Now() with no clock injection.","links":[]}
{"op":"supersede","content":"TestSince is flaky: it compares against time.Now(). Fix by injecting a clock; ...","links":[{"rel":"supersedes","to":"01M4DM7JQK..."}]}
{"versions":2,"content":"TestSince is flaky: it compares against time.Now(). Fix by injecting a clock; do not widen the tolerance."}
records in the log: 3
memories in the fold: 2

Three records, two memories. ynm remember stores at the personal level unless you pass --level distributed, which is why only the step 3 note is shared so far.

5. Two stores, not two flags

Look at where each kind of memory lives, then give the project a stand-in remote (a bare repository under $P, no GitHub) and write one memory of each kind:

cd "$P/work"
git for-each-ref refs/notes
git --git-dir="$YNM_HOME/store.git" for-each-ref refs/notes
git init -q --bare -b main "$P/remote.git"
git remote add origin "$P/remote.git" && git push -q -u origin main
ynm remember --type semantic --level distributed --subject store-layout \
  --content "internal/store keeps records in a JSON file; file.go owns the format."
ynm remember --type semantic --content "Private: I find the store package the hardest to review."
ynm sync
git -C "$P/remote.git" for-each-ref --format='%(refname)'

You should see one note ref in the project and one in the personal store, then the remote holding the code branch and the two shared note refs, and nothing personal:

refs/heads/main
refs/notes/ynm/distributed/common/procedural/2026-10
refs/notes/ynm/distributed/common/semantic/2026-10

Now play a second machine with a clone that has a memory home of its own:

cd "$P/work"
( git clone -q "$P/remote.git" "$P/clone" && cd "$P/clone"
  export YNM_HOME="$P/clone-home" YNM_USER=other
  ynm init --personal && ynm init --no-clients && ynm sync
  echo "--- distributed"; ynm list --level distributed
  echo "--- personal"; ynm list --level personal )
ynm doctor | grep personal

The clone gets its own empty personal store, --no-clients skips the Claude Code setup it already has from the commit, and sync pulls the team’s notes. You should see:

--- distributed
01M4DM7NT0...  semantic   distributed common  internal/store keeps records in a JSON file; file.go owns the format.
01M4DM7J0X...  procedural distributed common  Run golangci-lint run ./... before pushing; CI rejects findings.
--- personal
no memories

ynm doctor agrees: personal refs never pushed and no personal refs in project repo, both ok.

6. The right memory, in a budget

Add two more memories, then ask for a ranked answer with its working shown:

cd "$P/work"
ynm remember --type procedural --level distributed --tags release \
  --content "Tag releases from main only, after the gate is green."
ynm remember --type semantic --subject store-layout \
  --content "The store tests use t.TempDir, so they leave nothing behind."
ynm recall --text "flaky clock" --explain

You should see the correction from step 4 first, with the score broken down:

0.875  01M4DM7JQK...  semantic   personal  TestSince is flaky: it compares against time.Now(). Fix by injecting a clock; ...
        rel 1.00 rec 1.00 imp 0.50

Now the block a session would start with, in full and then cut:

cd "$P/work"
ynm context
ynm context --budget-tokens 40
ynm context --text store --budget-tokens 60 --json | jq -c '{included:(.included|length),tokens,truncated}'
- (procedural) Tag releases from main only, after the gate is green. [release]
- (procedural) Run golangci-lint run ./... before pushing; CI rejects findings. [lint]
- (semantic) The store tests use t.TempDir, so they leave nothing behind.
...
- (procedural) Tag releases from main only, after the gate is green. [release]
{"included":2,"tokens":43,"truncated":true}

Each block opens with a ## Memory heading, left out above. truncated true means something was left out to fit the budget.

7. Warned, never refused

Store three near-copies and watch the warnings:

cd "$P/work"
ynm remember --type semantic --tags api \
  --content "The greet command takes a --name flag and defaults to world."
ynm remember --type semantic --tags api \
  --content "The greet command takes a --name flag, defaulting to world."
ynm remember --type semantic --tags api \
  --content "The greet command takes a --name flag, defaulting to world!" --json | jq -c '{guidance}'
ynm recall --text greet --tags api

You should see a note after the second, a longer one under guidance after the third, and all three returned by recall:

remembered 01M4DM7WF2... in personal
note: 1 similar memory exists: 01M4DM7W2V... (The greet command takes a --name flag and defaults to world.). If one of them is the same fact, prefer `ynm supersede` over a duplicate.
{"guidance":"2 similar memories exist: 01M4DM7WF2... (...); 01M4DM7W2V... (...). If one of them is the same fact, prefer `ynm supersede` over a duplicate."}

8. “This keeps happening” turns itself into a lesson

Dreaming is the maintenance pass. First the cautious half, which makes no model call: YNM_NO_CLAUDE_CLI=1 stops ynm reaching for claude.

cd "$P/work"
ynm list --json | jq length
YNM_NO_CLAUDE_CLI=1 ynm dream
ynm list --needs-review
ynm list --json | jq length
YNM_NO_CLAUDE_CLI=1 ynm dream

You should see the three greet memories flagged, the count unchanged at nine, and dedupe: 0/15 changed among the pass lines. The second run ends with nothing new since the last run; only expiry ran. Nothing is merged.

Now three episodes from three CI failures that share a subject:

cd "$P/work"
cat > "$P/episodes.txt" <<'EOF'
Run 412: TestSince failed in CI, passed on rerun. The failure was at the one-second boundary.
Run 431: TestSince failed again on a busy runner. Rerun passed. Same boundary assertion.
Run 447: TestSince failed after a rebase; a local run with -count=50 reproduced it twice. The test reads time.Now() directly.
EOF
while IFS= read -r line; do
  ynm remember --type episodic --subject flaky-testsince --content "$line"
done < "$P/episodes.txt"
ynm dream --dry-run --passes reflect --json | jq -c '{writer:.full.writer,reflect:.passes.reflect}'

While the episodes go in, ynm may print note: 1 similar memory exists, which is step 7 again and harmless. The dry run is free and shows one candidate and no changes:

{"writer":"claude-cli","reflect":{"candidates":1,"changed":[]}}

The real run is the one model call:

cd "$P/work"
ynm dream --passes reflect --json | tee "$P/dream.json" | jq -c '{judge:.full.judge,reflect:.passes.reflect}'
ynm list --type reflective
ynm export --type reflective | jq -c '{op,type,links:[.links[]|.rel]|group_by(.)|map({(.[0]):length})|add}'

You should see a judge, one changed memory, the lesson, and links back to the three episodes. The wording of the lesson will differ from ours:

{"judge":{"name":"writer-emulated:claude-cli","calibrated":false},"reflect":{"candidates":1,"changed":["personal:flaky-testsince"]}}
01M4DM8ESA...  reflective personal    user/ltl  TestSince failed intermittently in CI on 2026-10-08 (runs 412, 431, 447). ...
{"op":"create","type":"reflective","links":{"derives-from":3}}
{"op":"annotate","type":"reflective","links":null}

$P/dream.json holds the full report, including the judge’s scores. If the draft is withheld, changed is empty and you can run the command once more.

9. Memory advises, it never decides

Look at everything the memory hands a client at session start, using the JSON that Claude Code would send:

cd "$P/work"
echo "{\"session_id\":\"demo-1\",\"cwd\":\"$PWD\",\"hook_event_name\":\"SessionStart\",\"source\":\"startup\"}" \
  | ynm hook session-start | jq -c '{keys:[paths|join(".")], text:.hookSpecificOutput.additionalContext[0:90]}'
echo '{"prompt":"remember I prefer tabs"}' | ynm hook prompt
ynm hook stop < /dev/null

You should see:

{"keys":["hookSpecificOutput","hookSpecificOutput.hookEventName","hookSpecificOutput.additionalContext"],"text":"ynm memory for this session (use memory_recall for more; use memory_remember to keep facts"}
{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"The user is asking you to remember something. Store it with memory_remember (ynm), not in a note file or memory directory. ..."}}
{}

The key list is complete: one string of text and no decision, permission, block or stop.

Clean up

cd "$LTL"
rm -rf "$P"                  # the work copy, stand-in remote, clone and dream.json
rm -rf "$YNM_HOME"           # this part's memory home

Your own ~/.ynm was never touched.

Next: the factory, with no agent in it at all, in Part 3.