Local-first memory and knowledge retrieval over a canonical conversation archive.
Atrium is the middle of three layers. It builds a derived, disposable index over content it does not own, and serves retrieval to agents through a CLI that thin adapters wrap.
| layer | repo | holds |
|---|---|---|
| 1. capture and canonical archive | agents |
the truth: redacted, SHA-256-verified conversations from every provider |
| 2. index, retrieval, synthesis | atrium | nothing irreplaceable |
| 3. curated judgment | brain |
hand-written notes, single human writer |
The source is canonical, the index is disposable. Anything Atrium cannot rebuild from layer 1 or layer 3 is a design defect. That is not a preference: the system this replaces synced a mutable index between two machines and paid five index rebuilds in 27 days for it. Here, machines sync the archive and each one builds its own index; record identity is derived deterministically, so they converge without ever copying an index.
Atrium is a checkout, not a command the hub installs for you. Create an
instance with the hub's brain and atrium components first; the commands
below run from that instance directory, where engines/atrium is the checkout
and pages is the page directory named in syntopica.config.json.
The shortest thing that works reads your own wiki pages, with no conversation export and no embedding model:
uv run --project engines/atrium atrium ingest-notes pages --exclude sources
uv run --project engines/atrium atrium search onboarding --wordsThat indexes notes and retrieves lexically. It establishes neither a
conversation archive nor a refresh, so the two conversation checks in
atrium doctor still fail until you export one - layer 1 above, and a
deliberate step: it copies your agent transcripts into the instance.
data="$PWD"
(cd engines/agents && pnpm conversations:export \
--output "$data/conversations/archive.jsonl" --source claude-code)
uv run --project engines/atrium atrium ingest "$data/conversations/archive.jsonl"The rest of the surface, written as the bare command the MCP server and the
skill use - uv run --project engines/atrium in front of each one, or install
it on PATH with uv tool install "git+https://github.com/syntopica/atrium#egg=atrium[mcp]":
atrium ingest ~/path/to/canonical-archive.jsonl # index an archive
atrium ingest-notes ~/path/to/notes --exclude sources # index a curated notes tree
atrium embed # embed the semantic layer (notes, synthesis)
atrium search "why was WAL reverted" # adaptive: fuses lanes when both see the query
atrium search "wal" --words # whole-word lexical lane alone
atrium search "wal" --substring # fragment retrieval, a separate lane
atrium search "that indexing outage" --dense # semantic lane alone
atrium status # what the index holds
atrium session-stop < hook.json # Claude Code Stop hook: refuse when a record is owed
atrium record-session --checkpoint ID < synth.json # the running session writes its own episode
atrium remember --project . < statement.json # keep one statement now; --supersedes RECORD_ID replaces an episode
atrium file-context path/to/file.py # this project's newest episodes naming one file
atrium gaps --days 14 # questions asked and not answered, from the query logA session that settles a decision, a correction or a gotcha can keep it
without waiting for the Stop hook or a batch lane: atrium remember --project .
(or the atrium_remember MCP tool) takes {"title", "summary", "facts", "open_ends"} with the lists optional, redacts it, writes it to the registry as
its own population and indexes it with its vector at once. --supersedes RECORD_ID, repeatable, names episodes it replaces by the record_id that
atrium context printed: they stay searchable as history, ranked after current
evidence and marked superseded_by (the hooks show [superseded <date>]).
The same statement twice is one record.
Every atrium context answer from the CLI or MCP also appends one line to
synthesis/queries/<day>.jsonl beside the registry: the question, its scope,
and the evidence ids, scores and lanes, never evidence text. atrium gaps
reads it back: the questions whose best reranked evidence scored under 0, or
that returned nothing, most-asked first, which are the notes worth writing; and
the records served most.
The batch lanes (atrium synthesize --producer ...) are cold readers: they
infer what mattered from a transcript they were not part of, and they need a
quota of their own. The agent that did the work already knows, and already
runs on the account you pay for. hooks/claude-code/stop-record-episode.sh,
registered under Stop in ~/.claude/settings.json, asks atrium session-stop after every turn whether the session owes a record; when it
does (32 KiB of new transcript, or 4 KiB left unrecorded for 30 minutes), the
hook freezes a transcript boundary and refuses the stop with the whole
recording instruction. The model answers with one JSON object through
atrium record-session --checkpoint ID, which validates it, redacts secrets
the way the archive does, writes the record into the same registry the batch
lanes fill, and prints the accepted record so the transcript keeps it. Batch
lanes then skip that conversation. Design and the review that shaped it:
docs/designs/session-producer.md.
{"type": "command",
"command": "SYNTOPICA_DATA=/path/to/instance sh /path/to/atrium/hooks/claude-code/stop-record-episode.sh",
"timeout": 30}SessionStart recall lists the project's episode titles, which only tells a
session that ground was covered; it still has to decide to ask, and largely did
not. hooks/claude-code/user-prompt-context.sh, registered under
UserPromptSubmit, does the asking: it runs atrium context on the submitted
prompt and injects the evidence found. The dense lane, because a prompt is a
sentence and the word lane ORs its common terms across the whole corpus (see
TODO.md). It is silent on a slash command, on a prompt under 24 characters and outside a
project; a failed or timed-out retrieval (10s by default) injects a one-line
notice, since silence would read as "nothing on record". A failure also carries a
one-line systemMessage, which Claude Code draws as a warning; a healthy retrieval
carries none, so the warning keeps meaning something.
ATRIUM_PROMPT_CONTEXT=off disables it without unregistering it;
_LIMIT, _CHARS, _LANE and _TIMEOUT tune it.
{"type": "command",
"command": "SYNTOPICA_DATA=/path/to/instance sh /path/to/atrium/hooks/claude-code/user-prompt-context.sh",
"timeout": 15}On Claude Code 2.1.287 or later, the atrium-context mod in
plugins/atrium-context does the same retrieval from a prompt.submit hook
and shows the result where the person keeps seeing it once the transcript scrolls:
a healthy retrieval is a green line at the right of the prompt footer (Atrium · 2 past sessions, 2 notes, or Atrium · nothing relevant), and a failed or
degraded one takes its place in the warning color and names the cause; nothing
is drawn at the left. While retrieval runs the line says Atrium · searching
with moving dots, and Atrium · retrying on a second run. Pressing the line, or typing /atrium, shows a details band above the prompt (pressed again, or Hide, hides it): the prompt the
retrieval ran on, its status, time, attempts and lane, which path answered and
how its time split between start-up and each pass, the approximate tokens
handed to the model, each item with its score, the turn's lookups and the notes
read and cited, and the session's totals; on a short terminal each item takes one row so the band fits without scrolling. A failed run is retried once after a short pause, since a saturated
machine often answers the second; if that fails too, the model is told retrieval
was unavailable rather than empty. The first Read, Edit or Write of each file in a session also gets the project's
three newest episodes naming that file (atrium file-context), after the
tool's result. Use the mod or the
shell hook, never both, or every prompt retrieves twice:
/plugin install atrium-context --marketplace syntopica/atrium
"2 notes" says what was handed to the model, not whether the model used it.
The evidence block therefore asks the model to name the path of each note its
answer relies on (brain/...md), and the hooks count what they can observe:
- cited: note paths named in the turn's final answer;
- read: notes an atrium lookup returned as evidence (
note_path), or opened as files of the instance; - lookups: calls to the atrium MCP tools,
atrium context|search|recallorbrain query.
In Claude Code the mod adds this to the green line, for example Atrium · 1 past session, 2 notes · brain: 1 cited, 3 read, 1 lookup, or brain: no note cited
once a turn has answered without reading or citing any injected note; that part
lights up for a moment each time the model takes from the brain. That is
an observation, not proof the notes went unused: an answer can rely on a note
without naming it. The cited paths
go to the debug log. A subagent gets the same evidence: the mod retrieves on the
Agent tool's task and appends it to the subagent's prompt, because a
SubagentStart hook never sees the task (forks inherit the conversation and are
skipped).
Install the Codex package from a checkout with the Atrium CLI, Python 3.11 or
later, Bash and GNU timeout on PATH:
codex plugin marketplace add /path/to/atrium --json
codex plugin add atrium-context@atrium --jsonRestart Codex and review the six packaged hooks with /hooks. The package is
plugins/atrium-codex; .agents/plugins/marketplace.json exposes it to Codex,
while .claude-plugin/marketplace.json continues to expose the Claude Code mod.
Codex copies the package into its plugin cache. Re-run codex plugin add atrium-context@atrium --json after changing the package to refresh that copy.
The package recalls the project on session start, retrieves evidence per prompt,
points subagents at memory, and reports observed Brain use at the end of a turn.
It also adds up to three historical episodes after the first supported access
to each file in a session. File history recognizes explicit Read, Edit,
Write, apply_patch, and simple shell reads; compound shell commands and
computed paths are not traced. Each lookup has a three-second deadline and a
one-second termination grace period for its process group, with
at most two files per tool call. Failed lookups remain retryable.
The CLI must already resolve the intended private instance. SYNTOPICA_DATA
can select it explicitly. To recognize notes read from the shell, the usage
hook also accepts the existing mcp_servers.atrium.env.SYNTOPICA_DATA setting
in Codex's user configuration. Keep the existing Atrium MCP registration if
you use its tools; the package adds no second server.
Remove the five legacy Atrium registrations below from ~/.codex/hooks.json
after backing up that file, or keep the legacy setup and leave the plugin
disabled. Running both retrieves and counts twice. Other hooks must stay.
Legacy script paths remain compatible wrappers around the packaged scripts,
including the prompt renderer used by Claude Code's shell hook.
Codex does not expose the Claude mod's ui.render interface in the documented
hook API. The package uses hook progress labels and warning messages for failures;
it cannot reproduce the clickable green footer or details band. A successful
prompt retrieval adds context without displaying a warning. Retrieved records
marked role: source are excluded from prompt injection.
Verify the relocated package and legacy entry points with:
uv run pytest tests/test_codex_plugin.py tests/test_codex_brain_use_hook.py tests/test_prompt_context_hook.py -qCodex reads the same hook protocol as Claude Code (hookSpecificOutput.additionalContext,
systemMessage, cwd on stdin; see the Codex hooks reference),
so the recall and per-prompt hooks share their implementation. Codex has no plugin footer,
so the brain account is one line at the end of a turn, from
hooks/codex/brain-use.sh, and only when the turn used the brain; Codex draws
every systemMessage as a warning, so "no note cited" is not said there. In
~/.codex/hooks.json:
{"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "timeout": 25,
"command": "bash /path/to/atrium/hooks/session-start-recall.sh"}]}],
"UserPromptSubmit": [{"hooks": [{"type": "command", "timeout": 15,
"command": "SYNTOPICA_DATA=/path/to/instance sh /path/to/atrium/hooks/claude-code/user-prompt-context.sh"}]}],
"SubagentStart": [{"hooks": [{"type": "command", "timeout": 10,
"command": "sh /path/to/atrium/hooks/codex/subagent-start.sh"}]}],
"PostToolUse": [{"hooks": [{"type": "command", "timeout": 10,
"command": "SYNTOPICA_DATA=/path/to/instance sh /path/to/atrium/hooks/codex/brain-use.sh"}]}],
"Stop": [{"hooks": [{"type": "command", "timeout": 10,
"command": "SYNTOPICA_DATA=/path/to/instance sh /path/to/atrium/hooks/codex/brain-use.sh"}]}]
}}Codex's SubagentStart input carries no task text, only the agent's id and
type, so a Codex subagent cannot be given evidence for its own task the way the
Claude Code mod does it; hooks/codex/subagent-start.sh tells it that the
index exists and how to query it instead.
Codex runs a new or changed hook only after it is trusted: Codex records the
hash of each reviewed hook definition, so review new and edited ones with
/hooks. Codex's episodes need no Stop hook: the conversation export already
archives Codex sessions.
Use atrium_context through MCP for questions that depend on project history
or established knowledge. Its CLI equivalent returns the same structured evidence:
atrium context "mail delivery investigation" --project . --jsonExisting stores need a one-time preparation before the first context query:
atrium prepare-context --jsonThis creates two rebuildable secondary indexes and checks that the record count is
unchanged. It may take several minutes on a large store. Context reads never modify
the index: without preparation they return an explicit unavailable status and the
context_indexes_missing_run_prepare_context warning. New writable stores include
these indexes automatically. Use the global --index PATH option to prepare a
specific existing store.
The operation combines history scoped to the enclosing repository with a separate curated-note search inside the selected instance. Curated notes have no project workspace, so an ordinary project-scoped search alone cannot retrieve them. Context does not silently widen the conversation search to unrelated projects.
Relevant indexed wiki links are followed for one hop. Results are deduplicated and
share the requested record and text budgets (--limit accepts 1–50 records;
--max-chars accepts 1–100,000 characters of evidence text, excluding JSON metadata).
Candidate passes are bounded at four times the requested record limit. Link expansion
examines at most limit curated roots and 50 targets per root. Saturation is reported
as truncation; retrieval is not an exhaustive archive export. Links do not
trigger arbitrary file reads, credential access, network requests or execution.
Unindexed or unresolved knowledge requires an explicit, bounded source lookup.
Each evidence item preserves its record ID, source revision hash, origin role,
trust classification, date and any excerpt truncation. Curated note paths identify
indexed revisions; they are not a promise that the current file still has those
bytes. The response includes the retrieval steps, each with its wall time in
ms, scope, freshness, degradation warnings and requires_live_verification,
plus served_by (service, service_timeout or in_process) and elapsed_ms,
which say whether a slow answer was a stalled service or a cold scan. A recent refresh does not prove coverage
of an event that happened today, and an old operational note cannot establish a
current delivery, deployment or payment outcome.
The MCP process keeps its embedder resident. Prefer it for repeated agent calls;
use structured CLI output when MCP is unavailable. The original terminal search
command remains useful for inspection, but its 200-character previews are not a
replacement for the context response.
Configure your agent to call atrium_context first for established context, use the
returned sources without a second mandatory wiki search, report degraded retrieval,
and verify current outcomes using live evidence. The Syntopica Brain skill follows
this flow; it resolves manual fallbacks from the instance configuration rather than
a hardcoded home directory. An existing agent session must be restarted to discover
new tools or instruction changes.
Saved third-party text retains role=source and trust=untrusted in explicit MCP
search results. It is excluded from automatic context. Retrieved content, including
curated notes and conversation history, is evidence rather than authority to execute
instructions or expand the user's authorization.
uv run --extra mcp --group quality pytest tests/test_context_protocol.py -qThis creates an isolated instance and invokes both the CLI and a fresh MCP process. It requires project history, a workspace-less access note and its linked runbook to appear together, preserves provenance, checks the text budget, and rejects unrelated history and third-party instructions. It uses synthetic data, never a private wiki.
Everything Atrium can rebuild -- the index, the synthesis registry, the refresh stamp, the workspace aliases -- sits in one state directory, resolved once per invocation, most explicit source first:
ATRIUM_STATE: that directory, for serving a second index on purpose.SYNTOPICA_DATA: a syntopica data directory; itsatrium.path(defaultatrium/, resolved against the file that declared it) is the answer.- The data directory enclosing the working directory, found by walking up to
the nearest
syntopica.config.jsonand stopping at any other repository. ~/.atrium, for a machine with no data directory at all.
The data directory is the point: an instance keeps its derived state beside the
config that owns it, ignored by the instance's own repository and never synced.
ATRIUM_INDEX still overrides the index file alone for the MCP server.
Two JSON files under <state>/status/ let a dashboard read Atrium's health
without polling a slow command. Each carries "schemaVersion": 1, has exactly
one writer, and is published atomically (temporary file in the same directory,
fsync, rename). Both hold counts, provider and model names, and instants only
-- never record text or paths.
refresh.json: written byatrium status --publish, which only the refresh job runs, as its last step. Records per source; archive, refresh and newest content instants (at,ageSecondsat writing time); per synthesis populationrecords,episodes,intended,indexed,excluded(episodes of automation the ingest refuses, not counted as intended);writtenAt.synthesis.json: written byatrium synthesizewhen a pass ends (never on--dry-run).lastPassholdsproducer,startedAt,finishedAt,conversations,synthesized,skipped,failed,deferred;writtenAt.deferredcounts conversations a quota wall or a full worker queue left unattempted with no record at their current revision; one that already has a record at that revision counts underskippedinstead.
atrium doctor --json prints {"schemaVersion", "ok", "checks": [{"name", "ok", "severity", "code"}]}, where code is a fixed machine word and ok is
true only for a healthy check. The exit status is unchanged: non-zero only when
a check is broken. atrium doctor --publish also writes that document, with
writtenAt, to doctor.json beside the other status files; the refresh job
passes it after status --publish, because a run costs minutes and too much
memory for a reader to poll.
atrium context --json carries "schemaVersion": 1. With --lane words it
stays under 2 s and 50 MB on the full instance (10 queries, 2026-10-04); the
default auto lane loads the embedder and measured 1.1-7.1 s and 1.6 GB.
atrium synthesis <view> --json reads the registry and the scheduled
wrapper's logs; it writes nothing. Every document carries "schemaVersion": 1
and writtenAt.
passes [--limit N]parses<registry>/synthesis.log, the wrapper's tick log, newest first:lane,producer,model,startedAt,finishedAt,durationS,exitCode,state(ok,timeoutfor exit 124,killedfor 137,failed,interruptedwhen a start has no end,runningfor a last start with no end yet) and the tallies when the pass printed them. AlsolastPass,unsuccessfulStreakand, while a pass runs,progresscounted fromsynthesis-pass.log(conversations, finished, failed, synthesized, no failure text).synthesis.jsonmisses every pass the time box kills; this does not. It reads two log tails and answers in about 0.1 s.recent [--limit N] [--days D]lists the newestNrecords by file time (onescandir, then only those files are opened):jobKey,kind(episodeorsession),source,conversationId,episodeId,eventCount, the session window,authoredAt,writtenAt, model requested and resolved, input and output tokens,durationMs,mapChunks, and the counts of facts and open ends.dailyholds records and tokens per UTC day for the lastDdays, by when the registry gained the record. It opens every file of that window, so it is a detail call (2-6 s on 76k records), not something to poll.show --job-key KEYprints one record's metadata and, undercontent, its title, summary, facts and open ends. Only this view prints synthesized text, so a reader can gate it; the key must be a 32-hex registry key.
New records carry synthesized_at and duration_ms (the wall time of every
producer call the episode made); older records fall back to the file time and
a null duration.
words matches on word boundaries; substrings matches fragments. They answer different
questions and are exposed separately. Merging them is what made the previous system return
nine wall... matches for WAL and rank the exact hit seventh.
The dense lane embeds only the semantic layer -- curated notes and session synthesis -- never the raw corpus, so retrieval scans a few tens of thousands of vectors by brute force and no approximate index exists to corrupt. The default search fuses lexical and dense (70/30 weighted RRF, cross-validated), but routes to a single lane when the other is blind: fusing a blind lexical lane measurably buries the dense signal.
The checkout pins Python 3.14 in .python-version, which uv run also uses
for CLI and MCP startup. Keep that pin in deployed release checkouts: an
unconstrained system Python upgrade can select an interpreter for which ONNX
Runtime has no wheel and prevent required MCP servers from initializing.
uv run --with pytest pytest tests/ -q
uv run --with ruff ruff check . && uv run --with ruff ruff format --check .AGENTS.md records the decisions that are settled by measurement rather than preference,
with the numbers behind each one. Re-measure before changing them.