What past sessions learned. An observer — a plugin agent, agents/observer.md — runs when a
session ends and writes what it saw as observation rows in the vault's store; the next session
opens on "since you were last here"; and a session asks the store before it re-derives.
The shell is the door — observer search → timeline → get, the plugin's own command (bin/observer, on
the Bash tool's PATH while the plugin is on; inside the kit also ak observer …), and every plain answer ends on a
hint: naming the next command. There is no MCP server: a shell-only arm matched the MCP arms on
every case (the kit's tool-routing eval).
you what comes back door
───────────────────────────────────────────────────────────────────────────────────────────────────────
open a terminal "since you were last here: 3 new" — shown to YOU; the model gets only the doors line SessionStart hook
"did we try this before?" dated rows, memdb:<id> to cite observer search → timeline → get
"which sessions was X in?" one entry per session: uuid · started · asked observer search --by-session
read a file a past session worked on "this file has prior observations: …" PreToolUse Read hook
end the session the observer writes rows (detached, never blocks) SessionEnd hook
"these three rows say the same thing" one summary row stands for them; --undo restores observer fold
"what keeps coming up?" anchors seen ≥3× across ≥2 sessions observer patterns
"this row is noise" preview, then hide for a reason; never deleted observer forget … --confirm
"is it recording here?" the writer, the agent, the store observer status · mute --here · show · try · types · store
The observer started from claude-mem. It keeps the same store layout and the same three-step recall (search → timeline → get). It drops two things:
claude-mem observer
──────────────────────────────────────────────────────────────────────────────────────────────
how an agent asks 4 MCP tools, their schemas sent with a shell command whose output names
every request the next one (hint:)
what keeps running a Bun worker, an HTTP server, a nothing: a hook runs when the session
Chroma vector database ends, writes its rows, exits
- No MCP server. When agents had only the shell, they picked the right command first just as often
as they picked the right MCP tool (an eval of tool routing, runs 5–6). A command costs one
line at session start, not a tool list sent with every request. It also works the same for you,
a subagent,
claude -por another harness. - Nothing left running. There is no daemon to start, watch or restart. Both tools keep a vector index on disk (here about 1 GB, claude-mem's Chroma about 1.7 GB on the same machine). The saving is in the processes, not the disk.
ak setup pick observer in the list, or:
/plugin marketplace add proxify-dev/agentic-kit
/plugin install observer@agentic-kit
Needs uv, python ≥ 3.10, and the claude CLI (the observer runs claude -p at
SessionEnd). The verbs run as observer … — bin/observer runs the console script through
uv run --project <plugin root>, which resolves rich-click and the dense channel's packages into the
plugin's own .venv on first use — and as ak observer … inside ak, where observer search and
store embed hop into that venv for the dense channel.
The dense channel of search is on by default: zvec 0.7 and fastembed (bge-small, 384-d)
are dependencies, observer store embed writes the collection once for an existing
store (~14 min for 20k rows), and every SessionEnd embeds the rows it just wrote. The model
lives in ~/.cache/fastembed ($FASTEMBED_CACHE_PATH wins). Without a collection search
answers as union — nothing fails.
The store is ONE file, ~/.ak/db/brain.db ($AK_HOME moves the kit home,
$AK_STORE its db/ alone), whichever scope a session runs in: a repo's own .ak/
holds that project's notes, and its sessions are rows here, labelled by project, so one
search spans every project (vault_root.db_dir(), vendored from the vault plugin). The
mute dial (db/capture.json), the ledger (db/objects-ledger.jsonl) and the capture scratch
and digest cursor (cache/sessions/, cache/digest-cursor.json) live in the kit home too, never in a vault. The vault plugin is not required: this
plugin creates the store, and plants nothing else.
agents/observer.md is a standard Claude Code agent file: name, description, model in the
frontmatter, the prompt as the body — what counts, how to decide a verdict, how corrections are
filed. At SessionEnd scripts/summarize-session.py runs it headless:
claude -p <the frame + the session> --bare --strict-mcp-config
--agents '{"observer": {…the file…}}' --agent observer --tools "" --output-format json
--bare keeps the person's CLAUDE.md, hooks and plugins out of the run, and it also skips plugin
agents — so the file is handed over inline. On Windows it goes as a temp file's path instead
(--agents with -p takes one), and claude is started without a shell: claude.exe first; a
claude.cmd is the kit's shim (asked for the real binary with CC_SHIM_PRINT_REAL=1) or npm's
(seen through to its target), because cmd.exe cuts an argument at its first newline. With only an
sh stub on PATH the run logs summarize: skipped — … to the session's log and writes nothing.
The message it is sent is the FRAME (the JSON shape,
the type table, the ceiling, redaction), TODAY — the local day of the session's last message (a
session resumed over days is told its last day; a live run with no transcript, the day it ended) so
until: tomorrow resolves to a date — and the session. The types are one table,
hooks/memory_schema.py; the correction flag (hooks/prompt-flag.py) is one regex. Until
2026-09-25 all of this was records under the vault's loadout/, overridable per scope and
switchable per type; that folder was retired.
Before a row is stored, two things the model wrote are checked against the session itself.
files_modified keeps only files a Write or Edit event really touched, under that event's path
(21% of stored paths named none; claude-mem gated the same field in 13.12.2). And redact() masks
secrets in every row, prompt, summary and note: vendor key shapes, Authorization: Bearer,
and <name>_token|_secret|_api_key|password values (tests/test_redact.py).
Its rows are raw and unverified, so they are never injected into a model's context: an agent
searches them (observer search), and ak memory harvest (the vault plugin) turns
what keeps recurring into proposed project notes.
hooks/ SessionStart → session-start.py one ~40-token line naming the doors (the digest goes to the person's screen; a resume shows none)
UserPromptSubmit → session-register.py · prompt-flag.py (a correction is flagged for the observer)
PreToolUse Read → file-context.py prior observations about the file (not in a subagent)
PostToolUse → capture-event.py the session's scratch
SessionEnd → session-end.py dispatches scripts/summarize-session.py, detached
agents/observer.md the observer: a standard plugin agent, run headless at SessionEnd (never delegated to)
_archive/skills/recall the old /observer:recall skill, archived 2026-09-25 — the shell verbs replace it
src/observer/ obs_index (the search backend) · curate (patterns/fold/forget)
capture_flag (the writer's dial) · cli · observer/store/memory (verbs)
bin/observer, .cmd the plugin's command: runs its console script in the plugin's uv env (a copy of the
kit's one launcher, scripts/seam/launcher)
scripts/cli.py the mount shim — `ak observer …` when the `ak` CLI is installed; its `brain_search`
gives `ak search` the rows `observer search` lists (union, no embedder); inside
`ak`, mute · unmute · prompts · fold · forget · store init · sync · backfill · embed carry ✎ (they write)
observer mute [--here] the writer, vault-wide or for one folder — ~/.ak/db/capture.json
observer prompts auto copy a session's prompts into the store only while the tracer is off
(on · off pin it; same file, key "prompts")
observer status exit 0 recording here, 1 not, and why; whether prompts are copied
Prompts are copied only without the tracer. The store's user_prompts table came with
claude-mem's schema; the observer never reads it back — its agent reads the transcript, and the
session row keeps the first prompt (asked) and the count. The tracer reads the same prompts from
Claude Code's transcripts (tracer sessions search --role user), so while the tracer plugin is
enabled (Claude Code's settings, any marketplace) the summarizer skips the copy
(capture_common.prompts_state). Rows already stored stay. Two other readers of the copy see no
new rows while it is skipped: the brain app (a session's prompt count, its prompt search) and
memory's E4 alias rarity (experimental, off by default).
Only people's sessions are recorded. Claude Code hands every hook CLAUDE_CODE_ENTRYPOINT:
cli (a terminal), claude-desktop and sdk-ts (a desktop chat app) are people and are
recorded; sdk-cli (claude -p) and sdk-py (the Python SDK) are programs — the observer's own
summarizer, the evals, probes — and are not, nor do they get the SessionStart digest (they keep
the one doors line). Over 6,685 transcripts (2026-09-22) this kept all 617 human sessions and
skipped 6,050 of 6,068 automated ones; a session with no entrypoint is recorded as before.
AK_CAPTURE=1 count an automated session as a person's: recorded, digest shown
AK_CAPTURE_DISABLE=1 never record (wins over everything; the summarizer sets it on itself)
An app that drives Claude through the TypeScript SDK starts as sdk-ts too, like a person's chat in
it, so only the app can tell its own automation apart: it sets AK_CAPTURE_DISABLE=1 on those runs.
Recall, and what stays hidden
search and timeline skip rows that are forgotten, folded into a summary,
superseded by a newer row (supersedes: obs:<id>) or expired (until: in the past).
get by id never hides — that is the point of asking by id — and says what the
row is. fold and forget write one line each to ~/.ak/db/objects-ledger.jsonl, the
same file another reader of this store can show as a ledger.
observer patterns [--project P] anchors seen in ≥3 rows across ≥2 sessions
observer fold <id> <id>… --title T --fact F… one summary row stands for them; fold --undo <summary> restores
observer forget <id>… --reason R a preview; --confirm hides them, --undo --confirm restores
observer forget --query Q --reason R a preview of what a search finds; it never writes
Nothing is forgotten without --confirm, and each preview's hint: is the command that writes
it. A refusal — fewer than two ids to fold, no --fact, a row already folded, rows of two
projects, a forget with no reason, an id not on record — is an error: on stderr and exit 64;
no store, exit 66. curate.py holds the three, a Python twin of the app's store-write.ts.
Every row search and timeline return carries session: the Claude Code session uuid
(sdk_sessions.content_session_id) — the id tracer takes, so a row leads straight to
ak sessions show <uuid> or /tracer:trace <uuid>. observer get adds the session,
the prompt number and what the session was asked. A row whose session the store never
recorded (the claude-mem imports, about 10k rows) says '' — unknown, never guessed. The
transcript behind an old uuid may be gone: Claude Code prunes them.
observer search --by-session answers "which sessions was X
in": the top 200 ranked rows vote for their session by reciprocal rank (k=60), so a session
with many hits and a session with the top hit both surface; each entry is
{session, project, started, asked, did, hits, top: [{id, title}]} — asked is how the session began, did what its latest summary says it completed — and unlinked counts the
rows with no session. limit/offset then count sessions.
The observer files what a session found; it never follows a thing from "broken" to "fixed". This is a trial, run beside the observer, of detecting open issues and when they are solved, silently, so you can check on it periodically. It changes nothing the observer does:
observer shadow on <kit>/db/shadow/issues/ON: from now on every session end reads the session twice
the second read summarize-session.py --shadow: the same agent and message, plus the project's
OPEN ISSUES and an `issues` section: opened (fixed_here or not), resolved, progress
what it writes <kit>/db/shadow/issues/events.jsonl and runs/<session>.json, nothing else:
no store row, no scratch cleared, no embedding
observer shadow run ID… the same on past sessions, oldest first (to seed it)
observer shadow report still open · closed by a later session · against the issue notes in projects
with memory on (.agents/memory/issues/: who opened it, who closed it)
observer shadow off stops it; what it wrote stays
An id the model names that was not in the OPEN ISSUES it was shown is dropped and counted. It costs one more
observer run per session. The data: hooks/shadow_issues.py; the verbs: src/observer/shadow.py; tests:
tests/test_shadow_issues.py (the real prompt is byte for byte unchanged without --shadow).
FTS5's MATCH is an implicit AND, so one word a row lacks used to return nothing. The
lexical rule, union, puts rows matching every term first (bm25 order) and, when fewer
than five do, rows matching any term after them. The default, hybrid, is union with the
dense channel fused into a query the every-term tier starved. --mode on
observer search and AK_OBS_SEARCH_MODE pick another:
hybrid the default — when the every-term tier alone fills the floor that answer
stands; only a starved query is fused with vector by rank (RRF, k=60)
union every-term rows first, any-term rows when fewer than 5 match — no vectors
and every term only: the pre-2026-09 behaviour
vector the dense channel alone
observer search <query> [--mode …] the rows, from the shell; --json for a list
observer search <query> --by-session one row per session the topic came up in
observer timeline <id> [--before N] layer 2: the rows around one, the anchor marked ▶
observer get <id> [<id>…] layer 3: rows whole (37078, memdb:37078, a comma list)
observer store embed [--rebuild] build the dense channel (needs the extra)
The dense channel is one 384-d vector per observation (title · subtitle · facts) in a zvec
collection at <store>/zvec/observations, written by observer store embed and
computed locally by fastembed; without a collection vector and hybrid answer as
union rather than fail. The gate is why hybrid is gated rather than a plain fusion: an
exact-identifier query that the lexical channel already answered loses its row when older
semantic neighbours are allowed to vote — and because the gate runs first, such a query
never loads the model. zvec takes one LOCK per collection, read-only opens included, so a
search opens the collection per query (~5 ms) and closes it; SessionEnd's writer holds it
only to upsert, one embed runs at a time (<store>/zvec/.embed.lock), and a search that
cannot get in within a second answers that query as union. Every
filter and the hide rule stay in SQL; the dense channel only proposes ids.
A --project scope reads both spellings of this repo's pair, ak and .ak, through
capture_common.labels_with_aliases — the one table the hooks and the digest also read.
uv run pytest -q
The summarizer and the capture gate read a transcript's chat rows only, by row type (hooks/transcript_rows.py):
since Claude Code 2.1.300 the transcript also saves every model call, and a saved message body carries
message.role too. AK_TEST_REAL_TRANSCRIPTS=1 uv run pytest -q tests/test_request_rows_real.py reads this
machine's 2.1.300+ transcripts with and without those rows and asserts the digest and the tool count come out
the same.
Shares only the vault's data paths and imports no other plugin's code. It reads projects/*.md
bindings and the vault root through copies of the vault plugin's vault_root.py, ladder.py and
project_match.py in its hooks/ (with ak's session_origin.py, transcript_rows.py and move/lineage.py), and prints through
its own copies of ak's ui.py and groups.py (src/observer/_ui.py, _groups.py), mounted or not.
scripts/copies.py in the kit writes every copy and fails on a byte of drift (where observer lists them).
The vault plugin needs this one for nothing; another reader may use the same store and the same ledger.