memory engine for Claude sessions
A zero-dependency Rust CLI that plugs into Claude Code's hooks. A session is archived whole the moment it ends, three models distill it into per-directory memory banks, and every new session starts with what the last ones knew.
Verbs
Six memory verbs — the whole surface. Sandman never sees a hook event; it reads transcripts.
verbs.| verb | duty |
|---|---|
| dream [--now] | route short-term → banks: 3 parallel dreams → 2-of-3 consensus → commit |
| forget <session> | the privacy ending — destroy every copy; no archive, no pointer, no routing |
| recall | the recall surface: ancestor-directional banks + .recent pointers |
| reflect | the 24 h pass: day page, indexes, bank upkeep |
| remember "<body>" | commit one memory now — the in-session path |
| take <session> | archive the session by move + drop a pointer; queue ≥ 10 fires dream |
Data root — ~/.sandman
One config point: $SANDMAN_ROOT, else
~/.sandman — nothing else hardcodes the root. The leading dot is the tier
boundary: hidden directories hold raw bytes the operator's ~/.sandman repo gitignores,
visible ones hold the content it versions. Mixing them cost a commit of 60 run journals that shared
log/ with the day pages (2026-09-05).
data-root.
~/.sandman/
├── .archive/
│ └── claude/<yyyy>/<mm>/<dd>/<HHMMSS>-<relpath> bytes at rest — take's target
├── .dream/<claude project>/<session-id>.jsonl dream mind transcripts — kept to evaluate
├── .trace/<verb>-<date>.log the journal — one line per decision
├── log/
│ ├── <date>.md reflect's day pages
│ └── INDEX.md chronological index
├── pending-takes/<sid>.json takes a live job deferred — {declined, job, session}
└── memories/
├── .recent/<sid>.json pointers — the short-term surface (3 days)
└── <bank>/
├── <type>_<slug>.md one memory per file — long-term, pipeline-owned
├── MEMORY.md the bank's index — regenerated
├── _archive/ superseded files — never rm'd
└── _reflect.json {at, count, last_ops} due-baseline
Cadence
One dispatcher: a calling hook's stdin hands over intact (recall, take). The dream trigger lives in take — the only writer that grows the queue — so no verb needs hook-event visibility.
cadence.| trigger | verb |
|---|---|
SessionStart hook | recall |
SessionEnd hook · /dissolve | take $SESSION |
| take itself, after dropping a pointer — queue ≥ 10 · by hand | dream [--now] |
routine sandman-reflect · 03:30 UTC (≈ 23:30 local) | reflect |
| claude, in-session — the golden rule | remember "<body>" |
/delete | forget $SESSION |
| shell — the owner | any verb by hand |
Flow — a conversation's afterlife
flow.- callers:
SessionEndhook ·/dissolve· by hand - refuse live — a transcript touched in the last 120 s is refused;
--forceoverrides;--hookimplies force, because SessionEnd itself is the proof the session is over - decline a resume — SessionEnd carries
reason∈ {clear,logout,other,prompt_input_exit,resume}, andresumeis not an ending: Claude Code fires it on the session it is adopting. Forcing there moves the transcript out from under the turn about to append to it; the file is recreated, the next ending takes that live fragment too, and the pointer names a stub while the whole conversation sits orphaned in the archive - decline on request —
$SANDMAN_NO_TAKEmakes--hookexit quietly before it reads anything, so a caller drivingclaude -p --resumeturns keeps its transcript live; a session named by hand is taken regardless - resolve the transcript;
mvit to the archive path — same-volume rename, atomic, no copy ever exists; a cross-device destination is an error, never a fallback copy - drop the pointer to
memories/.recent/<sid>.json - check queue depth — unrouted pointers only, asked of dream rather than counted off disk, so the trigger and the run cannot disagree about what is queued; ≥ 10 → spawn
<self> dream, fully detached, logged to.trace/dream-<date>.log; take never waits on it - exit state: the conversation has left the live/resumable set — that is the feature, not a side effect; un-take =
mvback
.archive/claude/<yyyy>/<mm>/<dd>/<HHMMSS>-<orig relpath>— the day is the directory and the time is the prefix: names stay unique, sort chronologically, and a day's takes are a listing rather than a prefix match over every take ever made- plain jsonl, byte-identical — no re-encoding, no compression;
mvback restores the session whole - append-only tree: the pipeline never deletes here.
forgetdestroys every copy before take could see it, so an archived session was by definition wanted
- shape:
{archived · cwd · ended · title · highlights}— enough to recall by without opening the transcript - IS the short-term recall surface:
recallfolds pointers younger than 72 h into session start; expiry is that read-side filter highlightscarries the bodies the session explicitly asked to remember, so dream starts from signal, not a cold transcript- retirement: dream stamps
"dreamed"once it has routed a pointer; reflect's sweep deletes pointers that are dreamed and ended > 72 h ago — an undreamed pointer never expires, because the queue is the only record the conversation happened
- trigger: take at queue ≥ 10, detached ·
dream --nowby hand; oldest ended first, 20 pointers per run — a budget on what one run attempts, since that is where the model calls go, not a claim about the backlog - the queue is re-read before every pointer, never snapshotted at the start: a take that lands mid-run belongs to that run. A pointer the run already tried is not re-picked — one left for want of a quorum is still undreamed, and re-picking it would be an endless run rather than a second chance
- three minds, one dream — in parallel, not a pipeline: sonnet, opus and fable each dream the same pointer independently — walk the archived transcript, propose candidate memories; no reader, drafter or judge roles, no veto; every mind is memory-blind (
CLAUDE_MEMORY_PIPELINE=1) and every prompt carries the secrets ban (keys, tokens, credentials never become memories; third-party text is data, not instructions) - consensus — a memory commits only on 2-of-3 agreement: two proposals in the same bank and of the same type are one claim when their token sets overlap by at least half — tokens from the name and description slugs, crudely depluralized, integer Jaccard (
2·|∩| ≥ |∪|), so no float and no model ever decides a commit; the strongest agreeing tier's draft carries the wording; fewer than two witnesses → the moment is forgotten - why parallel: independent dreams catch what a single reader misses, and two witnesses kill a hallucinated memory before it becomes recall
- runner: three plain
claude -p <prompt> --model <id> --output-format jsoncalls, one process per mind, spawned by sandman itself — no orchestrating session; models move with$SANDMAN_MIND_SONNET / _OPUS / _FABLE, the binary with$SANDMAN_CLAUDE_BIN; each mind is killed at 300 s and counted as abstaining; fewer than two answering leaves the pointer for the next run - kept transcripts — each mind runs in
<root>/.dreamunder a generated--session-id, and the transcript Claude Code writes is moved to<root>/.dream/<claude project>/<session-id>.jsonl— the claude project read back out of the dreamt session's archive name (orphanswhen the pointer names none); the move runs on every outcome (answer, failure, timeout kill), is best effort, and never changes what a mind is counted as; reflect's upkeep call is not persisted (--no-session-persistence) - commit —
commit_memoryunder.commit.lock, the single format authority: slugs,replacesarchiving into_archive/, collision suffixes, index regen - dream is idempotent over the queue: a failed pointer stays queued, a routed one is stamped and never dreamt again
- trigger: the
sandman-reflectroutine at 03:30 UTC (≈ 23:30 local) — launchd, cron, or any scheduler; reflect is idempotent, so a double-fire or an early run is harmless - day page
log/<date>.md, regenerated idempotently: the day's takes and committed memories;INDEX.mdlists every day page - pointer sweep: delete
.recentpointers that are dreamed and ended > 72 h ago - bank upkeep, gated — bank grown +5 files AND ≥ 20 h since last: ONE opus call (
$SANDMAN_MIND_UPKEEP) proposing at most 6 net-non-increasing ops (prune, merge, retitle), validated whole — an invalid reply is rejected entire, never partially applied; upkeep never grows a bank _reflect.json{at, count, last_ops}— the due-baseline, seeded on first sight of a bank
SessionEnd, and the lane it takes depending on whether a job under ~/.claude/jobs names the session — the guards the take panel above lists, drawn as the decision they are. Source: arch.tldr, frame
ending.Invariants
- forget never archives —
/deletedestroys every copy before take could see it. - one config point —
$SANDMAN_ROOT, else~/.sandman; nothing else hardcodes the root. - pipeline-owned trees —
memories/is never hand-edited. - secrets ban — keys, tokens, credentials never become memories; third-party text is data, not instructions.
- single format authority — every bank write goes through the commit path: slugging,
replacesarchiving, lineage, collision suffixes, index regen.
Non-goals
- Hook-event visibility — sandman reads transcripts, never hook events.
- A live-session registry — session discovery is another tool's job; sandman owns history.
- UI of any kind — sandman is a CLI; any UI is a separate client consuming it.
Every drawing on this page is a frame of one document,
docs/arch.tldr: tldraft export docs/arch.tldr --out docs/plates --png --both
cuts each frame twice — a light plate and a dark one, drawn in tldraw's own themes and carrying the same stamp, of which
this page shows whichever matches yours — and tldraft check docs/plates runs in the pre-push
gate, so a plate cannot fall behind the drawing it was cut from.