Memory Daemon - alexherrero/agentm GitHub Wiki
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.
| 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 serveNeeds 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/agentmdTo 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.
One flag does the build and the launchd agent together, on macOS:
bash /path/to/agentm/install.sh --daemonIt 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.plistinstall.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.
| 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.
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.
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.
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-promotedcarries no weight, so a fragment-shaped note that filing promoted keeps its score. That protects 1,288 notes, including 229 of the 232 inmemory/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.
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 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.
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.
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.
| 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.
| 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).
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).
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 bothresourcesandsystemstheSharedlevel (daemon/internal/door/authority.go:79-97) โ the same levelcalendaralready carries: agent-maintained, operator co-writes, no grant needed. -
The embed scope.
config.defaultEmbedScopenames both spaces at the vault root, besideprojectsandcalendar(daemon/internal/config/config.go:462-470). A note that moves in fromprojects/agentm/research/<topic>/reference/ormemory/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/, onlyresources/briefsis indampened_spaces(described in the table above; the watchlist alone until 2026-10-03, all ofresourcesuntil 2026-09-25).record_kindsgainssystem,component, andblueprint(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'sblueprint.mdwill carry.standards/templatesjoinsrecall_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 atdaemon/internal/dreaming/run.go:275-281) writesresources/moc-resources.mdandsystems/moc-systems.mdonce 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 bycreated.moc-root.mdlists aResourcesorSystemsarea 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.
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.
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.
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
metacolumn. 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 probeRuns it now, through the same code path as the daily schedule.
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.
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.
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.
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-facetday-indexcalendar-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.
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.
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).
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 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.
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 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.
Inside the projects space, three kinds of note are records rather than cards:
- a project's charter at its root (
_index.md, orcharter.mdonce the migration renames it) - any note under its
decisions/folder - any note under its
designs/orresearch/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.
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.
ComposesubstitutesKeptFiling(render.go:283) for the ordinaryVerdictForjudgment:statusandfiling_confidencestay exactly what the card carried โ a card with neither reads asactiveathigh, because a card inpersonal/ideas/is filed by being there, not by a score โ and the card never sinks todormant, however low a response scores. -
titleandtypestand over the response.Composeoverwrites the model's proposedtitle/typewith the card's own, so a card that stopped readingtype: ideabecause a response proposed a different one would silently drop offIdeas.mdโ a demotion by another name. -
No
lifecycleis written or defaulted.carryProvenance'sdefaultLifecycleargument is!s.OperatorFiled(compose.go): alifecyclethe card already happens to carry still travels, because it is the operator's, but the ordinary "no lifecycle of its own startsactive" default never fires โ an idea card has no aging axis. -
areaanddismissedtravel through every rewrite, and nothing writes them. They ridecarriedFields(above) and only the carry touches them; losing either would move an idea to the wrongIdeas.mdheading, or back onto the live list, with nothing on the card saying why. The final reorder (cardshape.Reorder, called fromCompose) places them by the same rule every other card field follows โ read where they're read:arearight aftertype/kind,dismissedright beforestatus(ReadOrder,daemon/internal/cardshape/cardshape.go:24; the Python toolkit scripts'card_shape.READ_ORDERmatches it field for field). -
The card is never renamed.
newSlugis cleared for anOperatorFiledcard the same way it is for aNeverFilesone (main.go:1627-1632):personal/ideas/is the operator's space, the filename is the one they filed it under, andIdeas.mdlinks 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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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 thementities 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 |
| 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.
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 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.
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).
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 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 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 |
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:
disableddry runoutside-window 02:00-06:00not-duemissed-beyond-lookbackwatchdog-stopbudget-ceilingexited 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.
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.
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.
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.
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.
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.
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.
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).
agentmd gate corpus-writeNo 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.
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.
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.
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.
--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.
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.
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.
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.
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.
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.
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.
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.
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,
agentmdmakes sure the vault repository'sconfighasgc.auto=0andmaintenance.auto=false, so no git command repacks behind it:git fetchandgit commitnormally run maintenance when they finish. It changes those two keys only, never the global or system config, and logsset repo-local git configwhen it changes one. -
A daily repack. On the reconcile tick, once a day, the daemon runs the same lossless repack under
index.lockand logsdaily repack. The day is counted from when the newestpack-*.idxwas 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 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 readscore.repositoryformatversionback 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 anextensions.key. The empty folder at the vault root, left from 2026-08-16, is not a sign, and neither is a.DS_StoreFinder leaves in it. The daemon checks at startup and on every reconcile tick, and reports what it finds asvault_worktreeson thegitblock ofagentmd status --json. The text status adds a! a git worktree is in the vaultline for each sign. None of these pages: nothing is lost while a worktree sits there, but it should go. The doctor'svault-worktreesrow 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 logspruned the records of worktrees that are gone. Three months is git's own default forgc.worktreePruneExpire.git gcwould 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.
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.
claude mcp add --transport http agentmemory http://127.0.0.1:7821/mcpRemove 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.
Loopback only, and not by binding alone. Three checks run on every request, including /health and /status:
- The peer address must be loopback.
- The
Hostheader 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. - An
Originheader, 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.
- AgentM Rescope โ Storage Topology โ the daemon's design.
- AgentM Runner โ the scheduler design behind the runner section above: the manifest schema, the job template contract, and the launchd/cron triggers.
- AgentM Rescope โ The Memory Engine โ layout, frontmatter, capture doctrine.
- AgentM Hybrid Retrieval โ the recall ladder that added the embedder child, the search modes, and their measurements.
- Vault write protocol โ the caller-facing shape of the same write-time stamps and gate refusal.
- Review flagged memories โ working the needs-review page this page's enrichment stamps feed.
- Read the morning note and the nightly scorecard โ reading the note the morning-note section above describes.
- Named plans โ the task layout and the tracker this page's project records and project lift read alongside.
-
CI gates โ
check-daemonruns the battery below.