Memory Daemon - alexherrero/agentm GitHub Wiki

Memory daemon (agentmd) reference

The resident Go process that watches the vault, maintains one FTS5 index, and serves two MCP tools. It is the only thing that runs git against the vault. Search runs lexical-only unless a local embedding model is installed, in which case one supervised child process gives it a second, dense-vector search mode โ€” see The embedder child.

Source lives in daemon/. Design: AgentM Rescope โ€” Storage Topology.

โšก Quick reference

Binary agentmd โ€” pure Go, builds with CGO_ENABLED=0, no cgo
Serves http://127.0.0.1:7821/mcp (loopback only; non-local requests get 403)
MCP tools memory_search, memory_capture
Index one SQLite FTS5 file, outside the vault, deletable and rebuildable
Embedder optional supervised llama-server child, EmbeddingGemma-300M โ€” attach or spawn; --no-embedder stays lexical-only
Vault path resolved at every start from plugins.obsidian-vault.vault_path
agentmd serve

Building it

Needs Go on the machine (brew install go). There is no vendored binary โ€” the daemon is built from source in this repo:

cd daemon && CGO_ENABLED=0 go build -o ~/.local/bin/agentmd ./cmd/agentmd

To redeploy after a change under daemon/, run scripts/deploy-daemon.sh from the checkout you want to deploy. It builds both binaries beside the live ones and swaps them in. It then restarts the launchd job and waits for /health. Don't swap the binary with a hand-typed mv: mv is on the global ask list, so a hand swap stops for a permission prompt.

CGO_ENABLED=0 is the point rather than a precaution: it produces a static binary with no system SQLite dependency, which is what lets the same source serve any machine on the home network. Cross-compile by setting GOOS and GOARCH โ€” GOOS=linux GOARCH=arm64 builds for a NAS from the laptop.

Installing it for good

One flag does the build and the launchd agent together, on macOS:

bash /path/to/agentm/install.sh --daemon

It builds ~/.local/bin/agentmd, writes ~/Library/LaunchAgents/com.agentm.daemon.plist, loads it, and then verifies the daemon answers /health before returning โ€” a job launchd accepted and that immediately died on a held port is indistinguishable from a working one in launchctl list, which is how the retired daemon stayed "healthy" and wired to nothing for months.

RunAtLoad starts it at login and KeepAlive restarts it if it dies; ThrottleInterval bounds the retry rate so a broken install idles instead of spinning. The vault path is deliberately not written into the plist โ€” it is resolved from the kernel config at every start, because a path baked into a plist is a cached literal that goes stale.

You only need that flag once. Once the agent exists, every later install or --update run rebuilds and reloads the daemon on its own, so refreshing the harness also refreshes the daemon. That matters because the binary is compiled from daemon/ โ€” without it, pulling new source leaves the old binary resident indefinitely with nothing saying so.

The refresh is deliberately non-fatal. A missing Go toolchain or a failed build prints a warning naming the fix and lets the install finish, because a project install should not die over the daemon, and a broken build must never take down a daemon that is currently working. The build goes to a sibling path and only replaces the live binary once it has succeeded.

