A Tour of the Harness · v1 · M7

Memory

What edgar keeps between sessions, and the line it will not cross to get it. Four stops, about half an hour.

Built M7, shipped in 1.0
Kept in sync by a test

Read stop 15 and stop 17 of the Core tour first: memory hangs off the prompt builder's pinned sections and off the append-only session file, and neither changes shape to make room for it.

The claim to test as you read: an active fact comes only from text a human typed, or from an error record the harness computed itself. Everything else — the model's own remember call included — can become a pending fact at most, and a pending fact is never in the prompt and never in the search index (MEM-8, MEM-9, ADR-0017). It is enforced by what each function accepts, not by a filter you could forget to call.

flowchart TD
    typed["text a human typed
provenance = user"] --> near{"near an existing
active fact?"} model["the model's remember call
provenance = model-proposed"] --> pend["pending"] near -->|no| act["active"] near -->|yes| pend pend -->|"a human runs confirm"| act pend -->|"a human declines"| gone["forgotten"] act --> idx[("the FTS5 index
= exactly the active facts")] act --> prompt["pinned into the prompt,
frozen at session start"] pend -.->|never| idx pend -.->|never| prompt

Stage 1. The boundary about 15 minutes

1What becomes a fact

memory/store.py memory/redact.py

Look for: add confirm _Op pinned overlap NEAR redact ASSIGNED

The whole boundary comes down to one line inside add: a fact becomes active only when its provenance is "user" — someone typed it — and nothing already active says nearly the same thing. overlap measures "nearly the same" (word-level Jaccard); NEAR is the threshold. Even a human's near-duplicate lands pending, carrying the id of the fact it would replace, because a contradiction is for a person to settle, not for whoever wrote last (MEM-10).

add(text, provenance):
    if provenance != "user":
        status = "pending"                      # model-proposed, always pending
    elif text is near-duplicate of an active fact (overlap > NEAR):
        status = "pending"                      # a human resolves the conflict
    else:
        status = "active"
    store the fact with that status

Read _Op next, and notice what it never does: it never rewrites a fact's text. An edit is a new fact plus a supersedes link. Every confirm, forget and replace is just a status change, logged under one operation number — which is the only reason edgar memory undo can always put things back.

  • The index invariant is worth finding yourself: the same function removes a fact from both FTS5 tables and only re-inserts it when the new status is exactly "active". "A pending fact can never be recalled" is true by construction, not by a check bolted on somewhere downstream.
  • pinned is the one read that also writes: it bumps each returned fact's use count. The set it returns is fixed once, at session start, and sits above the cache breakpoint (MEM-6, CTX-17) — which is why a fact you add now only shows up starting next session. That's a surprise worth understanding, not a bug to fix.

redact.py is twenty-five lines that run both on the way in and on the way out. Whole-secret shapes are stripped first and vanish entirely. Then ASSIGNED catches patterns like token = …, keeping the name and dropping only the value, so the fact still reads sensibly. It's honestly best-effort — the real guarantee is that secrets come from the environment (CFG-6), not that a regex always catches them.

Take with you: the boundary is a type signature, not a filter. Nothing on this path even accepts tool output, error text, fetched content, or an attached file in the first place — there's no filter to forget to call.

Stage 2. Getting it back about 10 minutes

2Recall, without an embedding model

memory/retriever.py memory/recall.py

Look for: Retriever Hit retriever Fts5Retriever search index SNIPPET

retriever.py is one of the six ports (ADR-0022): a Protocol with a single method, so a vector or graph plugin never has to import anything from edgar at all. The factory resolves a built-in name inside the function — the startup budget again — and otherwise looks in an entry point group, failing with a message that lists what's actually installed.

Core carries no embedding model, on purpose (ADR-0024). Instead, Fts5Retriever runs every query twice — once over a porter-stemmed index, once over a trigram one — and merges the two, so a porter hit always outranks a trigram hit on the same item. That's what lets a fragment like config/lo still find a path, which stemming alone never would.

index is where the care lives:

  • It seeks to a stored byte offset, reads the rest of the session file, then throws away everything after the last newline — the file is append-only, and the very last line might still be half-written.
  • It only indexes text blocks that aren't attached. Tool results and attached files stay out of the search index for the same reason they stay out of the facts: nobody chose to type them.

3The markdown round trip

memory/markdown.py cli/memory.py

Look for: render parse edit HEADER command said _review

Facts are rendered to markdown, opened in your editor, and read back. What you save is the new active set — delete a section, and everything in it is gone, not just left unchanged. parse strips HTML comments before it reads anything else, which is how HEADER can explain the rules right there in the buffer without itself turning into a fact.

  • The editor command is split with shlex, so something like code --wait works — but it's still never run through a shell.
  • The program is resolved with which first, which is what makes code.cmd work on Windows.
  • A non-zero exit from the editor changes nothing at all — no half-applied edit.

In cli/memory.py, said exists so edgar memory add and /remember report the exact same outcome in the exact same words, pending case included. _review is the human confirmation gate, and its default is the safe one: the prompt is [y/N], and with no terminal to ask, facts just stay pending — never guessed either way.

Stage 3. Branching a session about 10 minutes

4Forks and saves

storage/transcript.py

Look for: fork chain _upto save _inline _scrub forks adopt

M7 added three moves to sessions without breaking Core's append-only rule (ADR-0046). A fork costs exactly one line: a new file whose second line names its parent and a turn to branch from. The parent file is never copied and never touched. chain walks up while that second line is a fork record, then reassembles everything downwards with a loop, not recursion. _upto finds where to cut by looking for that turn's own finish record.

flowchart LR
    p[("parent.jsonl
head · turns 1-9")] -->|"read up to turn 4"| c c[("fork.jsonl
head · fork line · its own turns")] --> v["the view
chain(path)"] v -->|"save: inline blobs, then redact"| out[("one portable file")] out -->|adopt| new[("a new session of this project,
with a new id")]

save is the opposite move: it flattens the whole chain into one file that can travel anywhere.

  • _inline reads spilled tool output back off disk, so the saved file is self-contained — no dangling references to blobs that live only on your machine.
  • _scrub then walks the whole JSON structure — with an explicit stack, not recursion — redacting string by string. That's deliberate: a pattern matched across JSON syntax could corrupt the structure, not just the text.
  • None of this ever rewrites the original session file.

forks is the reverse lookup, and it shows what the layout buys you: it reads exactly two lines of each sibling file to find the children of one session. adopt deliberately turns a loaded file into a new session of this project, not a fork — so a file someone sent you can never claim a parent you don't actually have.

Sizes

Lines of code: blank lines and comments do not count, docstrings do. just loc prints the current totals.

FileLines of codeWhat it is
memory/store.py~205The facts table, the operation log, and the boundary
memory/recall.py~73The FTS5 adapter: two indexes, and the incremental catch-up
memory/markdown.py~52Facts to a buffer and back
memory/retriever.py~36The Retriever port and its factory
memory/redact.py~25Best-effort secret shapes, in and out
cli/memory.py~73The verbs, and the review gate
storage/transcript.py~187The session file, and what forks and saves add to it

Source: just loc, which counts with tests/support/budget.py. Rounded; the tour's test fails if a figure drifts more than 25 lines of code from the code.