Capture and injection (the hooks)
How Anamnesis wires into Claude Code's lifecycle: what gets injected at SessionStart, and how a session becomes one episodic note at SessionEnd and PreCompact.
Anamnesis has no daemon. It rides Claude Code's lifecycle hooks instead. Three things happen for you, automatically, around every session:
- At SessionStart a small set of notes is selected and printed as context, and a sync runs in the background.
- At SessionEnd the transcript is turned into one episodic note, then synced.
- At PreCompact (just before Claude Code compacts the conversation) the same capture runs, without syncing.
This page is the precise reference for that wiring: which hooks anamnesis init installs, with what
timeouts; exactly how the injection working set is chosen; and exactly how a transcript becomes a note.
Every field name, default, and threshold below is taken from server/src/anamnesis/inject.py,
server/src/anamnesis/capture.py, server/src/anamnesis/cli.py, server/src/anamnesis/onboard.py, and
examples/hooks.settings.json.
The hooks init installs
anamnesis init writes four matcher-groups into Claude Code's settings.json (resolved from
CLAUDE_CONFIG_DIR, else ~/.claude/settings.json). The shape is built by build_hooks in
server/src/anamnesis/onboard.py and mirrors the checked-in example at examples/hooks.settings.json.
| Event | Matcher | Command (subcommand) | timeout | async |
|---|---|---|---|---|
| SessionStart | startup|resume|clear | anamnesis inject | 15 | no |
| SessionStart | startup|resume | anamnesis sync | (none) | true |
| SessionEnd | (any) | anamnesis capture | 120 | no |
| PreCompact | (any) | anamnesis capture --source precompact --no-sync | 60 | no |
Timeouts are in seconds. They are the real values from build_hooks: inject gets 15, capture at
SessionEnd gets 120 (it may run an LLM summary plus a full git sync), and the PreCompact capture gets
60 (no sync, so it is faster). The background sync group carries async: true and no timeout, so the
session never blocks on the network.
Here is the literal block from examples/hooks.settings.json. init writes the same structure, except it
substitutes your resolved command form for the placeholder path and prepends an inline
ANAMNESIS_MACHINE_ID=... env prefix to each command:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis inject",
"timeout": 15
}
]
},
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis sync",
"async": true
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis capture",
"timeout": 120
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis capture --source precompact --no-sync",
"timeout": 60
}
]
}
]
}
}The inject matcher includes clear, so re-injection happens on /clear too. The background sync
matcher is only startup|resume: a /clear re-injects from the local index but does not kick off another
network sync.
How the command is resolved
The literal uv run --project ... form above is one of several. detect_command (in onboard.py)
resolves the base argv in this order:
- explicit
--command "..."override (split withshlex), --uv-project <dir>, which producesuv run --project <dir> anamnesis,- an installed
anamnesisfound onPATH(used directly, by resolved absolute path), - else the fallback
uv run --project <server-dir> anamnesis, where<server-dir>is the editable checkout'sserver/directory.
init is idempotent. merge_hooks strips any prior Anamnesis matcher-groups (identified by the command
markers ANAMNESIS_, anamnesis inject, anamnesis sync, anamnesis capture), keeps every other
top-level key and any non-Anamnesis hooks, then inserts the fresh set. write_settings backs up the
existing file to settings.json.bak before writing it atomically, so re-running init is safe. The MCP
server is registered separately via claude mcp add at user scope (see
MCP server).
Inline environment
Each installed hook command is prefixed with the ANAMNESIS_* values from build_env:
ANAMNESIS_MACHINE_ID is always set; ANAMNESIS_GIT_REMOTE is added when you configured a remote; and
ANAMNESIS_HOME is added only when the store home differs from the ~/.anamnesis default. The
machine-local config at <home>/config.json (written by write_store_config) carries the same machine id
and remote as a fallback, so a server or CLI launched without inline env still finds them. Because the
remote URL differs per machine, that config file lives outside the synced memory/ repo and never syncs.
A session at a glance
Injection: choosing the working set
At SessionStart, cmd_inject (in server/src/anamnesis/cli.py) reads the hook payload from stdin,
derives the project key from payload["cwd"] via resolve_project_key, then calls
select_inject(store, project=project, k=args.k) and prints render_inject(...) to stdout. The default
k is 8 (from the inject subparser). Claude Code injects that stdout as session context.
The whole path is pure reads over MemoryStore (no FastMCP, no network), which is why it fits comfortably
inside the 15-second timeout.
Project identity
resolve_project_key (in inject.py) derives a stable project key from the working directory, in this
order:
- the first non-empty line of the nearest
.anamnesis/projectmarker, searched from the cwd upward and stopping below$HOMEand the filesystem root (so a stray marker at$HOMEcannot hijack every project); - else the normalized
origingit remote (scheme,user@, and trailing.gitstripped, lowercased; anscp-formhost:pathis rewritten tohost/path); - else the git repo-root directory name, lowercased;
- else the cwd basename, lowercased, or
globalif empty.
The marker is the explicit, cross-machine-stable override, which matters for non-git workspaces where a subdirectory would otherwise resolve to its bare basename.
The selection algorithm
select_inject builds the working set from two project pools plus the global pool, against a fixed budget.
The relevant constants in inject.py are _DURABLE = ("procedural", "semantic") and _MAX_EPISODIC = 2.
In words, with the default k = 8:
- All global notes, always, in full. Every note whose
projectisglobalis included (minus any superseded), and the budgetkdoes not constrain them. Global notes are the "always-on" memory. - Then up to
kproject notes fill a budget. The durable pool (typesproceduralandsemanticfor this project) is sorted byupdated_atdescending, thenconfidencedescending, so recency wins and confidence breaks ties. - Reserve up to 2 most-recent episodics. Up to
_MAX_EPISODIC = 2recent episodic notes are reserved inside the budget as the "what I last did" continuity thread.reserve = min(len(episodic), budget), durable notes takebudget - reserveslots, and the finalproject_selisdurable_sel + episodic[:reserve]. - Exclude superseded notes. Any note id returned by
store.superseded_ids()(that is, any note named by another note'ssupersedesfield) is dropped from all three pools. Superseded notes are hidden from recall but remain browsable viaanamnesislisting and the dashboard. - Drop already-reflected episodics. Episodics carrying the
reflectedtag are excluded, because their content has already been distilled into durable notes (see Reflection). Including them would double-count.
The final list is the global pool followed by project_sel, de-duplicated by note id (so a note that is
both global and otherwise selected appears once).
The budget bounds the project notes, not the total. If you have many global notes they all inject. Keep global notes lean. The continuity reserve means at most two recent episodics ever push durable notes out of the budget.
The rendered block
render_inject turns the selected notes into one markdown block written to stdout. The exact header is:
# Anamnesis memory (auto-injected)Each note renders as a section:
## [<type>] <title>
_project: <project> | origin: <machine_id>_
<body>The metadata line appends a provenance clause only when the note is not plain human-authored
(prov_source != "human") or its confidence is below 1.0: | source: <prov_source> (confidence <value>),
where the value is formatted with %g (so 0.8, not 0.80). prov_source is one of human,
session-end, reflection, or import (the values the store schema enforces with a CHECK constraint).
If the selection is empty, render_inject returns an empty string and cmd_inject writes nothing.
Capture: a transcript becomes one episodic note
anamnesis capture runs at SessionEnd and (with --source precompact --no-sync) at PreCompact.
cmd_capture in cli.py reads the transcript path from --transcript or the hook payload's
transcript_path, parses it, resolves the project, writes at most one episodic note, and then syncs unless
--no-sync was passed.
Parsing the transcript
parse_transcript (in server/src/anamnesis/capture.py) reads the transcript JSONL line by line into a
ParsedSession with these fields:
first_prompt: the first non-metausermessage text (the ask). Lines withisMetaset are skipped.last_outcome: the last non-emptyassistantmessage text (the outcome).files_touched: thefile_pathinputs fromtool_useblocks whose tool name is in_EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}, de-duplicated, in first-seen order.git_branch: the firstgitBranchfield seen.cwd: the firstcwdfield seen.session_id: the firstsessionIdfield seen.raw: the full transcript text.
Message text is extracted by _text_of, which accepts a plain string content or a list of blocks and
joins only the text blocks. Parsing is deliberately tolerant: an unreadable file or a malformed JSON line
degrades to an empty or partial ParsedSession rather than raising, so capture never breaks session
teardown.
Skipping trivial sessions (the free gate)
Before any summarizer runs, is_trivial_session decides whether the session is even worth a note. It is
provider-agnostic and free (no model call). The thresholds in capture.py are
_TRIVIAL_OUTCOME_FLOOR = 40 characters and _MAX_LEN = 600 (the clip limit applied later by the
summarizer).
So a session is kept whenever it touched a file, and otherwise kept whenever there is a real user prompt
with enough outcome text. It is skipped when both prompt and outcome are empty, when there is no prompt and
a tiny outcome (shorter than 40 characters), or when the prompt is just a lone slash command (matched by
^/\S+$, for example /clear or /effort) with a tiny outcome. When skipped, cmd_capture prints
capture: skipped trivial session (...) and writes nothing.
Summarizing
If the session is not trivial, write_episodic calls the resolved Summarizer. The default is the
deterministic HeuristicSummarizer, selected by resolve_summarizer from the
ANAMNESIS_REFLECTION_PROVIDER environment variable (default heuristic). The heuristic produces:
- title: the first line of the first prompt, truncated to 80 characters, or
"Session summary"if there is no prompt. - body: an
**Ask:**block (the first prompt, clipped to 600 chars), a**Branch:**line if a branch was seen, a**Files touched (N):**list if any, and an**Outcome:**block (the last assistant text, clipped to 600 chars). Empty fields fall back to(no user prompt captured)and(no assistant output captured).
The heuristic always returns a result. A summarizer may also return None to self-skip a session it judges
not worth keeping; that path is reserved for the swappable LLM summarizer.
The summarizer is swappable, not hardcoded. resolve_summarizer maps the ANAMNESIS_REFLECTION_PROVIDER
values deepseek, openai, and local to an LLM-backed summarizer (loaded with a lazy import so the hook
hot path never imports the LLM code unless a provider is configured), and any other value falls back to the
heuristic. v0 ships only the deterministic summarizer; the LLM path is the seam, not a default.
Writing and provenance
When there is a result, write_episodic persists one note via store.write(...) with:
type="episodic",titleandbodyfrom the summary,projectresolved from the sessioncwd(falling back to the payloadcwd, then.),machine_idfromresolve_machine_id(),tags=["session", source], wheresourceissession-endorprecompact(so you can tell which hook wrote it),prov_source="session-end"(both hooks map to this single provenance value),prov_sessionset to the transcript'ssessionId,prov_modelfrom the summary (empty for the heuristic, the model label for an LLM summary).
On success cmd_capture prints capture: wrote episodic note <id> (...). See Data model
for the full note schema and front-matter layout.
Then sync (or not)
At SessionEnd, capture runs without --no-sync, so after writing it calls _run_sync: mirror Claude
Code's native memory into the store (unless ANAMNESIS_IMPORT_NATIVE=0), then git commit,
pull --rebase, push, then reindex. At PreCompact the hook passes --no-sync, so the note is written
and indexed but the network sync is deferred to the next SessionEnd or the SessionStart background sync.
This keeps compaction fast and avoids hammering the remote mid-session.
A PreCompact note is committed locally but not pushed until a later sync. If a concurrent sync on another
machine rebases first, that is normal; sync resolves it on the next cycle. The architectural rule still
holds: never sync the raw SQLite file. Only markdown moves over git, and the index is rebuilt locally by
reindex. See Sync over git for the full cycle.
Running and inspecting the hooks by hand
Every hook is a plain subcommand you can run yourself. From a checkout:
# Print what would be injected for the current directory's project (k defaults to 8)
uv run --project server anamnesis inject
# Inject for an explicit project key, with a different budget
uv run --project server anamnesis inject --project my-project --k 12
# Capture from a transcript file without syncing (safe to experiment)
uv run --project server anamnesis capture --transcript /path/to/transcript.jsonl --no-sync
# See what init would install, without writing anything
uv run --project server anamnesis init --printinject reads its cwd from the hook payload on stdin when run as a hook. Run by hand without a payload it
falls back to ., so pass --project if you want a specific project's working set.
Related
Keyword recall: FTS5 and BM25
How search() works: an FTS5 MATCH against memories_fts, BM25 ranking with a recency tie-break, OR-joined query tokens, and excluded superseded notes.
Cross-machine sync over git and Tailscale
How the git sync backend works: the commit, fetch, rebase, push cycle, the conflict policy, what is and is not tracked, the Tailscale bare-repo topology, and why the SQLite index is never synced.