The same run also fetches the embedder: embeddinggemma-300M-Q8_0.gguf (~330MB, one time) from ggml-org/embeddinggemma-300M-GGUF, to a temp path, SHA-256-verified, then moved into place โ€” an install never loads an unverified or half-downloaded model. llama-server itself is not built here (it is a cgo project, which the daemon's static-Go constraint exists to avoid); if it is missing from PATH (macOS: brew install llama.cpp), the fetch still succeeds but every status surface reports the embedder off and searches run lexical-only until it is installed. Pass --no-embedder to skip the model fetch on purpose.

Pass --no-daemon to skip the refresh for one run; the daemon keeps whatever binary it has. Logs go to ~/Library/Logs/agentm/daemon.log.

launchctl bootout gui/$(id -u)/com.agentm.daemon && rm ~/Library/LaunchAgents/com.agentm.daemon.plist

install.sh --mcp-server is retired and now refuses โ€” it installed the Python FastMCP server, and a second agent on port 7821 would lose a race for the port and retry forever.

Subcommands

Command What it does
serve Watch, index, serve MCP, commit. Prints listening http://โ€ฆ once the index is caught up.
search <terms> One-shot query against the index. -k, -mode, -question, --after, --before, -project, --json.
capture <text> One-shot capture. Reads stdin when given no argument.
reindex Full reconcile, then the orphan sweep, whose one line says what it removed. --from-scratch deletes the index first, proving it rebuilds from the files โ€” which also discards every vector, so prefer the plain form. The enrichment ledger is not in the index: since task 181 it lives in ~/.local/state/agentm/ledger.db, follows moved notes and survives a reindex, so a reindex is never followed by agentmd ledger --rebuild, which would wipe its rows and their input keys. An extractor change needs neither: the index re-derives its entity rows in place (task 186).
status Ask a running daemon for its state. Exits 3 when anything is red. --json for the raw document.
probe Run the round-trip self-probe now. Exits 3 on failure.
gate corpus-write Ask whether a corpus-wide write job may start. Exits 0 to pass, 3 to refuse, 1 when it could not decide.
classify Rank-penalty class counts over the live vault, printed beside the figures the measurement report established.
rules Print the filing contract. --json serves it to anything that needs the taxonomy, --file parses one specific file, --init <path> seeds a vault from the embedded copy without ever overwriting one.
meters The four diversity meters over the filed memory corpus. --sample, --trigram-top, --window, --json.
graph The memory context graph, laid out deterministically and written as SVG. --cap, --out.
clusters Which notes are too similar to be independent memories, and what kind of too-similar. --threshold, --sample, --json.
embed Compute the vector arm's embeddings for in-scope notes.
enrich Run the enrichment pass over the notes owed one. --sample N --seed S draws a reproducible random batch, --dump writes before/after pairs, --yes runs one batch without needing daemon.enrich_enabled set.
crystallize Write the lesson a recurrence taught. --dry-run finds the recurrences and makes no call, --topic narrows the run, --cap lowers the five-lesson limit, --yes runs one pass without needing daemon.crystallize_enabled set.
restated Judge the convention, preference and workflow pairs that may state one rule twice, and merge the ones that do. -dry-run shortlists and makes no call, -line sets the shortlist's similarity (0.74), -cap bounds the pairs judged a run (30), -apply merges while daemon.restated_merge_enabled is on, -yes merges once without it.
ledger Ask what dreaming has already done, and what is pending.
queue Show the pending-work queues, or record work owed.
sources Ask whether a source has been mined, and watermark it.
tiers Ask which model tier a dreaming job may run on. --audit --job summarize --cheap MODEL [--judge MODEL] [--samples N] [--seed S] [--yes] runs the audit that earns one.
door Ask whether a write inside a project needs alignment.
slop Score notes for template residue and novelty. --threshold, --json.
completeness Sample enriched notes and split them into claims for grading. --sample, --replicates, --json.
retire Stop and archive the orphaned pre-daemon memory server.

Every subcommand accepts --config, --vault, --index, --port.

The index

One FTS5 table, four columns, porter stemming:

CREATE VIRTUAL TABLE docs USING fts5(
  path UNINDEXED, title, meta, body, tokenize='porter unicode61');

BM25 column weights are 0.0 / 4.0 / 3.0 / 1.0. title is the frontmatter title plus the filename stem with separators spaced out. meta carries aliases and tags. body is the frontmatter block followed by the note body, so type: convention stays searchable.

Both settings are measured rather than chosen. Porter stemming is worth +5.7 hit@5 and is the only tokenizer knob that moves hit@5 at all; the 4x title weight is worth +3.8 hit@1. The meta column measures as a no-op today because only 5.5% of the corpus has anything in it โ€” it exists because dreaming's alias backfill lands next and needs somewhere to land.

The index file is not in the vault. It is a cache, and a database on a synced mount is a known corruption pattern. Delete it and the next start rebuilds it: 8,864 files in about 2.6 seconds, or 39ms for an unchanged corpus.

A progress log is indexed from its head rather than whole (agentm-vault ยง Projects and tasks): progress.md (a singleton pair's, or a task's) and progress-<slug>.md beside a flat plan are read through their first 88,192 bytes (note.ProgressHeadBytes โ€” 20,000 tokens times four plus 8,192 bytes of slack, the bound the Python arm's prompt-submit recall already puts on its read of any one entry, where it counts characters), cut back to the last line break so no line is split. An append-only log grows for as long as its task runs, and read whole a megabyte of it would carry nearly every query's terms and outrank the notes it mentions.

The embedder child

Hybrid search is optional and additive: the daemon is still pure Go (CGO_ENABLED=0), and everything above works with no model installed at all. When one is, the daemon supervises exactly one child process โ€” a llama-server running the pinned embeddinggemma-300M-Q8_0.gguf (768 dimensions, 2,048-token window) โ€” never two. A cross-encoder reranker was built and bake-off-tested (daemon/internal/rerank/), but its rejection floor could not separate true answers from hard negatives on this corpus at any threshold; it is refuted, kept as quarantined research code behind an unpublished flag, and agentmd serve never spawns it. See AgentM Hybrid Retrieval for the measurement.

agentmd serve binds the child it starts to a fixed loopback port (8901) so a one-shot agentmd search -mode hybrid โ€” the prompt-submit hook's own call shape โ€” attaches to that same warm model instead of loading a fresh 330MB copy per query. --embedder-url overrides the attach target; --no-embedder (also an install.sh flag) skips the model download entirely and runs lexical-only, a fully supported configuration that says so on every status surface.

Liveness comes from the work, not from /health: a wedged llama-server answers /health with 200 while failing every real embedding, so three consecutive failed embeddings โ€” not an HTTP code โ€” condemn the child and trigger a restart with exponential backoff. agentmd status reports embedder ok (warm) ยท <model> ยท N/M embedded or DEGRADED โ€” hybrid off with the reason; the same detail is on /status as health.embedder.

Notes longer than the window are split into overlapping chunks (the model's own byte budget, 1/10 overlap) rather than truncated โ€” a note scores by its single best-matching chunk. The vector arm is scoped to Agent/memory, Agent/desk, Agent/external, Agent/diagnostics (the diagnostics space joined in filing-v2 part 2a โ€” the digests and scorecards it holds lived under desk before the move and were already dense-retrievable), the vault-root Projects/ (joined in filing-v2 part 2b โ€” the project trees lived under desk before that merge and were already dense-retrievable too, so the move must not silently drop them from the vector arm), and the vault-root Calendar/ (joined in filing-v2 remainders task 3 โ€” its facet notes, day indexes and reviews were lexically indexed from the start, and the dense arm now reaches them too); _vault-archive/ and the residual _meta/ are never embedded.

The rank penalty

Miner fragments are short and quote the operator's own words, so BM25 ranks them above the filed notes that answer the question. Demoting them is worth +3.75 points of R@5 at p = 0.0195, measured over six replicates per arm.

Class Weight Detected by
fragment 0.30 body opens with a miner lead-in, mining_confidence in frontmatter, or a mid-word slug in a miner-filled directory
fragment-promoted none the same shapes, on a note whose status shows filing promoted it
status 0.60 status is unfiled, inbox, superseded, or expired
staging 0.30 a dream-staging proposal, which quotes both notes it is about
space 0.30 the note's first path segment is named in the contract's dampened_spaces
artifact 0.30 the note says altitude: artifact โ€” dampening lifted for a question that asks for that shape
lifecycle-dormant 0.30 frontmatter lifecycle: dormant โ€” silent past the axis's dormant_after_days
lifecycle-archived 0.30 frontmatter lifecycle: archived โ€” also walled out of the default result set (see below)
lifecycle-superseded 0.30 frontmatter lifecycle: superseded โ€” the axis is now the only carrier of supersession for a memory; also walled out of the default result set like archived (see below), lifted by include_archived, counted in superseded_hidden
ingest-staged 0.30 status: ingest_staged โ€” a unit the ingest sweep fetched and has not yet promoted; walled out of the default result set like archived and superseded (see below), lifted by include_archived, counted in staged_hidden (daemon/internal/note/classify.go:82-92,326-327)
completed 0.30 the note sits in a project's completed/ folder โ€” a closed task's leftover research, briefs and drafts (agentm-vault ยง Projects and tasks); earned by path, not by anything the note says
durable none the note never ages: lifecycle_tier: durable, lifecycle: pinned, kind: failure-incident, a decisions/ path segment, or a contract-exempt space

Four properties are load-bearing:

  • Strength does not matter. A 125-point sweep produced four distinct outcomes, and every weight at or below 0.6 ranked identically. There is no tuning knob because there is nothing behind one.
  • Multiply over an over-fetch. 200 rows are fetched, each score multiplied by the product of its classes' weights, then re-sorted. Re-ranking only the top k cannot promote the note the fragments were hiding.
  • Filing overrides shape. fragment-promoted carries no weight, so a fragment-shaped note that filing promoted keeps its score. That protects 1,288 notes, including 229 of the 232 in memory/preferences/ โ€” the promotion pipeline promoted their bodies verbatim, so they look mined and are filed.
  • Never exclude. A penalized note that is the best thing the corpus has still comes back first. Exclusion is what left recall returning nothing for four months.

The archived, superseded, and staged wall

lifecycle-archived, lifecycle-superseded, and ingest-staged are the three classes that break the "never exclude" rule above, on purpose. An archived, superseded, or staged note leaves the default result set entirely โ€” on disk, in the index, but invisible until you ask for it by name: include_archived: true on memory_search, -include-archived on agentmd search. One flag lifts all three walls together (wallUnserved, daemon/internal/index/search.go:884-907); there is no separate flag for staged units alone. Ask, and each comes back present and still demoted, never restored to parity with an active note. Every search outcome reports archived_hidden, superseded_hidden, and staged_hidden, the counts each wall kept out of that call's results, so an absence is visible rather than inferred.

Space, altitude, and the two that are not penalties

space replaces a directory boundary, and the replacement is the point. Recall used to restrict itself to the memory root, which cured a real leak by amputation: 13% of top-5 results across 20 prompts fell outside Agent/, and "what should I work on next" returned two Church notes. But a note that cannot be returned at all cannot be returned when it is the only answer โ€” and an invisible space is how this vault lost 9,786 notes once already. Dampening cures the same leak without hiding anything: a strong distinctive match still clears the multiplier, and a weak cosine neighbor does not.

artifact separates a note that states something durable from one that records a moment. A convention and a distilled meeting are both type: workflow and should not rank alike on a general question. When a question asks for the artifact shape, the dampening is removed rather than reversed into a boost. Every multiplier here is at or below 1.0 and the negative-IDF clamp depends on it, so a multiplier above 1.0 on a row whose score went negative would move that row up for being boosted.

The design makes artifact the default so canonical has to be earned. Capture used to write that default explicitly onto every new note (DefaultAltitude) rather than leaving the field absent for this ranker to read as a fallback. The card backfill (agentm-vault plan 06) retired altitude from the card entirely: capture no longer writes it, and the backfill drops it from every note still carrying it. The enrichment pass had already dropped altitude from its own response shape (agentm-vault plan 04 โ€” see Enrichment below) and does not touch the field either. The artifact row above keys off the literal string altitude: artifact. No memory writer sets it any more; the calendar's day indexes, facet files and reviews still write it on their own records (calendar_index.py, calendar_facets.py, the dreaming binary's calendar job). So on the memory classes the class matches nothing, and the dampening stays behind daemon.altitude_enabled, off by default, until the ranker is re-audited against a field the card no longer carries.

durable carries no weight and is not a penalty. It is the record of a decision, read by decay where the weights are not.

The project lift

A query that names a vault project ranks every note whose own project: names a different project, or none, at ProjectMismatch (0.80) โ€” inside the same multiplier as the classes above, so a matching card ranks 1.25x an equal unmatched one (agentm-vault ยง Projects and tasks). Nothing in this ranker may exceed 1.0, since the negative-IDF clamp depends on that ceiling, so the lift is spelled as a mild dampening of everything else. An empty or absent project ranks exactly as before. The lift applies in every search mode โ€” and, fusion, and hybrid's dense arm alike โ€” each reading the project the index stores alongside flags / captured / updated for every note.

Nothing reaches this from memory_search โ€” the MCP tool carries no project parameter. The two callers are agentmd search -project <slug> directly, and the prompt-submit recall hook, which resolves the session's project from its .harness/project.json + .harness/active-plan binding rather than taking a flag; recall.py's own in-process and daemon-backed search paths apply the identical 0.80 weight (_PROJECT_MISMATCH) so a session sees the same lift whichever arm answers.

Decay, and why it is off

Age is the one demotion this daemon computes and does not apply. daemon.decay_enabled defaults to false.

The curve is real and ported faithfully: full strength through six months of silence, half to a year, an eighth to three years, a sixteenth to five, and the sixteenth is a floor rather than a waypoint. The anchor is a genuine recall from .lifecycle.json โ€” in the engine state directory since the memory-root trims (agentm-vault plan 05), with the memory root read as the fallback on a vault that hasn't moved yet (note.NewAccessLog's dirs list, opened through index.OpenWithSidecar with cfg.EngineStateDir) โ€” then updated:, then created:, then a captured: date the note claims itself. A filesystem timestamp never anchors. The type-collapse migration rewrote 9,899 notes' frontmatter in an afternoon, and an mtime-anchored curve would read the migrated corpus as brand new and the files it skipped as uniquely ancient.

What is missing is a corpus that can carry it. 89% of notes are under a month old, the oldest in the memory layer is 93 days against a first band at 182, and exactly five notes of 15,824 cross any band. Scored with decay as the only variable, R@5 fell from 0.781 to 0.750 โ€” two questions lost, none gained.

One of those two is a precondition on ever turning it on: for a temporal question, age is the signal rather than the noise. "When did I switch from Antigravity to Claude?" is answered by a note written in February, and demoting it for being old is demoting it for being the answer. Nothing here reconciles a curve that ranks by staleness with a question class that ranks by antiquity.

There is no OR query rewrite. It read as the largest available win on one run; replicated six times it is +1.25 points at p = 0.46 and costs 18.8 points of correct rejection, because a search that never returns empty hands the agent five plausible notes and it names one. When a query matches nothing, memory_search says so and suggests fewer or different terms instead.

memory_search

Param Type Default Notes
query str required Two to five distinctive words. Always drives the lexical arms, regardless of mode.
mode str and and โ€” every term in one note (the original, still-default behavior). fusion โ€” best 2-term subset wins, max-score across all subsets. hybrid โ€” fusion plus a dense-vector arm, combined by reciprocal rank fusion.
question str โ€” The full natural-language question. Read only when mode is hybrid: the dense arm embeds this instead of query. Omit it and hybrid embeds query instead โ€” never an error, just a weaker dense arm.
k int 5 Capped at 50.
after str โ€” Capture date on or after. YYYY-MM-DD or RFC3339.
before str โ€” Capture date before.
include_archived bool false Lifts the archived and superseded wall for this call โ€” the note re-enters the result set, demoted like any other lifecycle class.

Two more knobs exist in the code and are deliberately not in this table: a -lex3 flag (widens fusion's subset search from 2-term to 2- and 3-term) and a rerank mode (cross-encoder rerank with a score floor). Neither is in memory_search's published schema and neither is requested by the prompt-submit hook โ€” lex3 missed its own recall floor by two questions, and rerank could not separate true answers from hard negatives at any threshold. Both stay in the tree as tested, working code reachable only from agentmd search directly: a refuted rung is still worth keeping when it costs nothing in production. See AgentM Hybrid Retrieval.

Returns {results, note, matched, archived_hidden, superseded_hidden, staged_hidden}. Each result carries path, score, raw_score, penalty, captured, captured_source, and snippet. score is the penalized score and larger is better; raw_score is the value before demotion, so a penalty is visible rather than inferred from a number moving. archived_hidden, superseded_hidden, and staged_hidden are the counts each wall kept out of this call's results โ€” present even at 0, so an absence reads as measured rather than assumed.

note is set whenever the driver should know something โ€” a rewritten query, or an empty result set.

A query that names an entity puts its page first (task 179, your ruling of 2026-09-30; daemon/internal/index/entityname.go). When the query is an entity page's title, one of its aliases or its id โ€” or asks about one in so many words: "what do I know about crickets", "who is Jane Doe", "what shipped in agentm v10.0.0" โ€” that page comes first, moved up or added with its head filled, in every mode and after the CLI's rerank. A search with a date bound is a question about a time and is left as it ranks. A name two pages share names neither, a hand-written note in entities/ is not an entity page, and every other query ranks exactly as before. It exists because a page listing every note that mentions its entity is long, and the 576 issue and release pages each carry their repository's name: without it "alexherrero/crickets" put the repository's page 107th.

memory_capture

Param Type Default Notes
text str required The fact, in plain prose. One concept per call.
title str derived Weighted 4x in ranking, so a good one is worth writing.
type str preference One of preference, workflow, idea, fix, convention, reference.
summary str โ€” One line: what this is and when it applies. Worth writing whenever the body runs past a paragraph.
why str โ€” Why this was kept โ€” what was happening, and what it decides later. Only write it when you actually know; a guessed reason is never invented downstream either. This is also the judgment signal โ€” see status below.
importance int โ€” 1-10. Written to both importance and importance_proposed โ€” an operator's later edit to importance alone is what marks the value theirs from then on.
related [str] โ€” Notes this one sits beside, by slug or [[wikilink]]. Rendered as a quoted flow list of wikilinks.
project, task str โ€” The vault project and task this session is bound to, when it has one โ€” the session's opening brief names both (agentm-vault ยง Projects and tasks); pass them so the card is found with the project's work.
tags, aliases [str] โ€” Both land in the meta column.
source str โ€” The transport the memory arrived by โ€” one of the contract's sources vocabulary (operator-direct, conversation, external-fetch, email). Sets the trust tier. As of the provenance ruling (2026-09-06) it is transport only; where the material came from goes in source_id or source_url below.
source_id str โ€” A mined unit's registry identity, when the memory came from one.
source_url str โ€” A fetched page's address, when the memory came from one.
space str memory Which configured space to write into.

status is not a published param and cannot be set through this tool. It is derived from what the writer knew: a call that names a type and gives a why is a card someone judged, and it lands active; anything else lands unfiled, a candidate the nightly pass judges later (knowingWriter, daemon/internal/capture/capture.go:158; called from Do at :313-337). The wire-level Request.Status field still exists for callers that haven't moved off it, but a value that disagrees with the derivation is reported back in the response rather than silently obeyed or swallowed.

instructions is accepted on the wire (Request.Instructions) but is deliberately not published in this schema, for the same reason probe isn't: the ingest sweep executes a matching instruction under a fixed grammar, and a field a model can see is one it will eventually fill from note content โ€” an execution path for whatever that content says. It reaches this door only from operator-typed surfaces: the CLI's agentmd capture -instructions, and the phone clipper. See How to capture from your phone.

Capture writes the file, then updates the index. No model call, no network, and it works offline โ€” the mechanism that makes something exist and findable never waits on judgment. If the index write fails, the file is still on disk and the next reconcile pass picks it up; the response says so rather than inviting a retry that would write a duplicate. There used to be a second effect after the write โ€” an eager enrichment pass fired out of band โ€” and it is gone: capture no longer imports the enrichment package at all (daemon/internal/capture/capture.go), and the note waits unfiled for the nightly batch instead. See Enrichment below.

agentmd capture mirrors this schema on the CLI: -type, -summary, -why, -importance, -related, -project, -task, -instructions, alongside the pre-existing -title, -tags, -aliases, -source*, -space. There is no -status flag (daemon/cmd/agentmd/main.go:518-534).

Configuration

Read from ~/.claude/.agentm-config.json, overridable per-invocation by flags.

Key Default Notes
plugins.obsidian-vault.vault_path โ€” Required. $MEMORY_ROOT overrides it, and names the memory root (<vault>/Agent on the shipped layout): the daemon takes the configured memory_root off the export's end to find the vault root, and an export that does not end in it is a flat layout โ€” both roots at once. --vault beats the export. Deprecated: $MEMORY_VAULT_PATH is the old name for the same variable, with the same memory-root meaning; the hooks and the runner still export it alongside $MEMORY_ROOT, and it is removed one release after 2026-09-11.
daemon.spaces derived from memory_root: {"memory": "<root>/memory", "projects": "Projects", "diagnostics": "<root>/diagnostics"} Space name to vault-relative directory. diagnostics joined the defaults in filing-v2 part 2a. projects points at the vault-root sibling Projects/ as of filing-v2 part 2b โ€” unprefixed, since it sits beside <root> (previously <root>/desk/projects).
daemon.shard date The fallback only โ€” a typed note the filing contract's routing table names lands in that class directory regardless of this setting (see Lifecycle, sources, and facets). daemon.shard governs only a note the contract can't place: date writes <space>/<YYYY>/<MM>/<slug>.md, flat writes <space>/<slug>.md.
daemon.phone_paths [] Vault-relative prefixes whose changes are attributed to the phone.
daemon.reconcile_every 5m How often to re-walk the vault.
daemon.port 7821
daemon.index_path platform state dir Rejected if it resolves inside the vault.
daemon.unfiled_age_red 72h The oldest unfiled item's age that turns the queue red.
daemon.unfiled_count_red 1000 Size backstop. Deliberately far above an ordinary day.
daemon.queue_baseline first run Items captured before this are the inherited backlog. Recorded automatically if unset.
daemon.health_every 15m How often thresholds are evaluated and the probe runs if due.
daemon.probe_every 24h How often the self-probe runs.
daemon.probe_budget 10s How long one round trip may take before it counts as failed.
daemon.people_email_evidence_enabled false Lets a mail thread with you count as shared work for each person on it, toward the two a person needs for an entity page (task 179). Nothing reads mail yet: the builder asks an email source only while this is on, and none ships. Set it with agentm_config.py --people-email-evidence-enabled true. agentmd status --json lists it under switches with the other daemon switches.
plugins.autonomy.email_to โ€” Where alerts go. Shared with the daily email that carries the morning note.
plugins.autonomy.email_smtp_url โ€” smtp://[user[:password]@]host[:port]. Both keys required, or the channel skips.
plugins.autonomy.email_from email_to For relays that need a domain-verified sender.

daemon.spaces and daemon.shard are the seam the Agent/memory + Agent/desk migration turned on. Moving to that layout was an edit to these two keys, not a rewrite โ€” which is why the defaults derive from memory_root instead of naming directories as literals, with one deliberate exception: projects is now the unprefixed literal Projects, the vault-root sibling filing-v2 part 2b moved it to (the Python stack names the same sibling as ../Projects, relative to its own root).

The two new spaces: resources/ and systems/

Note

Steps 2, 8 and 9 shipped โ€” tasks/176-converge-the-vault-layout. The door, the embed scope, the storage contract, and the maps job all know both spaces. Step 8 moved the first files into resources/ live on 2026-09-25 (vault commit 7ac40ea5cd): 233 reference cards to topics/<topic>/ (file-watching, go-git, go-static-binaries, llama-cpp-embeddings, sqlite) and 101 watchlist items to watchlist/<source>/ (archived on 2026-10-03 and no longer in resources/), plus a new university/index.md. resources/ is a real directory now, with something for the door, the embed scope, and the dampening to act on. Step 9 filled systems/ the same day (vault commit 6af892a888): the six homelab notes moved from memory/semantic/ to homelab/components/ under their own names and became kind: component records, homelab/system.md is a new overview drawn from homelab-domain and home-server, and agentm/system.md and crickets/system.md are front doors to each repo's wiki.

The plan adds two shared spaces:

Space Holds Dampened?
resources/ topics/<topic>/ โ€” 233 reference cards, moved from a project's research/<topic>/reference/ on 2026-09-25 (step 8), dropping the reference/ level. university/ โ€” opens with index.md, study guides after. Only briefs/ is dampened. The weekly field brief took over from the watchlist as the one dampened corner on 2026-10-03; before that dampened_spaces named all of resources (until 2026-09-25) and then the watchlist alone. The dampening runs at the daemon's fixed strength (see Space, altitude, and the two that are not penalties above) whatever the question, and with all of resources dampened, seven of the ten gold questions about a reference card lost it from the top five. The topic cards now rank as any note does: agentmd search "fts5 columnsize" returns resources/topics/sqlite/columnsize-detail-config-options.md first.
systems/ homelab/ โ€” a system.md overview written on 2026-09-25, plus components/*.md: the six homelab notes moved from memory/semantic/ under their own names. A one-page front door per software system (agentm/system.md, crickets/system.md). No

Step 2 wired these, ahead of any note moving; step 8 then moved the first ones into resources/:

  • The door. door.DefaultAuthority() grants both resources and systems the Shared level (daemon/internal/door/authority.go:79-97) โ€” the same level calendar already carries: agent-maintained, operator co-writes, no grant needed.
  • The embed scope. config.defaultEmbedScope names both spaces at the vault root, beside projects and calendar (daemon/internal/config/config.go:462-470). A note that moves in from projects/agentm/research/<topic>/reference/ or memory/semantic/ keeps the vector it already had, rather than the dense arm losing sight of it the night it moves.
  • The storage contract. Of resources/, only resources/briefs is in dampened_spaces (described in the table above; the watchlist alone until 2026-10-03, all of resources until 2026-09-25). record_kinds gains system, component, and blueprint (storage-rules.default.md:264-266) โ€” the shapes a system's overview (systems/<name>/system.md), one of its parts (systems/<name>/components/*.md), and a project's blueprint.md will carry. standards/templates joins recall_exempt_areas (storage-rules.default.md:418-420): the wall now keeps out placeholder text alongside the private material (personal/Home/Important Docs) it already walled. The Python fallback mirror (harness/skills/memory/scripts/storage_rules.py:510) carries the same two-entry list.
  • The maps. The mocs job's new PlanSpaceMaps (daemon/internal/dreaming/spacemaps.go, wired into the nightly run at daemon/internal/dreaming/run.go:275-281) writes resources/moc-resources.md and systems/moc-systems.md once a space holds a note: a section per folder, a folder's notes listed by title while there are 12 or fewer and counted once there are more (spacemaps.go:34-36,142-159), dated by created. moc-root.md lists a Resources or Systems area once either map is written (daemon/internal/dreaming/rootmap.go:104-109).

PlanSpaceMaps skips a space outright when it isn't yet a directory โ€” isDirExact, called from spacemaps.go:170. Step 8 gave resources/ real files, so it no longer skips: the next nightly run has a resources/moc-resources.md to write. Step 9 did the same for systems/, so the next nightly run writes systems/moc-systems.md as well; systems/ carries no dampening rule.

The project root locks to five files

Note

Steps 3 through 6 shipped, step 7 pending โ€” tasks/176-converge-the-vault-layout. Step 3 moved the files off the project roots live on 2026-09-25 (vault commit 5e591f5371): 25 files left the roots โ€” living documents to projects/<slug>/docs/ (followups.md, roadmap.md, conventions.md in six projects, plus version-summaries.md, blog's six design/strategy/voice docs, dev-setup's cloud-test-runbook.md, and two how-to-*.md files), config and source lists to projects/<slug>/desk/ (agentm's four lists; home's home_config.py and test_model.py) โ€” with 100 path links rewritten across 57 notes and 0 new unresolved links. Step 4 then closed the door. Step 5 gave every project a project.yaml. Step 6 gave the mocs job a generated tracker.md for every project โ€” PlanProjectTrackers (daemon/internal/dreaming/projecttrackers.go:199, wired into the nightly run right after the project maps at daemon/internal/dreaming/run.go:275-282) renders it from the project's own task trackers, so no session writes one any more; see the mocs job below for the shape. The first nightly pass over the shipped code has not run yet, so the file itself doesn't exist in the vault as of this writing. blueprint.md (step 7) is still to come.

The door locks every project's root to exactly five files: project.yaml, charter.md, blueprint.md, tracker.md, and moc-<slug>.md (see Vault project.yaml reference for the new file's schema). door.RootFiles names the first four (daemon/internal/door/door.go:98); door.IsRootFile adds the fifth, built from the project's own slug (daemon/internal/door/door.go:102-112). Roots.Judge reads both at the root: creating one of the five files is Standing โ€” a project missing its blueprint.md is missing a page, and writing it is maintenance (daemon/internal/door/door.go:184-189) โ€” creating any other new file at the root answers Alignment (daemon/internal/door/door.go:191-196), and changing an existing root document is Alignment as it always was (daemon/internal/door/door.go:178-183). Subfolders (docs/, desk/, decisions/, designs/, research/) are unchanged: everything below the root stays Standing. This reverses the door's earlier rule that a project could have as many root documents as it needed, with no cap.

Step 3 already moved the living documents that used to sit at a project root (followups.md, roadmap.md, and others) to docs/, and the config and source lists to desk/. Step 5 gave all twelve projects a project.yaml on 2026-09-25, held to its schema by check-project-yaml. Step 6 wired a generated tracker.md into the mocs job (see the callout above and the mocs job below); blueprint.md (step 7) is still to come.

The loud queue

Filing is asynchronous, so the queue is meant to be busy โ€” what must never happen again is a queue that stops draining and says nothing. The previous system's inbox reached 4,933 items in silence.

agentmd status reports five things and exits 3 if any of them is red:

agentmd 0.1.0-dev ยท up 14h
RED
  vault    /path/to/vault
  queue    4411 awaiting a judgment ยท oldest 4d1h old (red past 3d old, or 1000 items)
           of which 4349 inherited (captured before 2026-08-10, oldest 28d22h) โ€” reported, not paged about
  index    9159 documents ยท last pass 41s ago        (red past 15m0s)
  git      degraded: not a repository
           no undo for a bad write, and `agentmd gate corpus-write` refuses
  probe    ok 3h0m0s ago (round trip 11ms) ยท memory/2026/08/agentm-self-probe-โ€ฆ.md

The thresholds are age-dominant. Under a standing daily ingest, fifty fresh unfiled items every morning is an ordinary Tuesday; the oldest unfiled item being three days old means filing stalled. The count threshold is a backstop at a thousand โ€” at fifty a day it takes twenty dead days to reach, by which point age has been red for seventeen of them, so it fires on its own only when a producer wrote thousands of items at once.

The queue is unfiled or inbox notes no enrichment has judged. confidence_set = 0 narrows it past status alone (awaitingJudgment, daemon/internal/index/index.go:661): a card enrichment judged and left below the floor keeps status: unfiled, but it has been through the pass and is listed for you in needs review rather than counted here โ€” the same reason superseded and expired are excluded. Counting an already-judged card would put one retired years ago at the head of the queue and leave the age threshold red permanently.

The inherited backlog is reported and does not page. The first status read against the real vault was 4,349 unfiled items, the oldest 29 days old. Both numbers are true and neither is news: the design already decided that pile is rank-penalized and drained by dreaming later, and their dates come from filesystem mtime, which a sync client can rewrite wholesale. So the daemon records a queue baseline on its first run โ€” items captured before it are the backlog it inherited. The total, the inherited count, the backlog's own age, and the baseline date are on every status surface; only the part captured after the baseline is measured against the thresholds. A four-day-old item captured after the baseline pages even when the backlog is thousands deep, which is what keeps the split from being a mute button. Set daemon.queue_baseline to move the line by hand; delete queue-baseline.json in the state directory to re-record it.

Degraded git is reported and does not page. It blocks the corpus-write gate below, and it is on every status surface, but the vault is not a repository until the git-transport migration runs and a daily email about a deferred migration teaches its reader to ignore the channel.

A commit stall pages after half an hour. A repository that is there and refuses every commit is the opposite case: nobody chose it, and while it lasts the vault's history and its backup both stop. On 2026-10-04 that ran for eleven hours, and the only trace was a warning in the log every five minutes. The daemon now counts its failed commit cycles in a row. A cycle fails when it can't read the worktree's status, or when a commit with changes waiting records nothing and returns an error. A cycle that commits, or finds nothing to commit, ends the run. The run is on the git block of agentmd status --json as commit_failures (always present, so a clean 0 is visible), first_commit_failure and last_commit_error, and the text status adds a line under git while it lasts. Once the first failure is 30 minutes old, commit-stalled goes red. The doctor's daemon-commits row (scripts/machinery_doctor.py) fails as soon as the count is above zero.

On red, the daemon emails through the operator's own relay โ€” once per calendar day for the same set of conditions, and again when a different one goes red. With no relay configured the channel is a silent skip, said once at startup rather than discovered at 3am. Credentials are never sent over a connection that did not negotiate TLS: if the relay offers no STARTTLS, a URL carrying a password refuses to send rather than downgrading to a plaintext login.

The self-probe

Once a day the daemon proves the round trip on itself. It captures a synthetic note over its own MCP surface, asks for it back with two nonces, and records the result where status reads it.

  • The alias nonce appears only in frontmatter, so finding it can only be answered from the meta column. That is the sideways question โ€” the note's prose does not contain the word being searched for.
  • The body nonce appears only in the prose.
  • The whole trip must finish inside daemon.probe_budget.

The note is marked probe: self-probe in frontmatter. Everything that must not count a synthetic note in a measurement reads that marker โ€” agentmd classify --json carries a probe field per row and the summary counts them apart. Not a path rule: capture shards by date, so a probe written on the 31st and one written the next morning live in different directories, and any location-based exclusion would quietly stop excluding.

The current probe note stays in the vault so the round trip has an artifact anyone can look at; the previous one is retired on the next successful run. A failed probe's note is left in place as evidence. Probe commits carry origin: self-probe.

agentmd probe

Runs it now, through the same code path as the daily schedule.

The filing contract

standards/storage-rules.md decides where a memory goes and what shape it takes. The daemon reads this file at runtime instead of compiling it in. You write the contract in markdown, and the daemon follows it directly. Changing where a type routes, retiring a value, or moving a threshold is an edit to that file, and it takes effect on the next capture without a recompile or a release.

The daemon is the only thing that parses that file. Capture validates a caller's type against it, the MCP tool schema publishes its enum, and the Python batch layer asks over agentmd rules --json, once per run rather than once per note. A second parser would be a second thing to drift, and the whole claim is that a type added to the rules exists everywhere at once.

Resolution takes the first source that exists: $AGENTM_STORAGE_RULES, then the vault's own standards/storage-rules.md, then the copy embedded in the binary. The embedded copy is what keeps the taxonomy defined in a checkout with no vault โ€” a fresh clone, a CI run, a unit test.

Absence falls through; corruption halts

A missing rules file is not an error, so resolution moves on to the next source. A file that is present and will not parse halts filing and never falls back. The halt is what stops a model improvising around a malformed rule, which is how you get filing that looks fine and is wrong.

The halt is deliberately narrow, matched to what a broken contract actually endangers:

Still works Stops
Search โ€” it does not read the taxonomy Filing. Nothing is promoted, merged, expired or re-typed
Capture with no type โ€” it lands untyped and unfiled, which is the state filing drains anyway Capture that names a type. Validating the claim is precisely what is unavailable, so it is refused with the parse error attached
The index, the watcher, the committer, the probe agentmd gate corpus-write, which refuses โ€” a job that decides where thousands of memories belong should not decide it by guessing

Because search and untyped capture keep working, the halt would otherwise go unnoticed. It is reported in three places, each one a surface somebody reads: the filing line on agentmd status, shown red with the parse error and the remedy; the check-storage-rules CI gate; and the nightly dreaming digest. agentmd status also counts the typed captures the halt has refused since boot, which is what makes a client failing every write visible rather than silent.

A fix is picked up live. The daemon re-reads the contract on each health pass, so correcting the file returns it to OK without a restart. Capture does not pay for that re-read: it reads a held pointer and never parses the file, which is what keeps it inside its sub-100ms budget.

What the block carries

The machine-readable core is a fenced storage-rules block; the prose around it is what the enrichment prompt reads. Two registers divide the vocabulary. A note carries one field or the other, never both.

Register Field Holds
memory_types type The six values a memory carries. Something that asserts โ€” a preference, a convention, a fact, a recipe, a fix, an idea. Growth is braked by the warrant rule.
record_kinds kind Shapes a record carries. Something that records โ€” a nightly brief, a telemetry row, a session trace, an index page. Not memories, so they carry no type at all.

deprecations maps each retired value to its replacement, which is what makes a collapse mechanical rather than a judgment repeated thousands of times. rules_hash is computed over the block's parsed content rather than its raw text. Rewording the prose around it therefore leaves every judgment in the corpus standing, while changing what the block says marks them stale โ€” identifiable, and queued for a later re-filing pass rather than corrected on the spot.

The judgment hash (task 181). An enrichment judgment reads only part of the contract: the type vocabulary, which is memory_types, default_type and deprecations. JudgmentHash (daemon/internal/rules/judgment.go) is computed over those three alone, and it is what a judgment is keyed to: the fingerprint key, the ledger's version, the rules_hash stamp a note carries, and the refusal record. An edit to anything else in the block, such as the dampened spaces, the wall, a threshold or the routing, leaves every judgment standing. A changed confidence floor applies at each note's next judgment. The importance rubric stays outside both hashes. Filing still halts on a block that does not parse. agentmd rules prints both hashes, and agentmd rules --history lists every committed text of the contract with both, which is how an edit can be checked against what it re-owes. A test classifies every field of the block on one side or the other, so a new field cannot ship unweighed.

Lifecycle, sources, and facets

AgentM Filing v2 adds three vocabularies to the block (daemon/internal/rules/storage-rules.default.md:274-303). You declare each one; the daemon validates it exactly like memory_types and record_kinds above โ€” a malformed value halts filing, by name. At the time these were added, none had a runtime reader: the table below was what the contract named, not what anything did with it. Filing v2's write path (2026-09-04) gave two of the three a reader โ€” lifecycle and sources, detailed below. The calendar (filing v2 part 5, same date) gave facets a reader too, on the Python side: calendar_facets.py's own facets() function reads the registry through storage_rules.rules().facets() before writing a facet note โ€” see AgentM Filing v2 ยง The calendar for the register itself.

Key Field Values Holds
lifecycle lifecycle pinned, active, dormant, archived, superseded The aging axis a memory declares in frontmatter. default_lifecycle (active) becomes required the moment lifecycle names any value โ€” the same rationale as the unconditionally-required default_type, on a narrower trigger.
sources source operator-direct, conversation โ†’ trusted; external-fetch, email โ†’ untrusted The provenance vocabulary, stamped in a memory's source: field. Trust is a property of the transport, never the content.
facets โ€” meetings, correspondence, docs, diary The calendar's standing per-day registry. calendar_facets.py reads it (Python side); the Go daemon's own IsFacet (below) still has no caller.

The lifecycle field is a different key from the pre-existing lifecycle_tier: durable marker in the rank penalty above โ€” a different field for a different, already-wired purpose (decay exemption).

The same design added three calendar values to record_kinds (storage-rules.default.md:218-220):

  • calendar-facet
  • day-index
  • calendar-review

All three are in active use: calendar_facets.py writes calendar-facet, calendar_index.py writes day-index, and the dreaming binary's calendar job (see ยง The dreaming binary, agentmdream) writes calendar-review โ€” calendar_rollups.py wrote it before the takeover (filing v2 part 6, 2026-09-05) retired that script.

The routing table now sends an idea to memory/semantic, previously desk (storage-rules.default.md:182). At the time this reclassification shipped, capture did not read routing, and no file moved because of it. The ClassFor function (rules.go:618) was the only reader of routing at that earlier time, and only for the agentmd graph command โ€” every type: idea note classified as semantic for a memory/-prefixed destination (rules.go:627) instead of nothing at all.

Filing v2's write path closed that gap. The Capturer.Do method (daemon/internal/capture/capture.go) now calls classDir(contract, contractErr, noteType, spaceDir) and does read routing. classDir is checked before falling back to daemon.shard โ€” class routing takes priority over the shard setting. A typed note lands directly in the class routing names for its type: memory/semantic for an idea, alongside preference and convention.

classDir returns empty in exactly three cases:

  • An untyped request
  • A type the routing table doesn't name
  • A halted contract

In every one of those cases it falls through to daemon.shard. The renderNote function stamps lifecycle: active on every note capture.go writes โ€” a hardcoded literal; the Python writer below reads the contract's default_lifecycle instead โ€” filing_confidence: high when the caller named the type, and filing_confidence: low when the contract's default type was used. The status: unfiled default predates this change and no longer tells the whole story: the capture-writers plan replaced the flat default with a derivation โ€” active when the writer named a type and gave a why, unfiled otherwise โ€” see memory_capture above. Filing v2's write path (task 5) adds a fourth stamp beside these three: trust, from the caller's source tag through the contract's sources map. trustTier (capture.go) reads contract.Sources[source] directly rather than through the SourceTier method below, and treats any URL-shaped source as untrusted even when the contract names nothing for it; a source that is neither recognized nor URL-shaped earns no stamp at all. Every write through this path asks the volume gate first, below.

Validation sits beside the existing checks in validate() (daemon/internal/rules/rules.go:424-466): kebab-case and no duplicates for each list, default_lifecycle checked against lifecycle's own values, and sources checked against a closed, deliberately two-value tier set (SourceTiers, rules.go:140) โ€” a finer trust ladder would be precision a write-time check cannot honestly deliver. Rules exposes the read side as IsLifecycle, SourceTier, IsFacet (rules.go:508, :520, :538) โ€” still with no caller of their own; the trust stamp above reads the Sources map directly instead. StorageRules mirrors the same read side in Python as lifecycles(), default_lifecycle(), sources(), facets() (harness/skills/memory/scripts/storage_rules.py:119, :124, :128, :133), and three of those four now have real callers: save_entry (save.py) reads default_lifecycle() to default a memory-type write's lifecycle; both save_entry's own trust stamp and filing_engine.transport() read sources(); and calendar_facets.py's facets() function reads the registry before writing a facet note (the calendar, filing v2 part 5). lifecycles() stays uncalled on both sides, and facets() still has no caller on the Go side โ€” rules.go's IsFacet above is unchanged by this. All four keys are optional-when-absent, so a pre-v2 rules file keeps parsing while the migration runs โ€” the "absence falls through" rule this section already names.

The volume gate

A fourth threshold joined the block alongside the pre-existing thresholds.low_confidence: thresholds.daily_write_cap, the cap filing v2's write path (task 4) puts on how many memories the unreviewed front doors may add in one day โ€” the capture front door and reflect's mined candidates, plus the daemon's own native capture path. The default is 200 when the contract names none, grounded in the live corpus on 2026-09-04: the busiest day on record wrote 110, the 30-day median was 12, so 200 sits above every real day and well below what an earlier over-capture flood did. 0 disables the gate; AGENTM_DAILY_WRITE_CAP overrides it for a test or an emergency.

thresholds.daily_card_budget (30) refuses nothing (task 190). It is the write meter's budget: new memory cards a day, self-probes aside, before the morning note and the scorecard mark the day with a warning. The meter, harness/skills/memory/scripts/write_meter.py, reads the vault's git history rather than the corpus, so it sees a card written and deleted the same day, and it scales the budget to the window it reads (--since/--until). 0 turns the warning off. See the morning note.

Capturer.Do (daemon/internal/capture/capture.go) checks the gate before class routing, counting the index's own CapturedSince (daemon/internal/index/index.go) โ€” memory-space documents captured at or after the start of today โ€” against dailyWriteCap. Past the cap it refuses with a message naming the count, the cap, and the file to edit, and counts the refusal on an in-process capped counter (Capturer.RefusedByVolume) that is not yet on agentmd status or the health surface, unlike its sibling RefusedCaptures. On the Python side, filing_engine.apply() โ€” the write step behind capture.py and reflect.py's routing โ€” calls volume_gate.check() first, which walks the class directories counting each note's captured (else created) date against the same cap. apply() passes check() the day the arriving note's own captured stamp names when the caller set one, wall clock otherwise (filing_engine._write_day()) โ€” a fix (filing v2, the calendar, 2026-09-04) for the gate and the writes-per-day reading disagreeing about which day a write belonged to at UTC midnight, the same gap that could let a flood in progress find the door open. A note that reinforces one already home never reaches the gate in either language, so retrying a settled capture is always safe.

The gate does not cover every writer. save_entry() itself carries no gate call โ€” a direct /memory save (memory_append), an ingest write, or anything else that calls save_entry() without going through filing_engine.apply() first lands regardless of the day's count. The gate is scoped to where the design found the actual failure mode: unreviewed, automated volume, not a deliberate save.

The corpus scorecard's writes-per-day reading (see Read the nightly scorecards) is the Python-side count, reported daily with a week-over-week trend.

Enrichment

agentmd enrich runs as a nightly batch only. It used to have a second trigger โ€” eager, firing out of band just after a capture committed โ€” and that retired with the capture-writers plan: capture no longer imports the enrich package at all, Capturer.SetEnrichPass and Pass.Wait are gone, and the trigger enum collapsed to the one value the batch always was (Trigger, daemon/internal/enrich/pass.go:38-53). daemon.enrich_enabled now gates only the standing nightly behaviour, never a per-capture model call. See templates/jobs/enrich-nightly.yaml below for how the batch is scheduled.

The first pre-gate asks what the note is rather than where it sits. A note carrying the probe: marker is the daemon's own self-probe โ€” the synthetic note the round-trip check leaves behind โ€” and SelfProbe refuses it before anything else runs (daemon/internal/enrich/pregates.go:61-79). It reads the marker through note.Parse, the same parser retirement and the classifier read it with, so the set of notes the pass calls synthetic is the set the rest of the daemon does. It matters because of what a rewrite took rather than what it cost: on 2026-09-17 enrichment judged a probe card and dropped the marker, and the marker is what the next run reads before deleting yesterday's card, so a stripped card is one nothing ever retires. probe is carried in carriedFields as a second guard. The gate lives in freeGates (daemon/cmd/agentmd/main.go), which the cheap-tier audit draws its sample through, so the audit stops sampling the probe as well.

Eligibility refuses a superseded note, one whose status or lifecycle reads superseded or that names a superseded_by, and a paste-ready PROMPTS-*.md pack in the projects space (task 190, superseded and isPromptPack in pregates.go). Enriched, a dead draft gained aliases and a fresh date, and a search for its subject surfaced it.

Eligibility no longer reads status for anything else: what used to refuse any note that wasn't unfiled is gone (Eligibility.Statuses removed; the check now at daemon/internal/enrich/pregates.go:126-159), because status says whether a note was judged, not whether it has been through this pass. What the pass reads instead is PassDepth, from the note's own enriched_at and enriched_by stamps (pregates.go:200-208): no enriched_at means the whole pass is owed (DepthDeep), and so does an enriched_at stamped by a pass version other than the current one โ€” a prompt change re-owes the deep pass to every note (agentm-vault plan 04), because a stamp from an older prompt never answered what this one asks for: the neighbours, related, importance_proposed. Only a stamp naming the current pass version is owed the lighter pass a note that has moved since (DepthLight). A note genuinely unchanged since its last pass is caught for free by the separate fingerprint gate, keyed on the pass version, the judgment hash, and the body together (Fingerprint.Check).

What the night owes, and in what order (task 181)

The ledger has a file of its own. It lives in ~/.local/state/agentm/ledger.db, beside the night's other records, where an index schema change cannot reach it (ledger.OpenFile). The first open after task 181 carried every row out of the index and dropped the index's table. A missing file is rebuilt from the notes' stamps and says so on stderr, but a rebuilt row has no input key, so a reindex is never followed by agentmd ledger --rebuild. A row follows a note the night moved. When a row's path is gone, it moves to the one note whose body matches it and that has no finished row of its own, the rule the axis reconcile follows a move by (Ledger.Follow).

A skip never replaces a finished judgment. A gate's skip used to overwrite the done row it had just matched, so the next night bought the same judgment again (#785); Ledger.Record refuses that replacement.

The night takes what it owes by cause (enrichCauses and orderByCause, daemon/cmd/agentmd/enrich_run.go): never judged first, then changed, then retried, then owed the deep pass, then owed under an older judgment hash, then skipped, and last the notes it is current on, which the fingerprint gate skips free. Never-judged notes keep the tiers' order, the inbox first; every other cause goes least recently judged first, so a second contract edit reaches the notes the first one did not. agentmd ledger --pending, the dry run, the run record (owed, judged_by) and the morning note's enrichment line carry the counts by cause.

An unchanged judgment writes nothing. When the rendering differs from the note on disk only in updated, enriched_at and rules_hash, and the pass is not renaming the note, the file stays exactly as it is and the ledger records the judgment against the bytes on disk (enrich.SameButStamps). enriched_by is always written, because PassDepth reads it to decide whether a note is owed the deep pass. The run record counts these as verdicts.unchanged, and the morning note says how many judgments wrote nothing.

A project record with no frontmatter block is declined before the call. ComposeRecord cannot merge into one, and it used to refuse only after the judgment was paid for, leaving no ledger row, so the record was paid for again every night.

The people a note names

The deep pass returns people: at most twelve names the card itself contains, written as the card writes them, as a full name where it gives one (task 179). The prompt leaves out the person the card is written by or for, the authors of cited work and public figures mentioned in passing, and a light pass may move the field. Before anything lands, groundPeople (daemon/internal/enrich/compose.go) keeps only the names the note's own words contain: its title, the session's text and anything below the dreaming section, never an earlier pass's people: or its added prose. It then files each name under your table, standards/people/aliases.md (daemon/internal/people): a you: or deny: name is dropped, and an alias is filed under its person's full name. A card writes people: [...] in the card order; a project record gets it set over an earlier pass's. Adding the field moved the pass version to d83411616cd8, so every stamped card is owed the deep pass once, which is also how the corpus gains it. The first night under it cost $22.38 against $21.74 the night before, at the same token line.

The refusal record

A card whose response a post-gate rejected is left exactly as it stood, so it carries no stamp, so eligibility offers it again the next night. On 2026-09-11 nineteen cards did that in one run โ€” seventeen refused by the grounding judge, two by the alias-vocabulary gate โ€” at two model calls and roughly forty cents each, and the nightly job would have repeated it indefinitely.

The refusal is written down instead, one JSON line per card in enrich-refusals.jsonl beside the run record (Refusals, daemon/internal/enrich/refusals.go). It is keyed by Fingerprint.Key โ€” literally the same function the fingerprint gate calls, passed in rather than reimplemented โ€” so pass version, judgment hash and body decide a standing refusal exactly as they decide an idempotent skip. The refusal pre-gate reads it, seventh in the order, between the fingerprint and the budget: after the fingerprint because that answers the commoner case, and before the budget because the budget counts a call the moment it agrees to one. A refused card comes back when its body changes, when the prompt changes, or when GatesVersion changes โ€” the last being a version over the judge's own question, kept out of PassVersion deliberately so sharpening the judge re-queues the refused set rather than re-owing the deep pass to all eight thousand cards.

Only a rejection that named a claim is recorded (Outcome.RefusedBy, set in pass.go only when the post-gate's error wraps ErrNotEligible). A judge that could not answer, and a judge that rejected without naming a claim, both return a plain error and leave no row โ€” otherwise one lapsed login would blacklist every card that hour touched. A card that later enriches has its row dropped (Refusals.Resolve), and the file is compacted to one row per card at the end of each run.

It is not in the card's own frontmatter, and not a ledger stage. The frontmatter is read by PassDepth, which decides deep from light purely on whether a stamp is there, and a refusal stored beside one would have to be excluded by hand in every reader. The ledger's rebuild wipes a stage and recovers it by walking the corpus, and a refusal has nothing in the corpus to recover from โ€” that absence is the whole reason it needs recording.

The queue

The batch no longer asks for "every unfiled note." It walks the directories the filing contract routes a memory type into, less the derived classes it never owns โ€” today memory/semantic and memory/procedural โ€” read from the contract rather than listed in the binary (enrichQueueDirs, daemon/cmd/agentmd/enrich_run.go:166-199). memory/episodic is never walked: no memory type routes there, and its notes are session traces, not cards. Nor is the skill watchlist offered: it lives in the project space, projects/agentm/_skill-watchlist/, outside every class directory, and its entries are the skill-discovery pipeline's pending-review records. The eligibility pre-gate adds a second refusal beside this: a note whose kind is one of the contract's record_kinds โ€” a session trace, a directory index โ€” is refused as a record rather than a card (Eligibility.IsRecordKind, pregates.go:49-54,80-83). The queue itself is a snapshot taken once per run (enrichQueue, enrich_run.go:201-228) โ€” a note captured mid-night waits for the next run rather than moving the cursor underneath the one in progress. --dry-run sizes the night against this same queue: how many of its notes are owed the deep pass, the light pass, are unchanged at this pass, or unreadable, alongside the budget the run would run under. It counts the cards, the idea cards under personal/ideas/, and the project records apart โ€” one number over any two reads as a card count and is not, and the idea-card count is named only when it is non-zero (see Idea cards below) โ€” and it sizes from the position a cursor resumes at (queueStart, enrich_run.go), which is where the lister pages from, rather than from every path that sorts after --after. --sample draws from the same queue, and the coverage ledger's population (pendingFor) is the same queue too.

Within a cause (see What the night owes, and in what order), the project records queue after the cards, inside the same line (agentm-vault ยง Projects and tasks): enrichRecordQueue walks the index for a project's charter and the notes under its decisions/, designs/ and research/ (IsProjectRecord, daemon/internal/enrich/record.go), and every one it finds is appended to the card queue. No card waits for a record, and the position-based pager (queueAfter, enrich_run.go) walks across the join without knowing where one half ends and the other begins.

A record keeps its own eligibility rule rather than a card's: inside the projects space, ProjectRecord in the pre-gates (pregates.go) decides eligibility outright. A charter or a decisions//designs//research/ note is offered; anything else there โ€” a tracker, a plan, a progress log โ€” is refused directly, never falling through to the kind-names-a-record check a note outside the projects space gets. See Project records below for what happens once one is judged.

Project records

Inside the projects space, three kinds of note are records rather than cards:

  • a project's charter at its root (_index.md, or charter.md once the migration renames it)
  • any note under its decisions/ folder
  • any note under its designs/ or research/ folders

Never a tracker, a plan or a progress log โ€” a task's are the session's, and a nightly writer would race the session that owns them; a project's own tracker.md is generated by the mocs job, which a second writer would race.

A record's frontmatter belongs to whatever wrote it โ€” a research bundle's source_url and fingerprint, a charter's kind โ€” so a pass merges into it (ComposeRecord, record.go) rather than rendering a card over it, the same byte-for-byte-body guarantee Compose gives a card. It sets summary and related, keeps the record's own tags and aliases and adds the pass's after them, sets importance_proposed on a deep pass, and stamps the enriched_by / rules_hash / enriched_at trio. updated stays the author's date (task 190). Every other field the record already carried stays untouched.

No pass adds prose to a record (task 190). The deep pass's dated ## Added by dreaming (YYYY-MM-DD) section is a card's only: on seven designs it had restated links they already carried. A section an earlier pass left on a record stays as it was. Both passes leave the body as it was.

importance itself, title, type, status, filing_confidence, confidence and lifecycle are never written, because a record is not filed. write (main.go) clears the response's Slug before applying a record's composition, so a record is never renamed. The run counts it in verdicts.records rather than in filed_active or below_floor โ€” see the morning note below for where that count surfaces.

Idea cards

personal/ideas/ is the one folder under personal/ the night reaches (agentm-vault part 13; enrich.IdeasDir, daemon/internal/enrich/ideas.go:18). Nothing else there is queued at all. An idea card joins the queue in the cards' tier rather than getting one of its own โ€” enrichIdeasQueue (daemon/cmd/agentmd/enrich_run.go:366) walks the folder flat (a markdown file directly inside it, no dotfile, no subfolder โ€” enrich.IsIdeaCard, ideas.go:27) and appends every match to the card queue enrichServeOrder already built, so it is served oldest-first alongside the class directories' cards rather than ahead of or behind them. The dry-run line names it as its own population only when there are any โ€” , N idea card(s) under personal/ideas/ โ€” so a vault with no folder yet reads exactly as it always did (ideaClause, main.go:1454-1456); the night's run record counts idea cards in verdicts.Ideas, apart from filed_active and below_floor, the same way a project record is counted apart. personal/ideas also joined defaultEmbedScope (daemon/internal/config/config.go), named alone the way standards/voice is beside it โ€” the rest of personal/ stays out of the vector arm's scope, but leaving this one folder out would give the lexical arm an idea card the dense arm cannot see.

What is different about an idea card is what the night may write to it, and that is the stamp's business, not the queue's. Stamp.OperatorFiled (daemon/internal/enrich/render.go:103) is set from the path โ€” stamp.OperatorFiled = enrich.IsIdeaCard(rel) (main.go:1597) โ€” the same way NeverFiles is set for the drop folder. Under it:

  • No filing verdict is written. Compose substitutes KeptFiling (render.go:283) for the ordinary VerdictFor judgment: status and filing_confidence stay exactly what the card carried โ€” a card with neither reads as active at high, because a card in personal/ideas/ is filed by being there, not by a score โ€” and the card never sinks to dormant, however low a response scores.
  • title and type stand over the response. Compose overwrites the model's proposed title/type with the card's own, so a card that stopped reading type: idea because a response proposed a different one would silently drop off Ideas.md โ€” a demotion by another name.
  • No lifecycle is written or defaulted. carryProvenance's defaultLifecycle argument is !s.OperatorFiled (compose.go): a lifecycle the card already happens to carry still travels, because it is the operator's, but the ordinary "no lifecycle of its own starts active" default never fires โ€” an idea card has no aging axis.
  • area and dismissed travel through every rewrite, and nothing writes them. They ride carriedFields (above) and only the carry touches them; losing either would move an idea to the wrong Ideas.md heading, or back onto the live list, with nothing on the card saying why. The final reorder (cardshape.Reorder, called from Compose) places them by the same rule every other card field follows โ€” read where they're read: area right after type/kind, dismissed right before status (ReadOrder, daemon/internal/cardshape/cardshape.go:24; the Python toolkit scripts' card_shape.READ_ORDER matches it field for field).
  • The card is never renamed. newSlug is cleared for an OperatorFiled card the same way it is for a NeverFiles one (main.go:1627-1632): personal/ideas/ is the operator's space, the filename is the one they filed it under, and Ideas.md links it by that name.

What the pass may still write, on either depth: the summary, the tags, related, importance_proposed, and โ€” on a deep pass โ€” its own dated ## Added by dreaming section under the card's body, the same mechanic a card outside personal/ gets. Thinking an idea through is the one thing the design asks the night to do with one; re-grading it is the one thing it must not.

The budget, and what stops a run

DefaultBudget (daemon/internal/enrich/batch.go:149-157) is the operator's line: 2,000,000 tokens a night on either tier (raised from 1,000,000 strong on 2026-09-11) (StrongTokenLine, CheapTokenLine, usage.go:224-227), a 250-call guard (CallGuard, usage.go:228-232) that counts every model call โ€” the faithfulness judge's included โ€” a 3h30m time limit sized to the 02:00-06:00 window, and a fuse of five notes in a row whose model call itself failed (not a note a post-gate rejected, which is the model answering badly rather than not answering at all). All four are read before the next note (stop, batch.go:194-218); the first one a run hits ends it, and BatchReport.StoppedBy names which in words โ€” "the call guard (250 calls)", "the strong-tier token line (2,000,000 tokens)", the time limit, or the fuse โ€” alongside the --after cursor the next run resumes from. --max-calls, --strong-tokens, and --cheap-tokens may lower any of these lines and never raise them (lowerOnly, enrich_run.go:367-380) โ€” nothing run by hand or by schedule is entitled to more than the operator said.

The token line counts what a call adds: its input, its cache writes and its output (Usage.Added, usage.go:41-55, read by overLine, batch.go:300-307). The cached prefix a call re-reads is left out. That prefix is the same on every call, about 37,000 tokens of Claude Code's own baseline, and a card reads it twice: once for the pass, once for its faithfulness judge.

Measured on 2026-09-11, a card processed about 107,000 tokens, 74,000 of them that prefix. When the line counted cache reads, it stopped a night after nine cards of 181, and most of what it counted was that one repeated prefix. The operator ruled that the line count what a call adds and kept the numbers above. At the measured steady state a card adds about 33,000 tokens and costs about $0.39. The run's own totals still count every token processed; see Usage, printed and recorded.

What the call keeps out

Each call is a claude -p subprocess, isolated four ways in Caller.command and Caller.Call (daemon/internal/enrich/model.go:130-229):

Measure How What it keeps out
Hooks --settings '{"disableAllHooks":true}' this project's recall hooks, which would query the daemon and put the vault into the prompt that is rewriting it
MCP servers --strict-mcp-config --mcp-config '{"mcpServers":{}}' every MCP server the operator has configured, with its tool definitions
Working directory a fresh temporary directory per call (agentm-neutral-cwd-*) any CLAUDE.md or AGENTS.md above the daemon's own directory
Transcript --no-session-persistence the note, from ~/.claude/projects/, where Claude Code would save the call as a plaintext transcript outside the vault

A missing measure fails silently, because the call still returns a well-formed answer, so model_test.go asserts each one on the command itself. The two MCP flags travel together: an empty --mcp-config on its own is merged with the configured servers and replaces none of them. The MCP measure also cut most of a call's baseline. Measured on 2026-09-11, one trivial call carried 113,509 input tokens with the operator's servers loaded and 30,783 without.

The transcript measure arrived on 2026-09-20. Until then Claude Code saved every call, in a project directory named for its working directory, so each call left an agentm-neutral-cwd-* directory holding the note in plain text. There were 2,572 of them that day, and purging a note from the vault left those copies behind. Nothing reads them: hooks are off inside the call, so no Stop hook captures its cost, and the night's spend comes from the envelope. reflect.py corpus skips those directories, so it never mines an enrichment prompt as conversation. The ones already saved are left to Claude Code's 30-day retention sweep. The flag works only beside -p, so the test that pins it pins -p too.

Usage, printed and recorded

Every enrichment call now passes --output-format json (Caller.command, model.go:192), and usage.go reads the envelope it gets back: usage (input, cache read, cache write, output tokens) and total_cost_usd. Output that is not the envelope is refused rather than read as zero-cost text โ€” a call the token line cannot count would otherwise turn the budget off silently (parseEnvelope, usage.go:100-124). An error envelope's result text โ€” a lapsed login, an exhausted allowance โ€” is what the failure reports. A shared Meter adds up every call by tier, the pass's and the faithfulness judge's alike (Meter, usage.go:135-212; wired in cmdEnrich, main.go:1402-1407, 1443). The batch prints one line per call as it finishes (callLinePrinter, main.go:1739-1748):

call N ยท <rel ยท depth> ยท <model>/<tier> ยท N tokens (in N ยท cache read N ยท cache write N ยท out N ยท N against the line) ยท $N.NNNN

The judge's call carries faithfulness judge in place of the note, and a call that failed ends with ยท failed. N tokens is everything the call processed, cache reads included (Usage.Tokens, usage.go:35-39), and N against the line is the part the token line counts (Usage.Added). Once the run ends, the batch prints one line per tier, <tier> tier: <reading> of the N-token line, then night: N model call(s) of the N-call guard ยท <reading>, each reading carrying both figures (main.go:1644-1662). The last line the command prints is a JSON object carrying total_cost_usd โ€” the field the runner's spend line reads (see AgentM Runner) โ€” so the report above it and the spend line below it can never disagree. Each run also appends one line to enrich-runs.jsonl in the engine state directory (newEnrichRun/appendEnrichRun, daemon/cmd/agentmd/enrich_run.go:84-96,148-164), which is what the morning note reads for its enrichment row. The record's tokens counts cache reads too, and its per-tier usage keeps the four counts apart.

The tier table, and the audit that earns a cheap tier

Each depth routes through the same tier table agentmd tiers reads: the deep pass is the table's classify-unfiled job, the light pass is summarize (enrichJobs, daemon/cmd/agentmd/tiers.go:132-135) โ€” names from when the table's jobs were first named for dreaming, not enrichment. enrichRouter reads the table once per run, so a qualification written mid-night takes effect from the next night rather than the next note; a table that will not load routes every depth strong, the table's own answer to an unknown (enrichRouter, tiers.go:143-157). Every job routes strong until an audit says otherwise, and the strong model defaults to opus when daemon.enrich_model names none (DefaultStrongModel, model.go; strongModel, tiers.go:161-166) โ€” session 3 ruled the deep pass strong, Opus (agentm-vault ยง Dreaming, Q4). agentmd tiers resolves the same default, so the plain question answers about the run that would happen.

The light pass is the job the audit measures first, because it may move a card's summary, tags, related and confidence and nothing the daemon ranks by. agentmd tiers --audit --job summarize --cheap MODEL (cmdTiersAudit, daemon/cmd/agentmd/tiers_audit.go) draws a seeded sample of the cards the current pass has already judged on the strong tier โ€” those stamped enriched_by with the running pass version under the contract's class directories, walked by the same queue the batch uses and filtered through its three free gates (auditPool) โ€” and, for each card, makes three calls through the enrichment Caller: the cheap tier and the strong tier answer the light-pass prompt, rendered exactly as the batch renders it, neighbours included; then the judge is shown the card, the strong answer as the reference and the cheap answer on trial, and returns {"agree": bool, "reason": "one sentence"}. Agreement is a model's judgment, not a string comparison (the operator's ruling of 2026-09-11): "agree" means the two answers would file and rank the card the same way โ€” the same meaning in the summary, tags that name the same things, a related set that overlaps on the neighbours that matter, confidence on the same side of the filing floor โ€” and not the same bytes (auditJudgeInstructions). The judge is claude-fable-5-1 unless --judge names another; it may not be the cheap model on trial.

The strong tier is asked again rather than read from disk. The stamped card holds a deep-shape answer that went through the post-gates, Compose and CarryProvenance, so a field the gate stripped or the carry restored would read as the strong tier's judgment when it was the gate's. The comparison is between two models on one prompt, and both are asked it.

The cheap model comes from --cheap, else daemon.cheap_model, and the command refuses to run with neither named rather than defaulting one: the choice is the operator's at invocation (planTierAudit). A pinned job is refused before anything is drawn (tiers.CanAudit, audit.go:227).

The command projects before it spends. Without --yes it prints the pool, the draw and its seed, the call count (three per card) and an estimated cost at the strong tier's measured cost per call โ€” read from the strong tier's usage in enrich-runs.jsonl when it holds calls, else the 2026-09-11 figures of about 18,000 tokens and $0.20 per call โ€” and stops; --json emits the same projection as one object. With --yes it runs, prints a line per call as agentmd enrich does, then the report: judged, agreed, the rate, every disagreement with the judge's reason, usage per tier with the judge on its own line, and the verdict. A verdict that meets the pre-registered bar โ€” 90% agreement over at least 25 judged samples (tiers.MinAgreement, tiers.MinSamples) โ€” is saved to model-tiers.json in the engine state directory with the judge named on the qualification, and enrichRouter routes summarize cheap from the next run; one that does not saves nothing, and the report's why says what fell short. A tier or judge that could not be reached takes its sample out of the rate rather than counting either way, and the report names each such sample with what failed and why, up to five โ€” a lapsed login fails every call the same way, and the first live run could only say a tier could not be reached; five excluded samples in a row stop the run, the batch's own fuse (tiers.Audit, audit.go:260; tiers.MaxFailuresInARow). The per-call line carries a failed call's reason for the same reason. Every run, saved or not, appends one line to tier-audits.jsonl beside enrich-runs.jsonl (appendTierAudit), for the morning note to read later. --samples defaults to 30 โ€” the floor plus a margin for excluded calls โ€” and --seed S redraws the same cards.

Sequential, decided

RunBatch does not fan out, even though daemon.enrich_concurrency still bounds Pass.Run (batch.go:159-169). The cursor stays one answer โ€” what a deferred run resumes with --after โ€” and the token line and call guard are read before each note, so a sequential run overshoots the operator's line by at most the one note in flight, where N in flight would overshoot by N. A steady-state night is under fifty notes, which one at a time finishes inside the 02:00-06:00 window with hours to spare; only a prompt change re-owes the whole corpus, and that batch runs by hand.

The prompt is one string with two shapes (agentm-vault ยง Dreaming), the shape named on the last line of the message. Besides the type enum and the voice specification, BuildPrompt (daemon/internal/enrich/prompt.go:129-174) renders two more inputs: the contract's importance rubric โ€” the prose under ## Importance in standards/storage-rules.md, read at call time via Rules.ImportanceRubric (daemon/internal/rules/rules.go:153-159, proseSection, rules.go:168-190) โ€” and up to five neighbours, each an id, a title and a summary. Both are inputs like the card itself and sit outside the prompt hash: editing the rubric changes what the next deep pass proposes without re-owing a single already-judged card. The neighbours come from the daemon's own lexical search over the card's title and tags, over-fetching the top 40 (widened from 20 โ€” agentm-vault plan 09) and excluding the card itself, a derived class, a space no background model may read, and any neighbour whose title or summary carries a credential shape.

When the note belongs to a project โ€” a record by its path, a card by its project: โ€” that project's records then move to the front of what survives, keeping the search's order within each half, before the list is cut to five. A project's own decisions and research lead the neighbours of its cards and records, ahead of anything else the search turned up (enrichNeighbours, projectFirst, daemon/cmd/agentmd/enrich_run.go).

What it does once it runs: add to a card rather than rewrite it. The card's own text is the evidence and stays exactly where the session left it; the deep pass's judgment lands in the frontmatter above it and, for prose worth adding, in a dated ## Added by dreaming (YYYY-MM-DD) section below it (Compose, daemon/internal/enrich/compose.go:136-185) โ€” any heading the model wrote inside that section steps down to ###, so the section's own boundary stays the only ## it contains and a later deep pass can find and replace just its own section, keeping anything the operator wrote below it. Compose refuses to write a composition that would change one byte of what the session wrote.

The response may carry title, slug, type, summary, tags, aliases, related, importance_proposed, body and confidence (Response, daemon/internal/enrich/schema.go:33-64). altitude is gone from the shape entirely โ€” the card backfill (agentm-vault plan 06) retired the field from the card itself, so capture no longer writes it either, and enrichment neither reads nor writes it (see the rank penalty above). why is never asked for, and a response that offers one anyway has it stripped before the strict decode rather than failing the whole call over a field that was never going to land (strippedFields, schema.go:66-76). related may only name ids from the neighbours the prompt offered โ€” Compose keeps only those (relatedIDs, compose.go:104-124) and renders them as the quoted wikilink flow list the capture door already writes; an id the model invented is silently dropped rather than refusing the note. An empty body is a fine answer, and the usual one (Schema.Validate, schema.go:157-177). The light pass only ever moves summary, tags, related and confidence; it moves title and type at or above the floor and never proposes importance; and it leaves the body exactly as it was.

The prompt asks for body only when the card and a neighbour, between them, already state something the card alone does not (prompt.go:73-80). Every sentence must be traceable to a sentence in one of them, with nothing inferred, which is the same bar the grounding judge below enforces. The first supervised batch (2026-09-11) ran under an earlier wording that asked for a connection the card does not make, and the judge refused three cards of four on sentences the model had been invited to write.

aliases stay empty unless the note itself contains the other name: an acronym it spells out, a compound identifier it carries, or a name it says the thing is also called (aliasRuleBatch, prompt.go:104-109). The rule names two cases that do not count, a rewording of the title and the filename; the fourth card that first batch refused had offered its filename. The alias post-gate refuses a write whose alias the note cannot account for (Aliases, daemon/internal/enrich/aliases.go:31). Under the new wording the next three cards were all written, and none was refused.

VerdictFor (daemon/internal/enrich/render.go:203-223) is what a judgment decides about where a card stands. At or above the contract's floor a card lands active at filing_confidence: high. Below it the card stays unfiled โ€” fully indexed, rank-penalized, and listed for you in Review flagged memories rather than dropped. A second verdict below the floor โ€” a card that already carried an enrichment stamp and was already unfiled โ€” sinks it to lifecycle: dormant with a lifecycle_since date, journaled and named in the morning note; a pinned card and the two rule types, preference and convention, never sink, and stay unfiled and listed instead. Neither does a card whose lifecycle the operator set. cmdEnrich reads once, per batch, every note whose last lifecycle-journal line is the operator's (enrichHandStates, daemon/cmd/agentmd/enrich_run.go:78, over dreaming.OperatorStates) and sets Stamp.HandLifecycle on each of them (main.go:1626 and :1645); Compose then clears the sink (compose.go:172-175), and the verdict itself stands, so the card stays unfiled at low confidence and listed. The sink that does happen is journaled by enrichment itself: journalSink (enrich_run.go:90, called after the write at main.go:1724) appends one line to the lifecycle journal as actor policy, reason enrichment: a second verdict below the floor, under the note's memory-root-relative path, and writes nothing for a card that was already dormant. A failure to write the line is reported on stderr and does not undo the write. Without the line the lifecycle pass found a state no writer had journaled and read it as an edit of yours (see the lifecycle journal's actors). FilingConfidenceFor stamps the categorical twin of the confidence number every other writer already shares, two bands on purpose: the floor is the one judgment this pass makes about its own number, and a third band would be a threshold nobody measured. The needs-review reading selects on filing_confidence without knowing what floor produced it.

CarryProvenance (daemon/internal/enrich/carry.go:89) copies every capture-record and review-mark field the composed note doesn't already set โ€” source, source_id, source_url, source_fetched, lifecycle, lifecycle_since, superseded_by, supersedes, promoted_at, promoted_to, derived_from, created, via, surface, instructions, review_flags, related, trust, why, project, task, importance, importance_proposed, slug, fingerprint, occurrences, area, dismissed, and, since task 182, source_hash, source_version, consolidated_into and instrument (carriedFields, carry.go) โ€” from the note as it stood before enrichment. A census test (TestEveryCardFieldIsWrittenCarriedOrLetGoOnPurpose) holds every field of the card shape against the pass's own fields, this list and notCarried (each entry with its reason), so a writer's new field fails a test rather than being erased by the next rewrite: until task 182 a rewrite dropped crystallize's stamp and the same-source update's fingerprint. area and dismissed are an idea card's own two fields (agentm-vault part 13); see Idea cards below for what makes them different from the rest of the list. filing_confidence is deliberately excluded from that list: the pass re-judges it, which is how an unfiled capture actually clears the needs-review reading rather than carrying its old low stamp forward unread. why rides the carry list and is never written by the pass itself โ€” no pass was in the room, so a why it invented would read exactly like a real one.

The card backfill (agentm-vault plan 06) retired group, always_load, the mining_* trio and excerpt_edges_unverified from the card, so the carry lets them go rather than keep restoring retired fields onto a rewrite; captured no longer travels beside created either โ€” it folds into it, the earlier of the two days winning (earlierDate, carry.go:104). The migration's backfilled: list rides along too, trimmed (backfilledKept, carry.go:134): it keeps naming a field the backfill stamped only while the rewritten note still carries that field and the pass did not write it itself. A title or a filing_confidence the pass writes leaves the list; a carried trust or created stays on it. So a rewritten card still shows which of its fields the backfill stamped rather than a writer that knew.

Two guards sit beside the plain carry. carryImportance (carry.go:163-174) keeps an importance the operator edited: capture writes importance and importance_proposed equal, so a note where they differ is a note someone edited by hand, and that value survives a pass untouched โ€” the pass's own reading lands in importance_proposed instead. carryEvidence (carry.go:182-188) restores the note's ## Evidence block verbatim on the rare composition that would otherwise drop it โ€” insurance beside the byte-for-byte guarantee above, since the block quotes the note's source material and the pass was never entitled to rewrite or drop it.

A note with no lifecycle of its own starts active โ€” an enriched note is an auto-filed note either way, the same default a fresh write gets. An idea card is the one exception: it has no aging axis, so none is written onto it either โ€” see Idea cards below. main.go's cmdEnrich is the one caller: it reads the note as it stood, calls Compose with the response, the stamp, the pass depth and the neighbours offered, and Compose calls carryProvenance โ€” the unexported function CarryProvenance above wraps with the lifecycle default fixed true โ€” on the frontmatter it renders before the write applies (main.go:1581-1638).

Two gates retired with the rewrite they existed to check. The token-preservation post-gate (tokens.go) held a rewrite to keep every identifier its source had; the deep pass no longer rewrites, so Compose's byte-for-byte refusal above is the stronger form of what it checked. The faithfulness judge's completeness half โ€” whether a rewrite left something out, sampled and scored for the scorecard โ€” retired for the same reason: the card's own text is carried byte for byte now, so nothing of it can go missing. What the judge still checks, on every note, is the other direction: whether the title, the summary, or the added prose asserts anything the card and its neighbours did not (Grounding, daemon/internal/enrich/grounding.go).

The judge's source is the card the enricher was shown, not a shorter version of it. It used to be the body alone, with the frontmatter stripped, while the enricher was handed the whole card and told every claim must trace to it โ€” so a proposal naming the card's own source_id, lifecycle or captured date was refused for asserting what the judge could not see. Three of the nineteen refusals on 2026-09-11 were exactly that. judgeSource now keeps the frontmatter, less the fields the pass writes itself (passWrittenFields, render.go): on a card the pass has already enriched, its title, summary and tags are its previous answer, and handing those back as source would let one pass's hallucination ground the next pass's restatement of it. A card with no enrichment stamp has no previous pass, so all of its frontmatter is the capture's and all of it is evidence.

daemon.enrich_sample_rate, which governed how often the retired half sampled, is no longer read; a config that still carries the key is harmless.

Crystallize, the weekly phase

The second job that spends, and the only writer of a crystallized note.

A crystallized note is a hard-learned lesson: something that proved itself over time, across more than one task or more than one card, that the operator would keep after every card it came from has aged out. A session closing a task has the task in context but not the time axis, and a nightly pass over one card has neither โ€” so this runs weekly, in the night's window, against its own budget line.

Command agentmd crystallize
Scheduled by templates/jobs/crystallize-weekly.yaml โ€” weekly, window: "02:00-06:00", order: 3, budget: tokens: 200000, registered and off
Switch daemon.crystallize_enabled (agentm_config.py --crystallize-enabled true); --yes for one pass, --dry-run needs neither
Tier strong, pinned by the tier table without audit โ€” a bad lesson lands in the decay-exempt layer, where nothing ages it out
Writes <vault>/agent/memory/crystallized/<subject>.md, a kind: crystallized record, plus a consolidated_into stamp on each source card
Record <engine state dir>/crystallize-runs.jsonl, one JSON line per run, beside enrich-runs.jsonl

The phase reads and writes under the vault's memory root, wherever the job starts (task 177; before it, a job started in scripts/ read ./agent/... and found no cards). A lesson is a kind: crystallized record: it carries consolidated_from, lifecycle: pinned and source_id: crystallize, and never the card's why, filing_confidence or trust โ€” the phase's reason is written in the body, under the lesson, as Why it is a lesson:. Stamping a source card puts its frontmatter back in the card's order. The first run, funded to finish on 2026-09-28, made 115 calls on the strong tier, wrote 42 lessons, had 71 declined and 2 time out, and used 2,898,915 tokens ($32.31). The weekly job is registered from this template with enabled: true and a provisional budget: tokens: 4348373 โ€” one and a half times that run โ€” until the operator sets the lasting line from the first weekly runs' average. A declined subject is not remembered, so each weekly run asks the declined ones again until it has written its five lessons.

What it reads. The Outcomes of closed tasks (projects/*/tasks/*/tracker.md and projects/*/completed/tasks/*/tracker.md, where a closed task's folder moves two weeks after it closes, and each project's own tracker, status: done with a written ## Outcome), the ## Candidates lines the session traces accumulate, and the cards in memory/semantic, memory/procedural and memory/episodic. A card already stamped consolidated_into is not read again: it has taught its lesson.

The bar, before any model is asked. A subject is a term several sources name โ€” their tags, the things they put in backticks, the notes they wikilink โ€” and a subject clears the bar when at least three sources name it, across at least two sessions, at least seven days apart. The counting is arithmetic and lives in code; the recognition of what the recurrence taught is the model's, one call per cluster that cleared the bar. A run that finds nothing makes no call and costs nothing. Clusters whose source sets are contained in a larger one are dropped, so one recurrence writes one lesson rather than one per word inside it.

The bar again, on the lesson (task 190). The three are distinct notes: two ## Candidates lines of one trace count once, and so do cards that share a source_url, slices of one captured page (task 191; Source.Origin, folded to the page's address without its scheme, www., trailing slash or fragment). And after the model names the sources its lesson rests on, the bar runs again on those alone; a lesson under it is reported as a near miss (the lesson rests on โ€ฆ) and not written. A source that has taught a lesson does not teach another. A card carries consolidated_into, and a tracker or a trace line, which cannot, is kept in <engine state dir>/crystallize-consumed.json, together with every lesson's own consolidated_from, keyed so a tracker keeps its key when its task moves to completed/tasks/ and a trace line is consumed on its own, not its whole trace. A cluster whose sources have all taught a lesson is not proposed, an arc's synthesis excepted, so a lesson you delete is not minted again from the same closed tasks. A draft resting mostly on one lesson's sources is not written either: its new cards name that lesson in related, and the run record lists it under linked. A draft the model names like an existing lesson is refused rather than written over it.

The model may refuse. The prompt offers a {"skip": "..."} shape and says plainly that skipping is the safe answer, because three notes sharing a word are not a lesson and a phase that wrote one per cluster would fill the decay-exempt layer with vocabulary.

What a lesson carries. The card shape, with lifecycle: pinned, source: crystallize, trust: derived, consolidated_from naming every source, why written from the recurrence the phase found, and project: when every source shares one. Sources whose file name repeats across the vault โ€” every tracker.md โ€” are linked by path with the directory as the alias, so the link resolves to one file.

What gets stamped. Only cards, and only the ones the model names as what the lesson rests on. Each gains the lesson in consolidated_into โ€” one link, or a flow list when several lessons rest on the card (task 182: a run's clusters overlap). A stamped card ranks at x0.30 in both arms (note.ClassConsolidated and recall._stamp_demotion), but only beside a lesson it names: each arm demotes it only when one of those lessons is in the arm's own top k (50 per arm in hybrid), so it cannot crowd its lesson out and keeps its rank when no lesson answers the query. The lessons are read from the card at query time.

Recheck, re-stamp, and released sources. agentmd crystallize -recheck [-lessons a,b] asks each lesson which of its stamped cards it is true of and writes a manifest releasing the rest one lesson at a time; a released card loses the stamp but stays in the lesson's consolidated_from and "What taught it" as provenance, named in the lesson's released: list. agentmd crystallize -restamp (no model call) writes a manifest giving each card back the lessons that still list it, skipping released ones, so the two cannot cycle. Both manifests apply through agentmdream apply -manifest. Trackers and traces are never stamped โ€” a tracker is the operator's living head with a locked schema, and a trace is one session's record from which several lessons may draw.

The arc synthesis

When an arc of work closes, the closing session marks it on the project's own tracker with arc_closed: โ€” one name or a list โ€” and the phase's next run writes that arc's synthesis at memory/crystallized/<project>-<arc>.md from every Outcome in the project, asked a different question: not what recurred, but what the whole of it turned out to be about. The mark accumulates rather than resetting, so a second closed arc adds a line. The bar still applies: an arc of two tasks in a week has nothing to synthesise that its Outcomes do not already say, and the refusal is reported with arc: in front of it so the session that marked it can find out why. A synthesis is written once.

What it costs, and what the runner does about it

crystallize is the second job in the fleet that declares a budget:. That changed how the runner's daily ceiling works: at the old $5 default the enrichment batch's ~$22 night put the fleet over before this job was considered, so whichever paid job carried the higher order was refused every night. The ceiling is now $30 โ€” the sum of what the night's registered paid jobs legitimately spend, with headroom โ€” and the repeated-run guard became a rule of its own, reported as budget-repeat. See agentm-runner, 2026-09-18.

The morning note gives the phase its own spend line and a per-job line beside enrichment's deep and light passes, and names every lesson written in the past seven days under what needs you โ€” the re-audit trigger for this phase is "any crystallized note you would not keep", and that cannot be read from a count.

Restated rules, the nightly merge

A convention, preference or workflow stated twice merges into one note, keeping the newer wording (task 178, step 8; the operator's ruling 6c). Embedding similarity alone cannot tell a rule restated from two neighbouring rules: on the live corpus the known restated pair scored 0.794 and ranked ninth, below eight pairs of distinct rules. So the similarity only shortlists, and the strong tier judges each shortlisted pair.

Command agentmd restated
Scheduled by templates/jobs/restated-nightly.yaml โ€” daily, window: "02:00-06:00", order: 2 (after the dreaming binary, before the Python cycle), budget: tokens: 400000, registered and off
Switch daemon.restated_merge_enabled (agentm_config.py --restated-merge-enabled true); off, -apply judges and reports; -yes merges once; -dry-run needs neither and spends nothing
Shortlist every pair of active rule cards whose best chunk vectors reach 0.74 (index.VectorsFor)
Judge the tier table's fuzzy-merge job โ€” strong until an audit qualifies a cheap tier; a verdict is kept against both bodies in <engine state dir>/dreaming/restated-verdicts.json, so a pair is judged once until one of them changes
Merge through the dreaming journal (dreaming.ApplyManifest): the older note keeps its path, links and slug, takes the newer summary and body, keeps its importance and names the newer in supersedes; the newer takes lifecycle: superseded, lifecycle_since and superseded_by. A note is in one merge a run.
Review every shortlisted pair that did not merge, with the judge's reason, in <engine state dir>/dreaming/restated-review.json; the needs-review map lists them under Restated rules?
Record <engine state dir>/restated-runs.jsonl, beside the crystallize record; the morning note names each merge and gives the judge's spend its own line

The first judged run (2026-09-29, 25 calls, $6.33) called the known pair the same and the other 24 different, each with a reason.

The dreaming binary, agentmdream

The second Go binary the design names, built beside agentmd by install.sh. Where agentmd stays resident, agentmdream runs one pass and exits โ€” under a dual gate: enough time has to have passed since the last applying pass (-every; the flag itself still defaults to 168h if left unset, but the scheduled job now passes -every 12h explicitly โ€” see below) and something has to have happened since (captures in the index, genuine recalls in the recall history). Only an applying pass (-apply) moves that clock โ€” plus the class populations the trend compares against and the pass version the re-classification diff keys on; a report-only pass records its own stamp instead and leaves all three where the last applying pass put them, so running one by hand never pushes the next real pass back (agentm-vault plan 04, task 4 โ€” three hand-run report passes on 2026-09-05/06 had each reset the clock, and the maps and the copy collapse sat frozen for a week behind them). Twelve hours is the number the scheduled job actually passes; the design's own text still says twenty-four. The runner's own 02:00โ€“06:00 window is what actually makes the pass run once a night, so -every only has to clear the two gaps a bare day could be confused by. The binary starts after the enrichment batch, which takes anywhere from minutes to its three-and-a-half-hour limit, so two nights' passes can start as little as about nineteen hours apart โ€” a literal -every 24h would skip whichever night started earlier than the one before. Twelve hours is shorter than that gap and longer than any one night, so no night is skipped and a hand-run -apply in the afternoon is still refused. A second start while one is already running is refused (exit 3) by a lock compatible with vault_lock.py (mkdir + heartbeat + a stale window, pid takeover of a dead holder). Every mutation is journaled โ€” intent, then applied, then skipped โ€” fsynced before it happens, so a crash resumes from that journal by hash instead of losing or repeating work. Report-only by default; -apply makes the writes.

Binary agentmdream โ€” built beside agentmd by install.sh
Subcommands run, status, journal, ideas, move-tasks, apply, entities, version
Gate elapsed โ‰ฅ -every since the last applying pass (flag default 168h; the scheduled job passes 12h) and activity since then
Lock mkdir + heartbeat, stale-window pid takeover; a second start exits 3
Journal fsynced intent โ†’ applied โ†’ skipped, hash-checked resume after a crash
Default mode report-only (decides and prints, and records its own LastReport stamp without moving the gate's clock); -apply writes and moves the clock
Triggered by templates/jobs/dreaming.yaml, through the runner
Last-pass report <engine state dir>/dreaming/last-report.json, left by every completed pass; a refused or not-due start leaves the previous file โ€” the morning note's What ran section reads it
"$HOME/.local/bin/agentmdream" run -every 12h -apply   # the applying pass the runner schedules nightly
agentmdream status                                       # the last pass, the gate's answer now, the lock
agentmdream journal -tail 20                              # the mutation journal, newest last
agentmdream ideas                                         # print Ideas.md as the night would rebuild it (dry run)
agentmdream move-tasks                                    # plan the closed-task moves and print them; -apply moves
agentmdream apply -manifest FILE                          # check a one-time pass's manifest; -apply makes it
agentmdream entities                                      # plan the entity pages and their map; -apply writes them

entities runs the entity builder on its own (task 179; BuildEntities, daemon/internal/dreaming/entities.go): the supervised first build, and a rebuild after the extractor changes. It plans and prints the page counts by type and every page it would remove; -apply writes the pages and the entity map through the journal under the dreaming lock, as the night would; -json emits the plan.

run's other flags: -force (skip the gate and run now), -pace <duration> (sleep between mutations, for tests), -cap <n> (the automatic-demotion cap for this pass), -reclassify (run the sampled re-classification diff this pass even if the filing-pass version hasn't changed), -task-cap <n> (the most closed task folders this pass moves; 0 is the contract's demotion_cap, counted in folders), -json (emit the report as JSON). The morning note shows a pass written since the night window opened as a table, one row per job; see the morning note.

apply makes a one-time pass's manifest (task 178; cmdApply, daemon/internal/dreaming/manifest.go). A pass planned in Python โ€” scripts/migrate/plan_c_manifests.py traces|chunks|ideas|labels โ€” writes a manifest of in-place rewrites under agent/diagnostics/migrations/plan-c/: each act names a note (relative to the memory root; ../ reaches the rest of the vault), the sha256 it was read at, and the bytes to write. With no flags apply checks every act and writes nothing; -apply settles any intents a crashed pass left, then journals and makes every act under the dreaming lock, with a lifecycle-journal line for each supersede. A manifest any of whose notes changed or went since it was planned is refused whole (exit 4), and an act that reaches past the vault, into .git/ or .obsidian/, is refused.

move-tasks runs the closed-task mover on its own (task 177; cmdMoveTasks, daemon/cmd/agentmdream/main.go): the supervised first move of the backlog, and a rehearsal against a copy of the vault. With no flags it plans and prints what it would move, holds and repair, and writes nothing โ€” not even the intents a crashed pass left pending, which it only counts. -apply settles those intents, then moves, journaled and under the dreaming lock, whether or not daemon.task_mover_enabled is on; -cap <n> bounds the folders (0 is the contract's demotion_cap); -json emits the plan. Pointed at any vault other than the configured one it refuses unless AGENTM_STATE_DIR names a state directory of its own, because -vault and $MEMORY_ROOT move the vault and not the journal and sidecars, and a copy's moves journaled in the live state would be replayed by the next live night.

ideas's own flags (agentm-vault part 13; cmdIdeas, daemon/cmd/agentmdream/main.go:139): with none, it prints Ideas.md exactly as a rebuild would write it, under the file's own existing head, and touches nothing. -intro <file> builds the head from that file's text instead โ€” the one-time adoption of an operator's existing, hand-kept Ideas.md, read only once and copied byte for byte into every later rebuild. -write makes the write for real, journaled and under this binary's own lock, so -intro <file> -write is the whole adoption in one command; without -write the command only ever prints. -json emits the plan as JSON instead of the rendering. A file with no <!-- ideas:intro:start --> / :end marker pair and no -intro given prints nothing to write and exits 4 โ€” there is no operator text yet to keep, so there is no rewrite to make.

How the journal lands a move (task 180; moveNote, daemon/internal/dreaming/journal.go:471). A move renames the file with os.Rename, once the source hashes as Before and the destination is free. The note keeps its inode and, through Google Drive for Desktop, which mirrors the vault, its Drive file ID. Before task 180 a move wrote the note at its new path and removed the old one, which Drive read as a new file plus a trashed one.

When After differs from Before (a link repair, a lifecycle restamp), the renamed file is checked against Before again and rewritten in place with writeAtomic. If an edit landed between the hash check and the rename, the journal puts the note back where it was and skips the move as a conflict. writeAtomic writes a temp file and renames it over the note, so a rewritten note gets a new inode; Drive has been seen to treat that as an update to the same file.

A rename that fails across devices (EXDEV, or ERROR_NOT_SAME_DEVICE on Windows) falls back to write-then-remove and says so in the applied line's note. Any other failed rename, such as a file another process holds open on Windows, skips the move, names the error and leaves the source untouched; a later night plans it again.

The intent line is still fsynced before anything on disk changes, and a deletion still keeps its bytes. A note renamed without a rewrite keeps its old modification time; nothing that reads a moved note needs a fresh one. After a crash, Resolve (journal.go:225) reads a move like this:

Source Destination Resume does
gone at After records it applied, found on resume
at Before absent renames it, then rewrites in place if After differs
gone at Before, After differs rewrites the destination at After: the rename landed and the rewrite did not
at Before at After removes the source: a move journaled before task 180 crashed between its copy and its delete
anything else skips it as a conflict

Its jobs, in order

Job What it does
lifecycle A memory silent past dormant_after_days (365) sinks to dormant. A dormant memory returns to active only on a genuine recall after its lifecycle_since โ€” the access sidecar's record, never the note's own updated (recalledSince, daemon/internal/dreaming/lifecycle.go:820); one with no lifecycle_since returns on any recall. A dormant memory past archive_after_days (1825) moves to archive/memory/<class>/, stamped archived (lifecycle.go:493). An archived one past forget_after_days (2555) that has also been in the archive for the wait between the two lines is deleted (lifecycle.go:448). Your edit of lifecycle is found, journaled as yours and restarts the clock, and so is a note you move back out of the archive; see the lifecycle journal's actors. A crystallized lesson is retired to archive/memory/crystallized/, stamped archived and journaled, once following its superseded_by (through crystallized/ and the archive) reaches a live lesson, one that is not itself and not superseded; a cycle, a lesson naming itself or a missing link retires nothing, and neither does a pinned lesson or one the operator moved back out of the archive (a journal line of theirs undid an archived, read over the whole journal by OperatorUndidArchive, so a later edit of theirs does not lift it, and no copy is left in the archive: a fresh lesson written at a retired one's freed path is journaled the same way but leaves the retired copy there, and is not held). A successor written with CRLF line endings is read as LF. The dreaming facet lists it under Superseded lessons retired (task 191, #895; liveSuccessor).
copies Content-identical families collapse into the earliest note; every other copy is marked lifecycle: superseded + superseded_by: <canonical>, never deleted; status is untouched.
refile A memory whose type: the contract routes elsewhere moves under the same basename; a stale near-duplicate flag whose twin is gone gets cleared. The memory archive is left alone: a note in archive/memory/<class>/ belongs to the lifecycle axis, not to a misfiling (daemon/internal/dreaming/refile.go, task 187).
promote Reads every session trace's ## Captured and ## Candidates sections (agentm-vault plan 04, task 4). A session with no operator turn โ€” a Desktop scheduled task, opening with its <scheduled-task> wrapper, or a headless claude -p run โ€” writes no trace at all since task 190 (has_operator_turn, harness/skills/memory/scripts/episodic_trace.py; a typed message or slash command is a turn, a reply to a scheduled run keeps its trace); the recall hook's own ## Recalled list is basenames, not judgments, and promote no longer reads it. A ## Candidates line three or more distinct traces carry becomes a semantic candidate at memory/semantic/candidate-<first-words>.md โ€” status: unfiled, no why, derived_from naming the traces โ€” for the next enrichment batch to judge; capped at 10 new candidates a pass. A ## Captured link three traces carry already has a card and is only reported. Nothing is ever written to crystallized/, which holds model syntheses made at a task's close or on request.
entities The entity builder (task 179; PlanEntities, daemon/internal/dreaming/entities.go, and people_pages.go), free. Runs after tasks and projects (task 190), and is a page's one writer: the mover's link repair leaves memory/entities/ alone, and the builder lists a note moved tonight where it sits even before the index catches up. Writes memory/entities/{repos,issues,releases}/<slug>.md for every repository that entity_min_mentions (2) distinct sources mention, and every qualified issue and release that entity_min_sources_numbered (3) do โ€” a note, or a task's folder (โ€ฆ/tasks/<task>/, open or completed), whose notes count once together (task 186) โ€” and that the repository's clone allows: where a project's code_paths holds a git checkout of its repository (projectbind.Clones), an issue page needs a number no higher than the highest #N the clone's commit subjects cite on any ref, and a release page needs a version among the clone's tags (entityrepos.go; read once a build with git log --all --format=%s and git tag --list, and a repository with no clone here is not checked). A repository named in a project's former_names folds into the current one (projectbind.FormerNames): its references count toward the current repository's pages, and the repository page lists the old name among its aliases. It also writes memory/entities/people/<slug>.md for every person named in person_min_shared_work (2) distinct pieces of shared work โ€” a task's plan, progress or tracker, a decisions/ note, a project tracker, a calendar note or a meeting note. The people are the names enrichment wrote into people:, filed under standards/people/aliases.md, plus the table's own; where each appears is found by matching their names across every indexed note: a one-word name only as written, and never when it is a month, a weekday or a common word that is also a name. Two spellings that share a page's name ("Jean-Luc Picard", "jean luc picard") are one person. Each page is a kind: entity-profile record listing its notes by project, then by space, newest first; a person's lists shared work first. Superseded notes, the derived classes, diagnostics/, generated maps, the night's own calendar records and standards/people/ never count. A page keeps its created, so an unchanged corpus writes nothing; a builder page under its bar is removed through the journal, which keeps its bytes; a hand-written note in entities/ is never touched, and a page whose path one holds is listed as held rather than written. When a note the index holds cannot be read, the people pages stand as they were that night and the morning's facet says so; a builder that cannot plan at all leaves the pages and the entity map alone. The vault root is the configured one. daemon.people_email_evidence_enabled (off) is where a future mail source counts a thread with you as shared work. The mocs job lists the pages in mocs/moc-entities.md. A mention is dated by the note's own updated (skipping one on the day enrichment last rewrote it), else its created, else a captured the note wrote itself โ€” never the file's modification day, which a move sets (task 182); a note with none is listed undated.
calendar Writes the daily register's weekly and monthly reviews, and, beside each year that has a facet note, that year's generated map (moc-calendar-YYYY.md, agentm-vault plan 07). A period gets a review only when one of its facet notes holds a timed entry or prose beyond the daily template. A day of prose alone is listed without an entry count.
mocs A page per memory type, <type>.md, once it holds moc_min_members (5) live notes, the daemon's self-probe never among them. A page whose regeneration differs from the file in its updated line alone is not rewritten (task 190) โ€” past moc_split_at (40) it paginates inside itself, a section per 40 members, never into a second file (agentm-vault plan 07 retired the old numbered pages). When the type falls below moc_min_members, the job removes its page through the journal. moc-memory.md lists every type โ€” a type at or past the floor by its page's link, a smaller one with its notes in full โ€” and moc-root.md lists every area's map, both regenerated alongside it. A type's page is flagged stale: true past moc_stale_after_days (90). Over the projects space (agentm-vault plan 09) the job also writes a map at each project's root, moc-<slug>.md: the project's tasks, in flight first by importance and the rest folded under the day they closed, then its decisions and designs, newest created first, and its research as a count per bundle. Projects/moc-tasks.md lists every project's tasks once any tracker exists, and Projects/moc-projects.md lists every project folder, leaving out _archive/ and completed/, with its charter's What line (task 176 step 6 dropped the earlier fallback to the tracker's first State line โ€” the map's own task count already says where the project stands, so nothing here reads the tracker any more). A task is a tracker in either layout: tasks/<task>/tracker.md, or tracker-<task>.md beside a flat plan pair. The same job (PlanProjectTrackers, daemon/internal/dreaming/projecttrackers.go:199, wired in right after the project maps at daemon/internal/dreaming/run.go:275-282) also (re)generates each project's own tracker.md from its task trackers, in the tracker's one schema (scripts/tracker.py): frontmatter title/status from project.yaml (falling back to the charter's title and active); an Objective of the charter's What line plus a line saying the file is generated; a State of N open ยท M done then the open tasks grouped by the design that governs them, and the done ones collapsed to one line naming the latest to close; a Next naming the first open task; and an Outcome only when project.yaml marks the project finished. No session writes a project's tracker any more, and a night over an unchanged one writes nothing. The root map lists a Projects/moc-*.md map on the night it is first written.
ideas Rebuilds Ideas.md over the idea cards in personal/ideas/ (agentm-vault part 13; PlanIdeas, daemon/internal/dreaming/ideas.go:254): one ## <area> heading per group name the cards actually carry, alphabetical, cards with no area: last under "no group yet"; one - [[slug|title]] โ€” summary line per idea, title order within its heading; the dismissed ideas (a dismissed: date on the card) in one collapsed > [!note]- Dismissed (N) callout at the end instead of a heading of their own. Everything from the top of the file through the operator's own end marker is copied into the rewrite byte for byte. A file carrying no marker pair is never touched โ€” the first write is the deliberate adoption agentmdream ideas -intro <file> -write makes, above โ€” so a vault that has not adopted the file yet is untouched by the nightly run too. A rebuild over a folder that has not changed writes nothing.
dates Additive relative-date glosses (last week (the week of 2026-08-24)) in notes older than date_gloss_after_days (30) โ€” never a rewrite, never inside a fence.
tasks Moves a closed task's whole folder from projects/<slug>/tasks/ to projects/<slug>/completed/tasks/ once its tracker has read done or dropped for the contract's task_completed_after_days (14), oldest first, at most demotion_cap folders a night (task 177; PlanTaskMoves, daemon/internal/dreaming/taskmover.go). Each file is one journaled move, and the tracker moves last, only after every other file of the folder has, so a crash or a file edited mid-pass leaves the tracker where the next night finds and finishes the folder. Each applying night then rewrites, from what is on disk, every link that names a file now under completed/tasks/ โ€” through daemon/internal/linkrewrite, the port of the hand-run moves' rewriter โ€” and re-keys .heat.json and .lifecycle.json. A folder a note under personal/ or standards/ links into is held and listed, as is one holding a walled file, a file over 16 MB, a file that cannot be read, or a destination already taken; with no contract loaded the job reads nothing. Moves only with daemon.task_mover_enabled on (agentm_config.py --task-mover-enabled true); until then it plans every night and the morning note says what it would move.
projects Writes each project's activity: and last_worked: โ€” read once, before mocs renders the project trackers with it, so a tracker is written once a night (task 190) โ€” and moves a finished record under completed/ when its task closes or it is superseded. A record naming a task that has moved to completed/tasks/ still counts as its task closed. Only work moves last_worked (task 182): a map (kind: moc) never counts, nor an updated on the day enrichment last rewrote the record, and a blueprint counts only once edited after it was created. The reading is written to project-activity.json even on a reporting pass, and the ranker applies the band at query time, reloading the file when it changes โ€” no reindex.

Then three checks that write nothing: a vocabulary audit (every type:/kind: against the contract's own registers), trend flags (writes doubling week over week, a day at the cap, a class growing by half since the last pass), and a sampled re-classification diff (reclassify_sample, 30 notes) whenever the filing-pass version has changed since the last pass, or on -reclassify.

The lifecycle journal's actors, and your edit of lifecycle

Every change to a note's lifecycle leaves one line in the lifecycle journal, <engine state dir>/lifecycle-journal.jsonl (LifecycleJournalName, daemon/internal/dreaming/lifecycle.go:64): actor, from, reason, rel (relative to the memory root), run_id, to and ts (lifecycleLine, lifecycle.go:543). The pass writes its lines through the mutation journal's governance step (governance, journal.go:337, over EnsureLifecycleJournalAs, lifecycle.go:572), once per run, note and state, with the actor the intent names, or policy when it names none.

Actor Written for Constant
policy The machinery: this pass's sinks, lifts, archive moves and deletions, the copies job, and enrichment's sink ActorPolicy, lifecycle.go:100
operator A person: a hand edit of lifecycle the night finds, a note moved back out of the archive by hand, and the Python CLI's set and revive ActorOperator, lifecycle.go:101

HandEdited (lifecycle.go:775) finds your edit by comparing a note's lifecycle with the journal, and the pass acts on what it finds before it weighs the note's age (lifecycle.go:370-397):

The pass finds It reads that as The night does
lifecycle disagrees with the journal's last line about the note, whoever wrote that line, and the line ends in active, dormant, archived, pinned or superseded Your edit, from the state the line ended in Journals it as actor operator, reason edited by hand: <from> โ†’ <to>, stamps lifecycle_since, lists the note in touched_by_hand, and leaves it alone that night
The journal has no line about the note, or its last line ends in purged, completed, written or activity (which says nothing about the file at that path now), and the note is dormant with no lifecycle_since Your edit from active: every writer that sinks a note stamps one The same
Any other state on such a note Not an edit: a pin is exempt either way, and superseded and archived are the machinery's words, written with their own fields Nothing
lifecycle: archived on a note in its class folder, outside archive/ A note you moved back by hand Journals it as actor operator, reason moved back into its class by hand โ†’ active, sets active, stamps lifecycle_since, and lists it in returned

The stamp on an edit is the day the pass found it, unless you wrote a lifecycle_since of your own that is later than the journal's last line about the note and not in the future (handEditDay, lifecycle.go:788).

The clock restarts. When the journal's last line about a note is the operator's and still names the note's state (a hand edit, a note moved back out of the archive, or the CLI's set or revive), the pass counts quiet time from the later of the last activity and the day the state was set: lifecycle_since, else the day of the journal line (daysSinceSet, lifecycle.go:802, applied at lifecycle.go:437-441). No night sinks such a note until it has been quiet for the full dormant_after_days since the edit.

Each edit is named once. The pass lists the note in touched_by_hand (LifecyclePlan.Touched, lifecycle.go:131) and agentmdream run prints it as left alone <rel> โ€” its `lifecycle` was edited by hand; journaled as the operator's (daemon/cmd/agentmdream/main.go:425-427). The dreaming facet names the same notes under Left alone โ€” you edited its lifecycle (leftAloneHeading, daemon/internal/dreaming/dreamingfacet.go:104, rendered at :156), and a note moved back by hand under Moved back into its class by hand. The facet reads the pass's list and nothing else: once the edit is journaled the journal agrees with the note, the next night finds nothing to name, and a later edit of the same note is a new edit, named on its own night.

The takeover (2026-09-05)

The binary ran report-only beside the Python dream.py cycle through an overlap window, with a daily divergence review comparing the two. The one review agreed on every surface, and the operator flipped -apply in templates/jobs/dreaming.yaml the same day. Since then, dream.py no longer runs the suffix-backlog drain, the calendar rollups, or the lifecycle policy's own sinking and lifting. Since agentm-vault plan 04 it applies and stages nothing at all; what it still does is in the Python cycle beside it below. Rolling back is report-only mode โ€” drop -apply โ€” since the Python lanes it replaced are gone.

The Python cycle beside it

dream.py is the third step of the night. It follows the enrichment batch and this binary (templates/jobs/dream.yaml: schedule: daily, window 02:00-06:00, order 3, shipped dry_run: true). It reads, reports and proposes, and it changes no note.

Command python3 harness/skills/memory/scripts/dream.py [--vault-path <memory-root>] [--run-id <id>]; --batch-cap and --no-auto-apply are accepted and ignored, so an older manifest still runs
Stages, in order the corpus meters (entries, connectivity, browse surface); the filing contract, fail-closed (a block that will not parse halts every stage after it, and the digest says "Filing is halted" and why); lint, as a report (lint_repairable_count counts the mis-cased links /memory lint --apply would repair); possible twins (dedup at 0.92 similarity); shared keys (contradiction triage: same slug:, different body); proposed facets (a diary label on three or more days in thirty); the enrichment breaker's status and the correction loop
Findings file <engine state dir>/dreaming/review-proposals.json โ€” twins, same_key, facets, read by the needs-review map
Cycle report <engine state dir>/dreaming/python-cycle.json, beside this binary's last-report.json, for the morning note
Run digest <engine state dir>/dream-runs/<run_id>/digest.md, with the findings under "For you to judge"
Regenerated memory/mocs/needs-review.md, with the twins, shared keys and facets as sections of their own
Model calls none

Nothing applies a finding. You merge a twin or supersede one by hand, and you register a facet with an edit to the contract. See Review flagged memories. The verify-dreaming gate guards the wiring (see CI gates).

Parity as a recording

scripts/fixtures/dreaming-parity/expected.json was recorded from the Python producers, clock pinned, before they retired. The Go tests reproduce it โ€” including the calendar reviews, byte for byte โ€” and scripts/check-dreaming-parity.sh guards it in the local battery and in CI (see CI gates). The recording can't be re-recorded: the Python producers it was taken from are gone, so a changed decision from here is a deliberate edit to the recording, made on purpose.

Promote's own half is one such deliberate edit (agentm-vault plan 04, task 4): the recording captured the retired Python pass's crystallized digest, and promote no longer writes one, so that half of the check is narrowed away โ€” keeping it would mean checking against behavior the pass doesn't have any more. The recording's promote key is left as recorded, unread; the lifecycle and copies halves still check byte for byte against it.

The runner, and a refused manifest

The local scheduler (scripts/agentm-runner.sh โ†’ scripts/runner/cli.py; design: AgentM Runner) that fires agentmdream and every other .harness/jobs/*.yaml manifest on its own cadence. One malformed manifest used to stop every job in the cycle, with a launchd-log traceback as the only trace. load_manifests_lenient (scripts/runner/manifest.py) now keeps every manifest that loads and names each one it refuses, and the cycle runs whatever loaded rather than aborting.

The cycle's own account โ€” what loaded, what was refused and why, what ran โ€” lands at ~/.cache/agentm/runner/last-cycle.json after every run. Four surfaces read it:

Surface What it shows
Session brief โš  runner refused N manifest(s): <name>, <name>, โ€ฆ (every other job still runs; see ~/.cache/agentm/runner/last-cycle.json)
Morning note Did not run last night: <step> (<reason>), the reason taken from that step's outcome in the last cycle if that cycle started inside the night's window (see the morning note)
Doctor A runner-cycle row โ€” FAIL naming the refused files when the last cycle refused any (even though the other jobs in that cycle still ran), OK with the loaded/ran counts otherwise, UNVERIFIED when no cycle has run yet
agentm-runner run --strict The old all-or-nothing load, on demand: exits 3 on the first refused manifest and runs nothing

A plain (non---strict) cycle exits 3 only when nothing loaded at all; refusing some manifests while the rest load and run is exit 0.

The morning note

The last step of the night, and the page you read the next morning. morning_note.py reads what the other nightly steps left behind and writes one note. The daily email sends it, and the session-start line shows its first section. See Read the morning note and the nightly scorecard for how to read it.

Command python3 harness/skills/memory/scripts/morning_note.py [--vault-path <memory-root>]; without the flag, $MEMORY_ROOT, then the memory root the daemon reports
Scheduled by templates/jobs/morning-note.yaml: schedule: daily, lookback: 3d, window 02:00-06:00, order 5 (after the corpus scorecard at 4), tier: T2, dry_run: false
Writes <memory-root>/diagnostics/morning/YYYY-MM-DD.md, dated by local time, and a copy at latest_morning_note.md beside it; a diagnostics space the daemon reports takes the place of diagnostics/
Frontmatter title, kind: report, date, headline (What ran in one line with a count of the lists that need you, JSON-quoted), generated_by: morning_note.py
Last night everything since the most recent opening of the night window, 02:00 local, at or before the run
Model calls none

What it carries

Each section is left out when it has nothing to say. When What ran, What needs you and Spend are all empty, the note says Nothing ran last night and nothing needs you. instead. Line by line:

Line What it says Read from
What ran ยท enrichment N judged ยท N filed active ยท N below the floor ยท N sank ยท N calls ยท N tokens against the line ยท <model>, with N project records ยท inserted just before the call count on a night that merged into any (agentm-vault plan 09 โ€” project records are merged rather than filed, so they sit beside the filing verdicts instead of inside them), then N failed and Stopped by <reason> when present; a note that sank also counts below the floor. Then N card(s) stand refused at this pass, with , N of them skipped free rather than judged again when the run declined any โ€” silence means none stand, and the headline carries , N refused beside the judged count <engine state dir>/enrich-runs.jsonl, the runs since the opening; the numbers are the run's own refusals_open and refused
What ran ยท the binary the pass's mode, outcome and gate reason, then one table row per job (lifecycle, copies, refile, promote, calendar, mocs, dates, and tasks โ€” the closed task folders moved or, with its switch off, that would move, the links rewritten, held and waiting on the cap), and a line listing those folders; ran, and its gate held; the last pass was N ago when the runner started it and the gate held <engine state dir>/dreaming/last-report.json, when written since the opening
What ran ยท the Python cycle possible twins, shared keys, proposed facets, and the orphan, contradiction and mis-cased-link counts from lint; filing is halted with the parse error when the contract did not parse <engine state dir>/dreaming/python-cycle.json, when written since the opening
What ran ยท did not run Did not run last night: <step> (<reason>) for enrichment, the dreaming binary, the Python cycle or the corpus scorecard the runner's per-job markers and ~/.cache/agentm/runner/last-cycle.json
What needs you a count and the first five of: closed task folders the mover held back, each with its reason, and those a pass began and did not finish; unfiled notes the batch judged below the floor (they carry enriched_at), possible twins, shared keys, proposed facets, the binary's archive candidates, and what sank in the last seven days; then a link to [[needs-review]] the needs-review reading, dreaming/review-proposals.json, last-report.json, the lifecycle journal
The corpus a writes line (task 190): new memory cards in the last 24 hours by class, self-probes apart, against daily_card_budget; the vault's net notes; maps, project trackers, needs-review and entity pages rewritten, with how many changed only the calendar (the updated: stamp, the dream cycle's date, a count of days) or line order, where a date that is data, such as last_worked or an entity page's seen-dates, is a change; and enrichment restamps in project docs. Over budget, a dates-or-order-only rewrite, and a committer an hour behind are marked โš . The scorecard carries the same reading as its writes, the last 24 hours row. Then one line: class populations, N awaiting a judgment, the oldest <age>, coverage N of M stamped at this pass, and a link to the day's corpus scorecard when it exists; not measured (<reason>) when the daemon does not answer. When the ledger answers 0 eligible over a corpus that holds cards, the note reads coverage once more after a 2-second pause, since the first read can land while the daemon is still reconciling cards the batch just rewrote (_coverage, COVERAGE_REREAD_PAUSE); a second 0 prints coverage not measured (the ledger answered 0 eligible twice over a corpus of N cards โ€” read it again). An empty corpus reads 0 of 0, once the class directories, agentmd status, agentmd ledger --pending --limit 0, diagnostics/health/
Spend Last night: tokens added against the line, per tier as <tier> N of <line>, then tokens processed, calls against the 250-call guard, and dollars, always held to the operator's numbers whatever a run lowered; Seven days: tokens added and processed, and dollars, across the week's runs; Sessions, the last day: when the rollup recorded any enrich-runs.jsonl; ~/.cache/agentm/telemetry/rollup.db, opened read-only

A step counts as run when its runner marker finished since the opening, or when its own record is from tonight, so a batch run by hand inside the window reports as ran. For a step that did not run, the reason is the one its outcome carries in the runner's last cycle:

  • disabled
  • dry run
  • outside-window 02:00-06:00
  • not-due
  • missed-beyond-lookback
  • watchdog-stop
  • budget-ceiling
  • exited N

A step missing from the last cycle reads not registered when the runner has no marker for it. It reads no cycle has reported it when it has one.

The note runs inside a cycle, and the runner writes last-cycle.json only at the end of a cycle, so the last cycle the note can read is always an earlier one. When the runner starts the note, that cycle usually started before the window opened. Only a cycle that started inside the window gives the reasons above. From any other cycle the note keeps two reasons, disabled and watchdog-stop, because a cycle reads those before it looks at the window. A step with any other outcome there reads no reason on record for the night. Every cycle also prints its account to the runner's log, ~/.cache/agentm/telemetry/runner-launchd.log, and the reasons the night's cycles gave are there.

Who reads it

Three surfaces read the note:

Reader What it shows
Session-start line [agentm] Morning โ€” <headline> (written <age>); once the newest note is two days old (--deadman-days or $AGENTM_DIGEST_DEADMAN_DAYS changes the two), [agentm] โš  Morning note โ€” none in N days (last: <date>); the night has stopped finishing โ€” see runner. It reads latest_morning_note.md, or the newest dated note when the copy is missing, and reads the digest ladder only when no morning note exists
Daily email the whole note, frontmatter aside, under the subject AgentM morning โ€” <headline>, when this morning's or yesterday's note exists; the newest digest otherwise. templates/jobs/observability-email-daily.yaml runs at order 6, after the note
On-device notification the session-start line without its [agentm] prefix

The session-start hook passes no path, so resolve_vault() (scripts/health/session_brief.py) reads the config. It joins plugins.obsidian-vault.memory_root onto plugins.obsidian-vault.vault_path when that directory exists. That is how the line finds Agent/diagnostics/morning/ on a nested layout.

The derived indexes

Three tables come out of the capture transaction, and all three are caches. They rebuild from the markdown, none of them is authoritative, and deleting any of them costs a reconcile pass rather than data. That is not a slogan here: a test deletes all three, rebuilds, and compares โ€” and a companion corrupts rows and proves the rebuild repairs them rather than preserving the damage.

index key carries what it buys
chunks (doc_id, chunk_idx) header_path, content a focused note stops losing to a long document on term-frequency mass
links source_id โ†’ resolved link text, surrounding context one-hop graph expansion in both directions at lookup cost
entities (entity_uri, doc_id) โ€” every note mentioning an issue or repository, without a scan

All three were added additively, with no SchemaVersion bump. A bump discards the whole index file, and the expensive half of rebuilding it is the re-embed, which none of this touches.

Deleting a note, and the orphan sweep

Each of these tables is keyed by the document's id, and nothing in the schema ties them to it โ€” there is no foreign key, and SQLite enforces none unasked. A table is cleared when a note is deleted because Delete names it, and for no other reason. perDocumentTables is that one list, read by Delete and by the sweep alike, so a table is cleared by both or by neither.

It was not always one list. All three of these tables arrived after Delete was written, each carrying a comment saying the docmeta delete took its rows with it, and none of them was added to the delete. Counted on the live index on 2026-09-19: 27,891 of 46,581 chunk rows, 11,898 entity rows and 8,145 link rows belonged to 4,209 documents that no longer existed โ€” every purged note, every path the migrations retired, and the body of every note recall_exempt_areas walls. No search reached them, since every ranked read joins docmeta and an AUTOINCREMENT id is never reused; they were simply the text of deleted notes, kept.

SweepOrphans removes every row in every one of these tables whose document is gone, and sets back to dangling every link naming a path the index no longer holds. Reconcile runs it, which means the daemon's startup pass, its five-minute pass, and agentmd reindex โ€” whose output carries the one line it prints. It works in place: the work queue is a table in this same file, and deleting the index to clean it would cost a full re-embed. The enrichment ledger is not in this file since task 181. On a clean index it is one existence check per table and takes no write lock; the checks stop at the first table that answers yes, and docs is asked last because it is the FTS5 virtual table, where the query has no plan and scans (182ms cold, 10ms warm over 8,000 notes, against microseconds for the five with a covering index).

The report is filled in only after the commit returns. The deletes ride one transaction with a deferred rollback, so a count written as each statement ran would credit rows that are still in their tables โ€” and the daemon logs that number every five minutes.

The sweep is the backstop, not the mechanism. A note's rows have to go inside the transaction that removes its docmeta row, because the id is the join key and a note re-added between two sweeps takes a fresh one โ€” rows left for a later sweep are unreachable from the moment the docmeta row goes.

Two kinds of chunking, and why both

The tempting reading is that header chunking replaces the window chunking that came before it. It does not, and treating them as alternatives would regress a measured fix.

ChunkText splits by byte budget with overlap, sized to the embedder's context window. It exists because 562 of 9,473 notes exceed that window and used to lose everything past their head. It is about what the model can read.

Header chunking splits by markdown heading so a match points at a section rather than a file โ€” the fix for a 38KB design document taking all five top slots from a 1.1KB focused note. It is about what a person asked for.

A long section blows the window whatever its headings say, so the second does not subsume the first. The split runs at two levels: header first, then window-split any section still over budget, with every resulting row carrying the header path of the section it came from. One table, one contiguous chunk_idx space. A note with no headings โ€” 94% of this corpus โ€” produces exactly what ChunkText has always produced, byte for byte.

The chunk budget is fixed rather than read from the live embedder. The chunk table is a retrieval structure and has to stay stable across a model swap, or every swap silently re-cuts every note and a <path>#<n> reference stops meaning what it meant. The vector arm re-chunks to its own live window when it embeds; that is where model-specific sizing belongs.

Links, and what happens when one dangles

Both forms are read, because the corpus writes both: Obsidian produces wikilinks and everything generated produces markdown links. Supporting one format would miss half the link graph while appearing complete, which is worse than supporting neither.

A target resolves by longest matching path suffix, with proximity breaking a tie. A target written with more path than a bare name is more specific, and between two equally specific candidates a link far more often means the sibling than the far-away file with the same name.

An unresolved target is recorded, not dropped. A dangling link is a fact about the corpus, and a table that discarded them would make any later pass over them blind. The stub-synthesis stage that read them retired in agentm-vault plan 04; the record stays, and work_ledger.dangling_targets() still reads it.

Links inside fenced code are skipped. A link in a code block is a sample, and indexing it would connect a page to whatever its examples happen to mention.

Entities, and the pages built from them

Issue, qualified issue, repository, release, commit and changelist references are pulled out by regex and keyed by a namespaced URI, so issue:owner/repo#123 can never collide with repo:owner/repo (daemon/internal/extract/entities.go). Every note mentioning something is one lookup away, and the nightly entity builder reads the same rows (see the dreaming binary's jobs).

The extractor reads each note with its context (task 179; EntitiesIn, and entitycontext.go in the index). A bare #12 becomes issue:owner/repo#12 when the note's project lists exactly one repository in its project.yaml: the project is the note's place under projects/<slug>/ first (completed/ included), then its project: label (projectbind.NoteProject), because task files carry no label. A github.com/<o>/<r>/issues/N or โ€ฆ/pull/N link is a qualified issue, and lends its repository to the bare number beside it. A release, release:owner/[email protected], comes from a โ€ฆ/releases/tag/vX.Y.Z link, from a version right after a repository's short name or its owner/repo (a known or line-referenced one; a path reads the same), or from a version after a release word or nothing, given to the one repository the line names, else to the note's own; a line naming another repository than the note's gives none. The two words before a version are read in turn, past release words, numbers and other versions; any other word โ€” a plugin, a tool, node, agy โ€” means the version is not a release, and so does a line naming a repository no project lists (Bump actions/checkout from v3.5.2 to v4.0.0). A possessive is its owner's ("agentm's v10.0.0"), a pre-release link is not the release, and a bare #235 right after a repository's name ("crickets #235") is that repository's. GitHub's own pages are not repositories: docs. and gist. subdomains, and users/, orgs/, settings/ and the like in the owner's place. extract.Paged says which references get a page: repositories, qualified issues and releases; commits, changelists and bare numbers never do.

One refusal is set per project rather than in the code (task 187). A repository whose roadmap numbered its items in the same range as its first issues makes a bare #15 in an old note mean either, so a project can set bare_issue_floor in its project.yaml (the schema). A bare number below the floor of the repository it would qualify to, the note's own or the one the word before names (crickets #41), stays issue:#NN: indexed for search, never paged. An explicit owner/repo#NN, PR #NN, issue #NN, pull request #NN or a GitHub issue or pull address qualifies whatever the number (belowFloor and explicitRef, daemon/internal/extract/entities.go:397 and :418). projectbind.IssueFloors reads the floors: a repository two projects list takes the higher one, and a value that is not a positive whole number is ignored there and refused by check-project-yaml.

A reference takes the repository its text names, not the note's (task 191). The name before the PR or issue word does it, a comma between or not (crickets, PR #157, agentm PRs #239). So does a lane label ending in it (A-crickets #189), its name right after the number (#174 crickets, repoAfterNumber), or a list its first number started (crickets #35, #36, #174, listSepRe); a list the clause rule below started stays within that rule's ceiling as it goes on, a number over it staying the note's own while the list goes on past it (#895). An explicit PR #NN or issue #NN with no name of its own goes to the repository the nearest name before it in its clause names, when that is another repository than the note's (clauseRepo). A name may be a short name, possessive or not, the head of a path (crickets:README.md), or the head of a hyphenated plugin name (crickets-maintenance). The clause runs back to a table cell's edge, a sentence's end or a ยท item, and at most one ; further. A name inside a parenthetical that has already closed is skipped. The number must also be within the repository's ceiling, the highest #N its clone's commit subjects cite on any ref (projectbind.RefCeilings, read with the projects' repositories). With no clone, nothing moves on a clause's word. A GitHub address used as an image is not a repository mention (imageOnGitHub): the source of an <img> or a markdown image (the ! read before the opening bracket that pairs with the link's closing one, escaped brackets skipped, so a link wrapping an image still names its repository), or a blob/ or raw/ path to an .svg, .png, .jpg, .gif, .webp or .ico. EntityExtractorVersion is 2026-10-16-an-inherited-number-keeps-the-ceiling, so the first open after a deploy re-derives every note's rows.

Most of the rest is refusing false positives. #1 is as often a list marker as a reference, so a bare issue needs two digits; #todo is a tag. A bare number of six digits or more (a colour such as #191614, a listing number) or one right after a word that numbers something else โ€” item, roadmap, plan, feature, phase, step, part, wave, track, task, row, idea, catalog, master, or a version line such as V4, v3.0 or post-V4 โ€” is not given the note's repository and stays issue:#NN (refShaped, task 186); a number in parentheses or brackets is always a reference, so v10.4.0 (#797) keeps its repository. a/b is a path far more often than a repository, so the host is required. An all-digit run is a date or a count rather than a commit, so a hash needs a hex letter, and seven characters is where git abbreviates. A change to the extractor reaches the notes already indexed on the next open (task 186; entityreextract.go): the index records entity_extractor_version in its meta table, and when that differs from the version the index now wants it re-derives every note's entity rows from the bodies and project: labels it already holds, in one transaction, and records the new version. The vectors and everything else in the index are untouched, so an extractor change no longer needs reindex -from-scratch. The version it wants is index.EntityExtractorVersion alone, or, when any project sets a floor, that version followed by each repository's floor (extractorVersion, daemon/internal/index/entityreextract.go:35), so setting, raising or removing a floor reaches the indexed notes on the next open with no bump and no reindex (task 187). Bump the version with any change to EntitiesIn that should reach existing notes; on the live vault the pass takes a few seconds.

Aliases

Derived at capture from the note's own text, and nothing is invented. A model reading a note paraphrases the note, and the gap that hurts retrieval is between the note and a future question rather than between the note and a restatement of itself โ€” model-written aliases measured โˆ’3.85 R@5 and are not used.

Two channels. Acronyms are read in both directions and kept only when the expansion's word initials actually spell the acronym, so an ordinary parenthetical drags nothing in with it. Compound identifiers are decomposed, so idx_timestamp_desc also indexes as idx, timestamp and desc โ€” the class of token an embedder mangles and a tokenizer splits differently from how a question asks for it. snake_case and camelCase are decomposed anywhere; kebab-case only inside a code span, because a hyphen is ordinary English punctuation.

The list is capped, because the alias column ranks above body and is therefore scarce rather than free. Sorting happens before the cap, so which aliases survive is a property of the note rather than of the order the regexes ran in.

Kept across rewrites (task 182). A pass never removes an alias already on a card: CarryProvenance keeps the previous list first, as written (flow or block list), and appends any alias the pass derived that it did not already hold, case-folded. An alias written at capture is the asker's own phrasing, the channel the filing design adopted; until 2026-10-01 a rewrite replaced the list with the pass's answer and took every alias off a dozen notes, which scripts/migrate/restore_aliases.py gave back (31 aliases on 11 notes, leaving out anything the 2026-08-08 cold backfill wrote).

The corpus-write gate

agentmd gate corpus-write

No corpus-wide write job โ€” migration, backfill, reclassification, dreaming's future drain โ€” may start unless this passes. The job asks; nobody has to remember.

Exit Meaning
0 Pass. The verdict carries head, the commit the job would be reverted to.
3 Refused. reasons[] names the code, the detail, and the remedy.
1 Could not decide โ€” an unknown gate name, an unreadable config. Also a refusal.

It refuses on two conditions, which mean the same thing:

  • git-degraded โ€” the vault is not a repository, or has no commits. There is nothing to revert to.
  • uncommitted-changes โ€” the worktree already carries edits, so undoing the job and undoing whatever else is in flight would be one command. Let the daemon commit them: within a second of the last write for an ordinary file, or on the next reconcile pass for a tracked file under a dot directory. The one case it will not act on by itself is a file under a dot directory that git does not track yet โ€” commit that one yourself, or add it to .gitignore. See What gets committed.

There is no override flag. What is being checked is whether an undo exists at all, and a gate with a --force is a gate that documents the thing it was meant to prevent.

Every corpus-wide write job in the repo asks it. Dry runs do not, since they write nothing:

Job Gated path
alias_backfill.py run, reapply โ€” revert deliberately exempt
recall.py heat-policy --apply (promotes and demotes across the corpus)
sweep_junk_preferences.py --apply (archives a cohort)
migrate_arcs.py --apply on all three subcommands

revert is exempt on purpose: gating the undo on there being an undo is the one arrangement that could strand the corpus.

The call itself is corpus_gate.py, which runs the binary and relays its verdict without re-deriving it โ€” a second opinion in Python would be a second dialect of the gate. It fails closed on a refusal, on an undecidable answer, on a missing binary, and on a zero exit that does not name the gate (agentmd is a bare name on PATH, so an unrelated program exiting 0 must not read as permission). It is vendored byte-identically into harness/skills/memory/scripts/ because the LC-8 bridge rule forbids kernel toolkit scripts importing back into scripts/; check-vendored-parity.sh corpus-gate keeps the two in step.

The meters, and what they measure

Four numbers say whether the corpus is converging on itself. Enrichment rewrites every memory it touches, so the risk it carries is that a corpus of distinct memories slowly becomes a corpus of one voice saying similar things. These are how that would be noticed.

Meter Bad direction What it sees
trigram concentration rising the same phrases recurring across notes
moving-average TTR falling vocabulary narrowing within a sliding window
pairwise cosine similarity rising every note drifting toward every other
nearest-neighbour dispersion falling clusters tightening before the average moves

The last one is the earliest signal. Convergence starts locally โ€” a few notes tighten around each other while the corpus-wide average is still steady โ€” so the nearest-neighbour distribution moves first.

The two embedding meters refuse to run when the dense arm is absent, rather than returning zero. Zero dispersion is what a perfectly converged corpus looks like and zero similarity is what a perfectly diverse one looks like, so a missing embedder would report either "everything is fine" or "the corpus has collapsed" depending which number you read. Neither would be true, and refusing is the only answer that cannot be misread.

What they measure, and what they deliberately do not

The population is the filed live memory corpus: the configured memory space, status: active, excluding _inbox, _archive, scratch, _shelf and _opinions (MeterExcludedDirs). Two filters rather than one, because neither is sufficient alone. Status alone still admits hundreds of inbox notes carrying active from a mining pass that never reconciled them. Directory names alone still admit unfiled captures sitting in the memory space.

MeterExcludedNested adds a second cut, scoped to crystallized: the class's lane subdirectories (crystallized/<opinion>/) are out of the population while its own flat files stay in. Filing-v2 part 3 moved the _opinions mined-supplement lanes there, and the reasoning above about _opinions carries over unchanged โ€” a lane entry is still reflect.py's mined material awaiting promotion, not a filed memory, whatever directory holds it now. A flat crystallized/*.md file is a crystallized memory itself and stays in the population.

This is narrower than the vector arm's scope, deliberately. EmbedScope covers memory, desk and external so that retrieval can reach the gold set's answers, which is a fact about scoring retrieval rather than about which notes enrichment writes. When the meters used it, their window was 79% raw captures and dreaming's own staged proposal files โ€” and because the inbox accumulates near-identical mined clippings, the bias ran one way: similarity read high for a reason that had nothing to do with enrichment.

The window is the most recent notes rather than the whole corpus, because the question is whether what is being written now is converging. A uniform sample across five years would mix a month of drift into sixty months of history and report that almost nothing had changed.

Clusters, and the correction they feed

meters says how much the corpus is converging. clusters says where, which is what an action needs โ€” the correction loop works in order of severity, and severity belongs to a specific cluster rather than to a corpus-wide number.

Notes are grouped by single linkage above a cosine threshold, then classified from provenance alone. The classification is deterministic and involves no model call: whether two notes came from one source is a fact recorded in their frontmatter, and asking a model to guess at it would put a judgement call underneath an action that rewrites files.

Kind Means What may act on it
duplicate every member shares a provenance unit a merge proposal in the nightly digest, applied by a person by hand
collapsed every member has provenance, no two share any re-distillation from source
mixed some share, some do not nothing
unknown a member records no provenance nothing

Two of the four decline to answer, and that is the point. A mixed cluster has both problems and one fix for neither โ€” re-distilling the shared pair from their common source produces two notes from one source, which the merge arm then finds again. An unknown cluster is a finding about metadata rather than about notes.

Provenance is compared exactly. The live corpus's only two clusters are DeepSeek-OCR against DeepSeek-OCR-2, and kimi-code against kimi-cli โ€” four upstream projects, two pairs. Any prefix or substring comparison calls each pair a single source, which makes them duplicates, which proposes a merge that would supersede one of two real memories. The cheaper-looking comparison is the one that deletes things.

Single linkage means A and C can land in one cluster through a B close to both, even where A and C are not close to each other. That is right for reporting โ€” five notes collapsed onto one pattern are one finding, not ten pair findings โ€” and wrong if membership is read as "these are interchangeable". So every cluster carries min_sim, the loosest pair anywhere in it, and chained when that falls below the threshold.

The threshold

--threshold defaults to 0.95, and the number was measured rather than chosen. On the filed corpus the pairwise distribution runs median 0.43, p90 0.60, maximum 0.96 โ€” so 0.95 sits at the extreme tail and selects a handful of notes. A number picked by intuition would have been 0.90, which on a wider population swept nine notes in ten into a cluster.

It is a flag rather than a constant because a number that decides what gets rewritten belongs where a person can see it move.

The memory context graph

graph walks the link graph and draws it, using d3-force's three forces with d3's own constants โ€” so the picture is recognisably the one Obsidian would draw. Initialization is phyllotaxis rather than random, which makes "faithful to Obsidian" and "the same picture twice" the same implementation instead of two goals in tension.

Two spellings of one link target produce two rows in the index, so edges are deduplicated before layout. --cap bounds the node count by keeping the highest-degree nodes, and the report says how many it dropped rather than quietly drawing a subset.

The slop detector, and the question it does not ask

agentmd slop scores a note for template residue and novelty. It exists because enrichment writes prose, and prose written to a template reads fine one note at a time.

It does not ask whether a note is worth keeping. Three rubrics in this arc asked exactly that and all three failed their own calibration โ€” ฮบ = 0.189, then 0.349, then 0.286 โ€” and the reason is the same each time. Whether a note earns its place turns on what the operator wants their vault to hold, which is private to them and not recoverable from the text. Two graders cannot converge on a question only one of them can answer.

So the detector scores what is visible in the note: how much of it is boilerplate the template supplied, and how far its language sits from everything else. A low score is a note worth looking at, not a note to delete. The design's review band and narrow auto-expire band were never built, and the staging machinery they were meant to sit on retired in agentm-vault plan 04, so nothing acts on the score.

The length floor is an AND-gate, never a rule on its own. A short note is not slop โ€” this corpus is full of short dense references that are exactly what a memory should be โ€” so shortness only ever compounds another signal.

Completeness, and why it survived where the others did not

agentmd completeness samples enriched notes, splits each into claims, and hands the pairs to a grader that asks one question: did the rewrite drop something the source said?

That question is answerable by two people looking at the same two pieces of text, which is the whole reason it survived calibration where "is this worth keeping?" did not. The rubric is frozen before the sample is drawn, and it lives at scripts/health/fixtures/completeness-v1/RUBRIC.md.

One rule is worth naming because an earlier rubric lacked it: grade against what the source actually says. A rewrite that faithfully reports a truncated source has not lost anything, and a grader that does not know this marks every damaged note as an enrichment failure.

The check that needs no human at all is the gutted-note fixture: a note with material deliberately removed must score lower than its source, asserted in both directions. That one runs in CI.

Watching, and what actually guarantees correctness

Two mechanisms, and only one is the guarantee.

The filesystem notifier makes an edit visible in under a second when it fires. It cannot be relied on: the vault sits on a cloud-sync mount where events are dropped and coalesced, and on macOS each watched directory costs a file descriptor.

The periodic reconcile pass is the guarantee. It walks the vault, compares mtime and size against the index, and adds, updates, or drops whatever disagrees. On an unchanged corpus it is a stat-and-compare. Both paths finish by committing โ€” a change the notifier missed would otherwise be indexed and never committed, which is the half of the vault with no undo.

agentmd status reports how many directories were actually watched, how many could not be, and what the last pass did.

Git

Every change is committed with an origin: trailer naming where it came from: capture for the daemon's own writes, phone for anything under daemon.phone_paths, self-probe for the daily round-trip check, local-edit for everything else.

Sharing the repository with your own git

The daemon is not the only git client in the vault โ€” you run git there too โ€” so it speaks git's own concurrency protocol rather than assuming it is alone. Every index-mutating operation holds .git/index.lock (the same file C-git takes) for its whole read-modify-write span, and writes the index by temp-file-and-rename so a concurrent git status never reads a torn file. go-git does neither on its own; the daemon adds both, which is what stops a daemon commit cycle that overlaps your git rm from silently discarding your staging โ€” the clobber that fired twice during the 2026-08-11 rehoming pass.

When something else holds the lock, the daemon waits up to 10 seconds with backoff, then skips the cycle with a WARN naming the lock and its age, and retries on the next debounce or reconcile. It never steals another client's lock: a lock that will not clear is either a live git operation or a crashed one, and both deserve a human look. If the daemon logs that warning repeatedly and no git command is running, the lock is stale โ€” remove it by hand.

The one lock the daemon removes is its own. While it holds index.lock, the file reads exactly held by agentmd (pid N). If a lock with that content names a pid that is no longer running, and isn't the daemon's own process, it is a daemon that was killed mid-commit, so the daemon removes it, logs removed a stale index.lock left by a dead agentmd, and takes the lock. Any other content, or a live pid, is waited out and refused as above. Before this, a restart that killed a daemon mid-commit on 2026-10-04 left a lock its successor skipped on every cycle until someone removed it by hand.

What gets committed

Whatever git reports dirty, not whatever the indexer accepted. Those are different questions and conflating them was a real defect: until 2026-08-10 an event had to be markdown to reach the commit path, while agentmd gate corpus-write refused on anything git status could see. Every non-markdown file fell in the gap โ€” written, never committed, permanently dirty, gate shut, with no override to get past it.

So indexing and committing are now separate decisions. Only markdown is indexed, because FTS5 has no use for a PNG. Any change to a tracked tree wakes the committer, which then asks git what is actually dirty and commits that.

.gitignore is the policy surface. The daemon holds no second opinion about which files belong in history โ€” which is also where the older question of whether runtime state belongs in the undo story is answered, file by file, by a list you already edit and version.

One rule sits above .gitignore, and it is a safety rail rather than a policy: a file under a dot directory is committed only if git already tracks it. Drive stages every upload through .tmp.driveupload, and that churn peaked above 1,400 files during the git-transport cutover; a vault whose ignore list is missing or wrong would otherwise write all of it permanently into history. Trackedness is the test rather than a list of directory names, because it puts the line where intent already is โ€” .obsidian/app.json is tracked because someone chose to version it, so the daemon maintains it, while .tmp.driveupload/3700.md is untracked because nobody chose anything. The cost, accepted: a genuinely new file under a dot directory has to be committed by hand once, after which the daemon keeps it current.

Dot directories never wake the committer either, so a tracked file under one is picked up by the reconcile pass rather than within a second. A long sync would otherwise keep resetting the debounce and starve the very commit this exists to make.

A file large enough to be surprising is committed and logged as a warning, not skipped. Skipping was considered and rejected: a skipped file stays dirty, and a dirty worktree shuts the gate, which is the defect this design removes. Committing is also the recoverable direction โ€” .gitignore plus git rm --cached undoes it with the control you already use, whereas a gate held shut by a file the daemon refuses to touch has no lever at all.

Packs, and who packs the repository

The daemon reads the repository through go-git, which reads less of git's object store than git does. go-git v5.19.2 lists only packs named pack-*.pack (storage/filesystem/dotgit/dotgit.go:300), while CLI git reads any *.pack with its index beside it. It also loads the list of packs once per process (storage/filesystem/object.go:53), so a long-running daemon doesn't see a pack another process writes later. On 2026-10-04 git's loose-objects maintenance had left 6,716 objects in a loose-<hash>.pack, and once a later repack pruned their loose copies, every status read failed with object not found while CLI git and fsck stayed clean. A restart didn't help, because the objects were missing for any go-git process.

The daemon repairs that itself (daemon/internal/vcs/heal.go). When a status read or a commit hits a missing object, it reopens the repository, which re-reads the pack list, and tries again once. Before reopening, if objects/pack holds a pack go-git can't list, it runs git repack -a -d --keep-unreachable through CLI git under index.lock. That folds every object, reachable or not, into one pack- pack. It counts the distinct objects before and after, refuses to call a falling count a repair, and logs repaired unlisted packs with both counts. agentmd also checks for such a pack when it starts. A commit that hits the missing object part-way puts the index and its deletion quarantine back as it found them before the retry. The repair needs CLI git on the PATH. Without it the daemon logs the failure once an hour and keeps retrying the reopen, and the commit-stall alarm above is what tells you.

So that nothing writes such a pack in the first place, the daemon owns the repository's packing (daemon/internal/vcs/maintain.go):

  • The repository's own config. On start, agentmd makes sure the vault repository's config has gc.auto=0 and maintenance.auto=false, so no git command repacks behind it: git fetch and git commit normally run maintenance when they finish. It changes those two keys only, never the global or system config, and logs set repo-local git config when it changes one.
  • A daily repack. On the reconcile tick, once a day, the daemon runs the same lossless repack under index.lock and logs daily repack. The day is counted from when the newest pack-*.idx was written, which survives restarts. A repository with no pack yet counts from the first check. It skips the repack when there is nothing to fold: no loose objects and at most one pack.

A git command run by hand still works on the repository as before. Running git gc or git maintenance yourself is safe too, because the daemon heals around whatever it leaves.

A git worktree in the vault

A git worktree doesn't belong in the vault. Drive uploads one file by file, and the Claude desktop app sets extensions.worktreeConfig = true in the repository's config when it makes one. On 2026-10-02 a background-task chip opened from a session in projects/pixelton made a worktree there. Drive recorded 6,276 upload events, and after its next restart, on 2026-10-03, the daemon came up with git degraded: no commits, no undo, and the corpus-write gate refusing (#859).

Three things now hold:

  • The daemon reads past the key (daemon/internal/vcs/worktreeconfig.go). go-git v5.19.2 refuses the extension for two reasons of its own: it never reads core.repositoryformatversion back from the file, and its list of extensions allowed at format 0 doesn't match the lowercased name it compares. The daemon's storer hides that one key from go-git, because nothing the daemon does reads per-worktree config. It never removes the key from the file, and any other unknown extension still refuses the repository.
  • The status surface names a worktree in the vault (daemon/internal/vcs/worktrees.go). There are three signs: the repository lists a worktree besides its main working tree, a .claude/worktrees/ folder under the vault root holds something other than a dot-file, or the config carries an extensions. key. The empty folder at the vault root, left from 2026-08-16, is not a sign, and neither is a .DS_Store Finder leaves in it. The daemon checks at startup and on every reconcile tick, and reports what it finds as vault_worktrees on the git block of agentmd status --json. The text status adds a ! a git worktree is in the vault line for each sign. None of these pages: nothing is lost while a worktree sits there, but it should go. The doctor's vault-worktrees row fails on the same signs, and the morning note puts them first under "What needs you".
  • The record of a worktree long gone is pruned. A registered worktree whose directory has been missing for three months has its record removed by git worktree prune --expire=3.months.ago, and the daemon logs pruned the records of worktrees that are gone. Three months is git's own default for gc.worktreePruneExpire. git gc would do this prune itself, but the daemon turns automatic gc off in this repository. A shorter wait isn't safe: the record holds that worktree's HEAD and index, and a directory that only looks missing may be on a volume that isn't mounted. git keeps a locked record whatever its age. The daemon tries at most once a day while a gone worktree is registered, and until the record goes the sign says the directory is gone.

The vault-worktree-guard hook (harness/hooks/vault-worktree-guard/) refuses the tool calls that would make a worktree in the vault in the first place. To remove one the app made, archive the session that owns it, so the app's own cleanup restores the config. A worktree removed with git worktree remove leaves the key behind, and the doctor row says so.

Capture dates

captured is immutable: it records an event in the daemon's own life, and the shard a note is born into is the one it dies in. The daemon reads it from frontmatter captured, then frontmatter date, then the filesystem mtime, and every result reports which one it used.

On the current corpus 8,845 of 8,864 notes carry neither field, so their dates come from mtime. Once a date is recorded the index keeps it, so editing a note does not move its capture date and a sync client rewriting mtimes cannot shift the temporal bounds. after/before are exact for anything the daemon captured and a good approximation for everything older.

Registering it

claude mcp add --transport http agentmemory http://127.0.0.1:7821/mcp

Remove any existing agentmemory entry first โ€” before this daemon, that name resolved to the stock filesystem server pointed at the vault's parent directory, which was a coincidence of naming rather than a relationship.

Who can reach it

Loopback only, and not by binding alone. Three checks run on every request, including /health and /status:

  1. The peer address must be loopback.
  2. The Host header must name a loopback address. A DNS-rebinding request carries the attacker's hostname here, because that is what the browser was told to connect to.
  3. An Origin header, if present, must be loopback. A native client sends none; a browser always does.

The reason all three exist is that the first is not sufficient. The daemon listens on a fixed port for as long as the machine is up, so a page the operator visits can make his browser issue requests to 127.0.0.1 โ€” and those arrive with a loopback peer address like any other. Cross-origin refusals return 403.

There is no bearer token, on purpose. It would gate other processes running as the operator, and any such process can already read the vault files directly.

Related

โš ๏ธ **GitHub.com Fallback** โš ๏ธ