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.
| File | Lines of code | What it is |
memory/store.py | ~205 | The facts table, the operation log, and the boundary |
memory/recall.py | ~73 | The FTS5 adapter: two indexes, and the incremental catch-up |
memory/markdown.py | ~52 | Facts to a buffer and back |
memory/retriever.py | ~36 | The Retriever port and its factory |
memory/redact.py | ~25 | Best-effort secret shapes, in and out |
cli/memory.py | ~73 | The verbs, and the review gate |
storage/transcript.py | ~187 | The 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.