sandman · design

design this page mirrors docs/DESIGN.md; the on-disk format is docs/BANK-FORMAT.md.

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.

Six boxes in alphabetical order — dream, forget, recall, reflect, remember, take — each carrying the one-line duty the table below spells out. Six boxes in alphabetical order — dream, forget, recall, reflect, remember, take — each carrying the one-line duty the table below spells out.
The CLI surface — one box per verb, in the order the table reads. Source: arch.tldr, frame verbs.
verbduty
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
recallthe recall surface: ancestor-directional banks + .recent pointers
reflectthe 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).

The ~/.sandman tree as nested boxes: .dream, archive/claude, log, pending-takes, and memories wrapping .recent and one bank, each with its one-line meaning. The ~/.sandman tree as nested boxes: .dream, archive/claude, log, pending-takes, and memories wrapping .recent and one bank, each with its one-line meaning.
The data root as nested boxes — the same tree below, with its ownership shown. Source: arch.tldr, frame 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.

Seven lanes, each a trigger box arrowed into the verb it fires, with the verb's effect named beside it: SessionStart to recall, SessionEnd to take, the queue to dream, the nightly routine to reflect, the golden rule to remember, /delete to forget, and the shell to any verb by hand. Seven lanes, each a trigger box arrowed into the verb it fires, with the verb's effect named beside it: SessionStart to recall, SessionEnd to take, the queue to dream, the nightly routine to reflect, the golden rule to remember, /delete to forget, and the shell to any verb by hand.
What fires each verb — one lane per trigger, the same seven rows as the table. Source: arch.tldr, frame cadence.
triggerverb
SessionStart hookrecall
SessionEnd hook · /dissolvetake $SESSION
take itself, after dropping a pointer — queue ≥ 10 · by handdream [--now]
routine sandman-reflect · 03:30 UTC (≈ 23:30 local)reflect
claude, in-session — the golden ruleremember "<body>"
/deleteforget $SESSION
shell — the ownerany verb by hand

Flow — a conversation's afterlife

Five stations left to right: take, archive, pointer, dream, reflect — a conversation's whole afterlife from the SessionEnd hook to the nightly bank upkeep. Five stations left to right: take, archive, pointer, dream, reflect — a conversation's whole afterlife from the SessionEnd hook to the nightly bank upkeep.
Take → dream. The five stations below, in the order a conversation passes them. Source: arch.tldr, frame flow.
① take <session>
  • callers: SessionEnd hook · /dissolve · by hand
  • refuse live — a transcript touched in the last 120 s is refused; --force overrides; --hook implies force, because SessionEnd itself is the proof the session is over
  • decline a resume — SessionEnd carries reason ∈ {clear, logout, other, prompt_input_exit, resume}, and resume is 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_TAKE makes --hook exit quietly before it reads anything, so a caller driving claude -p --resume turns keeps its transcript live; a session named by hand is taken regardless
  • resolve the transcript; mv it 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 = mv back
② archive
  • .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; mv back restores the session whole
  • append-only tree: the pipeline never deletes here. forget destroys every copy before take could see it, so an archived session was by definition wanted
③ pointer
  • shape: {archived · cwd · ended · title · highlights} — enough to recall by without opening the transcript
  • IS the short-term recall surface: recall folds pointers younger than 72 h into session start; expiry is that read-side filter
  • highlights carries 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
④ dream — route short-term
  • trigger: take at queue ≥ 10, detached · dream --now by 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 json calls, 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>/.dream under 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 (orphans when 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)
  • commitcommit_memory under .commit.lock, the single format authority: slugs, replaces archiving 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
⑤ reflect — the 24 h pass
  • trigger: the sandman-reflect routine 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.md lists every day page
  • pointer sweep: delete .recent pointers 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
Three lanes leaving one origin, ClaudeCode(SessionEnd) to sandman take --hook: lane A archives the session outright, lane B declines the ending, writes the debt and schedules the alarm that drains it, lane C is the backstops. Lane B's drain hands the reclaimed session back to lane A's pointer. Three lanes leaving one origin, ClaudeCode(SessionEnd) to sandman take --hook: lane A archives the session outright, lane B declines the ending, writes the debt and schedules the alarm that drains it, lane C is the backstops. Lane B's drain hands the reclaimed session back to lane A's pointer.
Station ① alone, zoomed: one 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

Non-goals

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.