AGENT SKILL

memory

ZMem — ZCode multi-tier memory system. Provides cross-session recall, capture, and management of lessons, facts, conventions, and preferences. Use when you need to recall prior knowledge before a non-trivial task, capture a lesson at task end, or manage the memory store. Tier 0 (core.md/AGENTS.md) is auto-injected by the SessionStart hook; this skill operates Tier 2 (semantic store).

License 许可
MIT
所属仓库
zaxbysauce/zmem
最近更新
2026-11-15
Stars
★ 0
Forks
2

01 前言 Front Matter

name
memory
description
ZMem — ZCode multi-tier memory system. Provides cross-session recall, capture, and management of lessons, facts, conventions, and preferences. Use when you need to recall prior knowledge before a non-trivial task, capture a lesson at task end, or manage the memory store. Tier 0 (core.md/AGENTS.md) is auto-injected by the SessionStart hook; this skill operates Tier 2 (semantic store).

02 资源文件 74

03 SKILL.md 正文

Memory (ZMem)

ZCode's memory system has three tiers:

  • Tier 0 — Core: core.md (user-level, in the canonical shared store dir)
    • <repo>/AGENTS.md (project-level on ZCode). Auto-injected every session by the SessionStart hook. Edit core.md directly for stable rules/preferences. Keep <2KB.
  • Tier 2 — Semantic: store.sqlite (in the plugin data dir). Cross-task lessons, facts, conventions, preferences. Operated by this skill via scripts/store.py.
  • Tier 4 — Procedural: the skills library. (Extended later with evals + index.)

Finding store.py

The SessionStart hook injects a ready-to-run store command into context each session (look for the # Memory skill: invoke ... note). Run the command it shows as-is: it names the shell-quoted interpreter the hook itself resolved ahead of the absolute store.py path, so it cannot hit a stub python on Windows. The store.py path is the second quoted token in that command. On Windows it will be a Windows-format path like C:\Users\...\plugins\data\zmem@...\skills\memory\scripts\store.py (the ... segments are elision placeholders, not real paths). If you cannot find the injected path, the script is at the plugin root under skills/memory/scripts/store.py — invoke it through an explicitly resolved interpreter (py -3 on Windows, python3 on macOS/Linux), never as a bare path and never bare python3 on Windows (Store stub).

Local vs remote mode (issue #289)

With ZMEM_REMOTE_URL set, this machine's memory store lives on a hub and every store.py subcommand routes through the fleet backend — no local store.sqlite is ever created or read:

  • Writes (add, update, supersede, invalidate) enqueue a durable op and print a receipt line {"ok": true, "op_id": ..., "status": "pending", ...} — the op flushes to the hub on the next read moment. The routed write accepts --namespace --type --content --tags --signal --source-ref --capture-mode and, since 0.129.0 (issue #326), the expiry pair --expiry-class --inject-until on add and update (a state add requires --expiry-class; an update may omit --type and the target's type resolves when the hub applies the op — issue #317). Occurrence flags (--occurred-*, --mentioned-at) and --type event still exit 2 naming the flag (occurrence semantics do not travel); so do --confidence --taint --evidence --no-dedup --reason, an update that names a non-state type while carrying expiry flags, and a state add without --expiry-class. --content - still reads stdin.
  • Reads (recall, search, recent, list, get) always print the JSON envelope (with or without --json) plus a [PENDING] trailer for ops queued but not yet confirmed by the hub. Reads accept only the query flag, --namespace, --limit (and --json); search's query flag is --text. On failure they print {"ok": false, "reason": "hub-unreachable"} (or hub-response-invalid) and exit 1.
  • Store-independent commands keep running locally: failures, signals, corrections, queue-*, source-exists, source-ref-exists, ops-append, retain, retain-adapt, ledger-clear, delivery-clear, hermes-reflect, hermes-context --action commit-cursor.
  • Everything else (maintenance and admin: consolidate, organize, reembed, purge, export-*, init, backup, evidence, apply-ops, …) exits 2 with this command runs on the hub; ZMEM_REMOTE_URL is set — run it on the hub machine.
  • Config rule: ZMEM_STORE together with ZMEM_REMOTE_URL is a configuration error (exit 2). ZMEM_DATA alone is fine — it names the local data dir (op queue, sidecars).

Offline degrade (issue #290): when the hub is unreachable, reads fall back to the machine's broker snapshot — a read-only, principal-scoped copy under <data>/broker/snapshot.sqlite that expires 72 h after issue and is refreshed hourly and on every hub reconnect by the broker (never by hooks). A served answer carries the label [OFFLINE SNAPSHOT r<rev>, <age> old] (broker-emitted fence header on the Inject path; an additive offline_snapshot provenance key on CLI reads, with a stderr note — stdout stays JSON-parseable). An expired or missing snapshot keeps the plain hub-unreachable error on the CLI; the Inject path logs snapshot-expired instead. A row served offline is advisory and may be re-delivered live after reconnect; the snapshot never merges with live data — a reconnect supersedes it.

With ZMEM_REMOTE_URL unset, every command is local and unchanged.

When to use

  • Before a non-trivial task: recall relevant past lessons. High-precision-first: if a retrieved lesson does not clearly apply, ignore it.
  • After a failure with a generalizable lesson: add a lesson grounded in an external signal (test/compile/lint/reviewer/user). The reflection Stop hook will prompt you automatically when failures are detected.
  • When you learn a stable fact or convention: add it.
  • When a memory is stale/wrong: supersede it for a general tombstone; use the dedicated invalidate command when a fact is no longer true (it REQUIRES a reason so the correction is auditable); use update to revise a memory append-only (the old row is tombstoned, a new live row links back via update_of, and point-in-time --as-of recall still sees the old content).

Commands

Store commands run <interpreter> <store.py path> <subcommand> — use the interpreter the injected command names, or an explicitly resolved one (py -3 on Windows). Never bare python3 on Windows — that is a Windows Store stub — and never a bare store.py path (pin the interpreter explicitly; a bare .py path depends on platform dispatch). doctor is the one separate diagnostic script shown below.

doctor — read-only install diagnostics

python <doctor.py path> [--project <repo>] [--repo-root <zmem-repo>] [--format human|json|both]

Read-only preflight for cutover and operator debugging. It never mutates the store or host config. Checks:

  • resolved store path and split-brain env/config risk
  • local/non-OneDrive store path safety
  • Python version (supported floor 3.11) + SQLite FTS5
  • Node and a usable Git Bash/Cygwin shell on Windows
  • best-effort read/write access to the store path
  • schema compatibility against current v19
  • v9 append-only lineage columns present (valid_until/update_of/taint)
  • v10 entity identity tables present and non-vacuous (entity/entity_alias/ memory_entity); inspect deeper with store.py entity-list
  • v11 link surface present (memory_link table + memory.trust_score in range [0,1]); inspect deeper with store.py links --id <uuid>
  • v13 episode storage present with counts (episode-tables check), and the MCP token scope advisory (mcp-token check: warns unscoped_token: true on full-access operator tokens; in principal-map mode (ZMEM_MCP_TOKENS_FILE) reports scoped/unscoped/revoked counts instead — never a token value)
  • Claude/Codex native-memory conflicts via read-only config inspection
  • ZCode native memory (zcode-native-memory check, issue #185): ~/.zcode/v2/setting.json memoryEnabled: true fails cutover, explicit false passes; unreadable/missing setting warns — never auto-edited
  • host install-skew (issue #185): duplicate-install (fail) when more than one enabled user-scope zmem install is registered for a host; marketplace-skew (warn) when an installed cache version differs from the marketplace version its registry entry points at; project-pin (warn) when a project-scoped zmem pin is behind the enabled user-scope install. Registries are read through the #184 strict codecs; missing registries skip and malformed ones warn — doctor never edits host state
  • Codex manifest hook trust (untrusted-hook check, issue #185): compares the pre-approval events the repo manifest registers (SessionStart, PreToolUse) with the events the Codex config records trusted for the repo (falling back to the box-wide union when no entry names this repo — on a multi-repo box another repo's approval can stand in, so treat a pass as inventory, not proof). Missing registered events warn untrusted-hook <ids>; reapproval is always manual
  • orphan-store inventory (issue #185): warns with schema=/rows= for every non-canonical SQLite store on the known host paths (plugin-data env dirs, ~/.zcode/memory/store.sqlite); inspect then merge with promote-store --from <path> — doctor never deletes or migrates
  • canonical namespace for the provided project
  • host surface presence (Claude plugin, Codex plugin, ZCode plugin, memory skill — all four are REQUIRED surfaces; a missing Codex plugin file fails host-surfaces, and the repo-local Codex adapter lane never shipped)
  • served-tree drift vs release-manifest.json (served-drift check, issue #107): matched/drifted (warn — first 10 differing paths + both digests)/ unknown-skip when the tree predates 0.17.0 and carries no manifest, and unknown-WARN when a manifest is present but corrupt (failed its algorithm/digest integrity gate — the served mirror itself is damaged). Log-only; a drifted session start also appends one zmem-drift line to zmem-bg.log and shows the operator a systemMessage — never model context.

Use it before first install, before cutover, and after any store-path or hook surface change.

doctor --miss-rate — the miss-rate join + false-injection counter (issues #94, #129)

python <doctor.py> --miss-rate --store <snapshot-store.sqlite> [--miss-db PATH]
                   [--miss-transcripts GLOB ...] [--miss-bg-log PATH]
                   [--miss-window-before 1800] [--miss-window-after 300]
                   [--miss-limit 200] [--miss-min-overlap 2] [--miss-verbose]
                   [--format json]

Opt-in check that measures the miss rate — "a failure occurred in a session, a matching memory existed in the store, and nothing surfaced at that moment". It joins mined tool failures (ZCode episodic db via --miss-db, transcript JSONL globs via --miss-transcripts) against a snapshot of the store (recall with telemetry fully disabled, link_hops=0) and the bg log's injection decision lines — BOTH shapes: writer A's reason=injected and the session-start writer's status=injected lines (that writer has never carried reason=). Definitions (pinned):

  • missed — failure + floor-passing store match + no injection of a matched row in the window ⇒ counts toward the miss rate.
  • capture-gap — failure + NO store match (write-side problem, counted separately).
  • surfaced (sid) — an injected line whose sid= proves it is this session's decision carries a matched row id.
  • surfaced (legacy) — same evidence on a pre-#94 sid-less line or a sid=unknown line (window+overlap attribution only; miss_rate_strict_sid excludes it).
  • no-query — nothing derivable for the failure (no recovered operation and no ops ring): unmeasurable, excluded from every rate. On boxes where the ops-ring lane is not yet deployed this bucket can dominate until rings accumulate.

Issue #129 adds the OTHER gate direction to the same report: the false-injection rate — of the injected decision lines, how many were NEVER referenced by any later same-session operation, prompt, or captured failure. A line counts as USED when a later reference (mined failure operation/error text, the session's ops-ring events, or transcript prompts) contains one of its memory ids literally, or shares >= --miss-min-overlap (default 2) distinct ops tokens with the injected row's content. Rates print overall AND per moment (session_start, user_prompt, pretool, subagent, precompact; pre-#129 lines without moment= report under legacy with a caveat). Each decision LINE counts once — the same row re-injected at two moments is two denominator lines, never double-counted.

Decision telemetry lives in <data dir>/zmem-decisions.log (issue #129 split): maintenance output and zmem-drift lines stay in zmem-bg.log. Both logs rotate instead of truncating — ZMEM_BG_LOG_MAX_BYTES (default 262144) caps the active file and ZMEM_LOG_ROTATIONS (default 3) keeps bounded segments named <name>.1..<name>.N, each stamped with a # zmem-seq= marker; the join and the counter read rotated segments. Legacy deployments with only zmem-bg.log keep working: readers fall back to it when no decisions log exists, and an explicit --miss-bg-log wins over both.

REFUSES to run without an explicit --store, and refuses the host-default store even when given explicitly — the join reads session data, so snapshot first: copy store.sqlite AND any store.sqlite-wal/-shm beside it into a temp dir (plus zmem-decisions.log and its rotated .1...N segments, zmem-bg.log, and the ops/ ring dir when present), then pass that path. exit 1 from --miss-rate most often means exactly that: snapshot the store and re-run with --store; exit 1 from other checks means a real diagnostic failed. Read-only everywhere: the store/db open mode=ro, the report never writes, and a missing/old store is an error (never created, never migrated). Memory content stays out of the default output (ids/namespaces only; --miss-verbose adds a short preview).

page — read, list, and refresh curated pages (issue #138)

<interpreter> <store.py> page read --id <page-id> [--version <version-id>]
<interpreter> <store.py> page list [--namespace <namespace>]
<interpreter> <store.py> page refresh --id <page-id> --query "<source query>" \
  --namespace <namespace> [--tag <tag>]... [--llm-local]

Curated pages are local filesystem artifacts grounded in canonical memory. read and list are SQLite-independent and never create, migrate, or open a store. refresh requires an existing store, uses the normal read-only and maintenance coordination, and writes no canonical memory rows. The namespace, resolved data root, page ID, and regular contained page paths are validated before source selection. New candidates without evidence are excluded before publication; if a previously published live source loses its evidence during revalidation, refresh refuses and preserves the committed pointer.

--tag is repeatable. Each tag is one literal over stored comma-separated tokens: only delimiter-edge whitespace is trimmed, while internal spaces and case are exact. A comma in a requested tag is literal input, not a wildcard, and stored commas have no escaping extension. A blank or whitespace-only query keeps the existing recent-query selector behavior; a nonblank query that normalizes to zero terms has zero lexical coverage. The refresh snapshot and its source/evidence associations are revalidated before publication, so a changed source causes a refusal and leaves the prior committed pointer intact.

The optional --llm-local adapter is enabled only by setting ZMEM_PAGE_ADAPTER_ACTIONS to a non-empty local JSON action file. It is a bounded data-only input for offline maintenance; it cannot run commands, load code, access the network, or name files. The page CLI does not populate or drop a cache as part of these operations. ZMEM_PAGE_ADAPTER_ACTIONS must be a nonblank path to an existing file; a missing or unusable configured path is refused before the store is opened. An existing recording is opened and validated during refresh, so unreadable bytes, malformed JSON, and over-limit actions follow the normal atomic refresh refusal. An adapter failure leaves canonical memory and the last committed page unchanged.

Passive page verification uses a finite cost policy. Metadata discovery may scale with the page directory, but expensive source/evidence verification is limited to the 50 namespace-eligible committed pages with the greatest canonical source snapshot watermark (maximum source ingestion_ts plus snapshot hash), ordered descending and then by page ID ascending. The eligible set is the requested namespace plus user:global, and both share this one 50-page cap; a newer global page can therefore displace a page from the requested namespace. A 51st eligible page is deterministically withheld. It re-enters only when a later refresh advances its canonical source snapshot watermark sufficiently; refreshing an unchanged snapshot preserves its watermark and may leave it withheld. Foreign namespaces remain outside the eligible set. This is a selection bound, not a universal filesystem latency guarantee.

Publication stages into a nonce directory and replaces current.json last. Default and passive readers use only the valid pointer, so interrupted staging or an unreferenced version is residue for operator maintenance. An explicit page read --version selects and validates the requested version file directly; it can read a valid unreferenced version left by interrupted publication and does not prove that the version is the committed current pointer. There is no automatic garbage collector. First-publication recovery takes a fresh authority snapshot and preserves immutable history. Cleanup removes a whole inspected artifact and retries the operation; it never deletes an individual historical version as a repair. Directory fsync is used where supported, with explicit Windows directory durability limits and no universal crash-atomicity claim.

Version-2 pages privately bind each represented source to its UTF-8 content hash. The public shapes remain stable: page_read exposes the existing version keys plus content and namespace (and optional bullet_sources), while page_list and page_for_injection retain their existing key sets. The private format_version and source_content_hashes fields never appear in those public results. Legacy version-1 pages remain operator-readable but are withheld from passive delivery until a normal refresh creates a bound version; unknown formats are refused. Content changes withhold the page and require refresh, without rewriting old history.

Before canonical purge deletes live rows, it preflights derived needles against the whole pages artifact directory. An existing needle, unsafe path, or unscannable artifact fails closed before the row-deletion transaction. The operator inspects and removes the whole affected page or staging artifact, then retries; historical page versions are never individually deleted as cleanup. When page artifacts exist, the preflight intentionally snapshots the full live SQLite database into an in-memory connection, reapplies the pending purge there, and derives needles from the exact post-purge survivor set. This privacy check has memory and latency that scale with the store; no performance bound is promised. If the snapshot or artifact scan cannot complete, purge refuses before live row deletion. Passive credential withholding controls delivery only; it does not promise encryption or redaction of page content at rest.

The current observation gate is recorded as reject, so page metadata omits runbook (and since 0.107.0 the ALLOWED_TYPES vocabulary is the EIGHT-type set — entity_card joined as the card-refresh-only type; see "Entity cards (issue #179)" below).

event — occurrence-typed memories + the temporal recall arm (v18, issue #178)

<interpreter> <store.py> add --type event --namespace NS --content TEXT   --occurred-start 2026-09-01T08:00:00Z --occurred-end 2026-09-03T08:00:00Z   [--mentioned-at TS]
<interpreter> <store.py> recall --query "last week's outage" --namespace NS   --prompt-timestamp 2026-09-10T12:00:00Z [--as-of TS]

Schema 18 adds the event memory type and the memory_occurrence side table (occurred_start / occurred_end / mentioned_at beside the memory id). Event rows require BOTH occurrence endpoints (clamped to the temporal window: the future clamps to the prompt timestamp, older than 3650 days clamps to the floor); non-event rows refuse any occurrence field (exit 2, no partial row). Occurrence rows live with their parent and SURVIVE soft tombstones of USER event rows (update / supersede / consolidate-absorb) — historical as-of recall surfaces them inside the tombstoned row's validity window; generated-row writers (organize summaries, entity reconcile) keep same-transaction cleanup; hard deletes (purge, import-store) remove occurrence rows through the side-table registry; event->event updates additionally inherit the occurrence onto the replacement row (non-event->event via update is refused; dedup folds keep the keeper's occurrence).

The temporal recall arm resolves relative phrases in the query (the fixed 20-phrase vocabulary: today/yesterday/tomorrow, this/last/next week, this/last/next month, this/last year, past 24 hours/7 days/30 days, two/three days ago, in two days, earlier today, this/last quarter — longest-match-first, so "earlier today" wins over "today") against the --prompt-timestamp (default: the ZMEM_TEST_NOW seam / wall clock), selects occurrence-overlapping rows under the unchanged --as-of validity predicate, and fuses them as the capped fifth RRF arm (ZMEM_ARM_CAP_TEMPORAL, default 4). Knowledge validity (valid_from inclusive / valid_until exclusive) is SEPARATE from occurrence time. Recall rows reached via the temporal arm carry additive occurred_start / occurred_end / mentioned_at envelope fields (the time the event HAPPENED), distinct from valid_from (the time it was WRITTEN). The three keys ride only temporal-arm rows (never non-temporal rows, never overwriting an existing key), so projections that select known fields are unaffected. A phrase-free query or an occurrence-empty store fuses byte-identically. The Hermes/MCP add surfaces do not advertise event (they take no occurrence endpoints — the #173 state precedent).

recall — surface relevant memories (high-precision)

<interpreter> <store.py> recall --query "<query>" [--namespace NS] [--limit 5]
  [--link-hops 0|1] [--link-budget N]
                          [--include-global] [--global-limit 3] [--hybrid]
                          [--no-hybrid] [--no-mmr] [--no-bump]
                          [--as-of ISO-8601] [--no-unfold] [--json]
<interpreter> <store.py> recall --query "<query>" --explain [--target ID|FRAGMENT]
                          [--json]

Returns live (non-superseded) memories matching the query, filtered by confidence floor (>=0.25) and namespace — unless --as-of ISO-8601 is given, in which case it is a point-in-time read that returns rows valid at that instant (a row superseded after that instant may be included; a row created after it is not; see the --as-of notes below). Prefer --namespace project:<basename> to scope to the current project; use user:global for cross-project.

Retrieval debugger: --explain / --target (issue #82)

recall --explain re-runs the REAL pipeline (same lanes, same floors, same merge) and prints one blameline per verdict explaining why a row did or did not surface. It is an operator diagnostic in the same spirit as doctor.py: ZERO writes (the telemetry path is never reached — stricter than no_telemetry), it never unfolds, it never fails a recall (a thrown tracer degrades to the explain_unavailable verdict and the results still print). --target takes a memory id (full or unambiguous prefix) or a content fragment (case-insensitive substring, then token overlap >= 0.7); multiple matches produce one verdict per id, never a guess. With --json the read envelope gains an explain object: query, query_shape (the normalized terms + exact FTS MATCH expression, #112), target, the effective settings (namespace, limit, include_global, global_limit, no_mmr, no_bump, as_of, hybrid), and verdicts[] with id/reason/rank/score/detail.

Verdict reasons are a CLOSED set (EXPLAIN_REASONS in storelib/recall.py):

reason meaning
found in the presented result set at rank (1-based)
below_limit retrieved and scored but beyond --limit
below_floor the row's confidence is below the effective floor (the recall min_confidence parameter, default CONFIDENCE_FLOOR)
omitted_injection would have ranked; dropped because no_bump and prompt-injection risk
omitted_untrusted_web would have ranked; dropped because no_bump and taint=untrusted_web
namespace lives outside the query's expanded namespace set (and --include-global did not admit it)
superseded live-only recall and superseded_at is set (detail.successor_id names the live replacement when one exists)
not_valid_at_as_of --as-of set and the validity interval does not contain it
vec_lane_miss --as-of + hybrid: the row was valid then, but the vec KNN pool is live-rows-only (the SKILL.md as-of caveat — this is why history-on-hooks is not "just pass --as-of")
not_in_pool not in the FTS, vec, or entity candidate pool at over-fetch depth
not_in_db no row matched --target (detail.neighbors lists up to 5 nearest live rows by token overlap)
explain_unavailable the tracer threw; results still returned (fail-open)

For an injection-path explanation, combine --for-injection --explain. This is a read-only replay: it follows the same omit, selective-inject, score-margin, and token-budget stages as a passive injection, but never writes surface or retrieval telemetry. A target removed by the score-margin stage is reported with the closed-set reason margin_pruned; its detail includes the observed relative margin (formatted to six decimals), the configured threshold, and the retained top-row id. The existing omit, selective, scope, validity, and budget reasons remain unchanged for rows rejected by those stages.

Change-intent lineage unfold — explicit recall only (issue #82)

Already-stored lineage (update_of from update) becomes visible on the EXPLICIT path: when the query reads as change-intent ("what changed about X", "why did we switch to Y", "we used to Z") AND the presented results contain a live head of a tombstoned predecessor, recall appends the predecessor chain as budgeted extra rows tagged [PREVIOUSLY] (JSON keys unfold_of + unfold_hop, 1 = immediate predecessor). Extras never count against --limit, never enter the telemetry bump set (popularity rewards query matches, not neighbors), never cross namespaces, and are never silently dropped for taint — the explicit surface prefixes [INJECTION RISK] / [UNTRUSTED WEB] like any other row.

It runs ONLY when all of these hold: not --no-bump (hooks, PreCompact, Hermes prefetch, and eval defaults never unfold), not --no-unfold, the query matches the compiled change-intent regexes (deterministic, no LLM), and link_hops >= 1 (the same contract that keeps search — CLI, MCP, and Hermes _tool_search — byte-identical; MCP recall unfolds for free). --explain describes lineage in verdict detail but never injects rows.

Env var Default Meaning
ZMEM_UNFOLD_TOP_K 3 max presented hits to walk backward from (clamped 1-10)
ZMEM_UNFOLD_MAX_HOPS 3 max update_of hops per chain (clamped 1-10)
ZMEM_UNFOLD_BUDGET 4 hard cap on total [PREVIOUSLY] extras per recall (clamped 1-20)
ZMEM_FAILURES_DB_TIMEOUT_S 1.0 Stop-hook db reader busy-wait budget in seconds (clamped 0.1-5.0; invalid → default; failures --db-timeout overrides)
ZMEM_ZCODE_DB ~/.zcode/cli/db/db.sqlite ZCode episodic db the Stop-hook detector reads (empty/unset = default)
ZMEM_REFLECT enabled set to exactly 0 to disable the Stop hook entirely

Confidence floors (issue #58, 3.8)

Five distinct floors live on the recall path. Each reflects a different surface's precision-vs-coverage tradeoff. They are env-overridable; the constants live in schema_meta.py.

Constant Default Env override Used by
INJECT_FLOOR_PROMPT_DEFAULT 0.25 ZMEM_INJECT_FLOOR_PROMPT recall (UserPromptSubmit / PreCompact). Hard floor on FTS/vec results — anything below is dropped before scoring.
INJECT_FLOOR_RECENT_DEFAULT 0.5 ZMEM_INJECT_FLOOR_RECENT recent (SessionStart / subagent recall). Tighter because the surface is high-confidence recent material, not query-best match.
INJECT_FLOOR_GATE_NONE_DEFAULT 0.4 ZMEM_INJECT_FLOOR_GATE_NONE Hook selective-inject gate. signal=none rows must clear this floor; grounded-signal rows (test/compile/lint/reviewer/user) keep the 0.25 floor.
INJECT_FLOOR_TRUST_DEFAULT 0.2 ZMEM_INJECT_FLOOR_TRUST Selective-inject gate (issue #115). Hard floor on trust_score: a row the contradiction ledger has driven below 0.2 (nine or more distinct contradict events) can no longer ride the passive lane; compute_score also multiplies every composite by trust_score (identity at the default 1.0, so uncontradicted rankings do not move — explicit recall/search keep retrieving the row, just ranked lower). When E-5 (#124) starts recording violations automatically, they must feed THIS ledger via trust deltas — violated_count never becomes a second independent gate input.
INJECT_FLOOR_USER_GLOBAL_DEFAULT 0.5 ZMEM_INJECT_FLOOR_USER_GLOBAL user:global tier floor on the passive injection lane (issue #235). On gated moments (pretool/subagent — the hooks' posttoolbatch maps to pretool store-side) a global-tier injection candidate whose MAX measured relevance lane sits below 0.5 is withheld and its reserved slot returns empty rather than being backfilled; the envelope reports the count as global_withheld (present only when a withhold happened). A global candidate with NO measured lane (query-less recent pulls) keeps the not-measured exemption — never withheld by this floor; the user:global floor is armed on the subagent moment for every pull; a bridged task-keyed SubagentStart recall (issue #253) additionally gives that surface a measured relevance lane, while query-less pulls keep the not-measured exemption. session_start/user_prompt/precompact keep their current global composition. 0 disables (relevance values are >= 0, so nothing is ever below it).

The floors are intentional. Do not silently unify them. The selective-inject gate (3.8) is a passive-lane filter; the trust floor applies symmetrically to link-expansion neighbors that ride the passive lane (a once-contradicted neighbor at trust 0.9 still renders with its [CONTESTED LINK: <id>] marker naming the other end of the contradiction).

Score-margin gate (issue #182)

ZMEM_INJECT_MARGIN is an opt-in score-separation gate between the existing selective-inject filter and token-budget admission. It defaults to 0.0, which disables the gate and preserves the legacy injection bytes. The setting is read dynamically for each injection decision. A missing, negative, non-numeric, NaN, or infinite value fails open to 0.0; values above 1.0 are clamped to 1.0. The recommended initial rollout is 0.05.

With a positive threshold and at least two usable post-selective rows, the gate compares the two highest scores using the stable score-descending view and computes the raw (top - second) / top. Every candidate score must be usable; a malformed, scoreless, or non-finite score anywhere fails open. When the raw margin is strictly below the threshold, ordinary rows after the winner are pruned before the token budget runs. A decision or constraint in either leading position is protected: no rows are pruned, but the valid observed margin is still reported. Fewer than two rows and a non-positive top score also fail open. The original presentation order is retained for surviving rows.

When a valid score pair was observed, injection JSON adds margin formatted to six decimal places and margin_pruned_ids in prune order; no fields are added when the gate could not make a decision. The hook decision line appends the same diagnostics, in the order margin= then margin_pruned=[...], after the existing optional fields. Empty pruned lists are omitted from the hook line, and malformed optional values are ignored independently so legacy logging remains fail-open and byte-compatible.

When a passive inject surfaces nothing, it names WHICH gate fired (issue #87 / #85 direction 1): no durable memories retrieved for this prompt. means the candidate pool was empty OR every retrieved row was dropped by the passive injection-risk filter — the one-liner is shared by design (the model is never taught that omitted rows existed); the zmem-bg.log line distinguishes them. no durable memories met the inject bar. means rows reached the selective-inject gate and none passed. A token-budget wipe says so in its own words (memories withheld: the injection token budget (ZMEM_INJECT_TOKEN_BUDGET) dropped every candidate row.). Hermes/MCP session_start use the session variants (no durable memories retrieved for this session. and session memories withheld: ...). zmem-bg.log carries the same cut per decision line: every zmem-hook line has reason= (reason=empty-pool, reason=omitted, reason=below-bar, reason=budget-drop, reason=below-relevance, reason=already-delivered, reason=injected), plus omitted=N when the passive injection-risk filter dropped rows. The closed set lives in schema_meta.py (INJECT_SILENT_REASONS). Since issue #94 every line also ends with sid=<sanitized session id> ([^A-Za-z0-9._-] → _, cap 128; sid=unknown when the host sent none) — the session key doctor's --miss-rate join binds failures to injections with. The order is status, reason, omitted=, ids=, all=, tokens=, rendered_estimate=, admission_budget=, budget_dropped=, budget_truncated=, budget_dropped_protected=, sid= (the retired ops= ring count rode before sid= in releases <= 0.37; issue #158 moved operation context into the query, not the log). Current lines append exc=, moment=, optional lane=, ver=, t_ms=, then arms=, batch=, tools=, paths=, and margin fields (the budget fields are issue #116's distinct labeled numbers and ride only when budget accounting ran).

Decision attribution and report projection (issue #153)

The additive decision suffix is ordered lane=, ver=, t_ms= after the historical moment= field and before later additive tails (arms=, batch=, tools=, paths=, and margin fields). Closed lanes are claude, codex, zcode, deepseek, hermes-provider, and hermes-compat; runtime moments are session_start, user_prompt, pretool, subagent, and precompact. Silent reasons are exactly empty-pool, omitted, below-bar, budget-drop, below-relevance, already-delivered, and expired (expired names a pool that was eligible before the live-expiry gate drained it — issue #174's producer; every other condition keeps its precedence). The decision line can additionally carry the transport tokens reason=store-error — a store child that exited nonzero (e.g. a partial-upgrade store rejecting an unknown flag) — and reason=envelope-error — a store child that exited zero whose stdout was not a JSON envelope (issue #435); both are emitted by the hook body and the hermes-provider lane, and miss-rate reporting excludes both from the missed count like the other negotiation refusals. reason=injected is a successful decision and reason=disabled is the separate kill-switch marker.

already-delivered is selected when pre-ledger candidate ids are nonempty but the post-ledger pool is empty, after budget-drop in the precedence order; dual-empty remains empty-pool. ver= and t_ms= are an all-or-nothing enrichment pair: ver= is the release-manifest semver and t_ms= is a nonnegative rounded perf_counter duration. A manifest/version failure keeps the complete legacy line and omits all attribution fields. A lane is optional for compatibility callers, but an explicit lane must be closed-set.

parse_bg_log accepts legacy lines without attribution. If any attribution token appears, it requires valid ver= and decimal nonnegative t_ms= and refuses malformed enrichment; moment= remains open for compatibility values. Lane-less enriched lines and moments outside the report set stay aggregate-only. The named report is always a sorted, zero-filled 20-row matrix: five lanes × the four report moments session_start, user_prompt, pretool, and precompact. The runtime subagent moment and session_start_compact are intentionally excluded from that 5×4 projection but remain visible in aggregate statistics. The local Hermes provider writes hermes-provider; the remote compatibility path writes hermes-compat, and invalid explicit lanes return structured status 2 before store work while absent lanes stay omitted.

store.py report — per-session repeat and composition report (issue #249)

store.py report [--decision-log <path>] [--json] aggregates the decision log into the repeat rate and composition tables, read-only. It is the instrumentation Workstream P PRs 2-4 use as their before/after baseline.

  • rows[<id>] — delivered (total deliveries), sessions (distinct sid= count), max_per_session (busiest single session), and the row's tier. Lines carrying no usable sid= count toward delivered but contribute no session, and the run says so in caveats. The writers' sid=unknown placeholder counts as no session: it is what every host without a session id emits, so pooling it would invent one session and attribute unrelated deliveries to it. This matches how the sibling readers already treat it.
  • by_tier_moment[<tier>][<moment>] — delivered (the ids= entries) against withheld (the all= candidates that were not injected). delivered counts only lines the shared injected-line predicate accepts, so ids on a status=silent line are never reported as deliveries; their candidate pool is still counted as withheld, because a candidate was offered either way.

Top-level JSON keys: decision_log, decision_lines, ts_first, ts_last, rows, by_tier_moment, caveats. ts_first/ts_last are the decision timestamps actually covered, so two runs can be checked for an overlapping window before a before/after difference is read as a behaviour change.

--decision-log defaults to <data dir>/zmem-decisions.log, falling back to the legacy zmem-bg.log. Rotated segments (.1 … .N) are included. Without --json a human table prints; with --json stdout is exactly one compact JSON document, so it can be piped into jq unchanged — including on a refusal, where stdout carries {"ok": false, "error": "..."} at exit code 2 and the human [zmem] report: line goes to stderr. A missing or unreadable log is reported as a caveats entry rather than a silent empty report, because an empty report and an unreadable log are otherwise indistinguishable.

Tier labels come from the row's stored namespace: a project: namespace is project, user:global is global, and anything else — including an id no longer in the store — is unknown. The live five-tier scoped recall model spells its global tier user_global and adds domain / fleet_host / cross_project; its cross-project tiers depend on the CALLER's current project, which an offline report does not have, so it does not invent them.

Read it with caveats. Tier labels come from a point-in-time snapshot; an id missing from that snapshot is reported as unknown. The log-derived figures and the tier-summed totals are unaffected, but the per-tier split can shift, so unknown-bucket movement between two runs is not evidence of tier migration.

The command never writes to the store, the ledger, or the log. It opens the store through an immutable=1 handle only when there is no residue at all, or through a private staged copy of store.sqlite + -wal + -journal when there is; a plain read-only open would create sidecars and mutate -shm in place. A hot rollback -journal also forces the staged path on purpose: immutable=1 ignores the journal entirely and would report the uncommitted pages of an in-flight transaction as real rows. The staging directory is created owner-only and is removed on every path including failures; a directory that survives removal is never left silent — on a successful run it is appended to caveats, and on a refusal it is reported alongside it (folded into the refusal message, or on its own [zmem] report: stderr line for a non-ReportError failure). -shm is never copied — SQLite rebuilds it from the -wal, and copying it fails outright under a live writer on Windows.

Query context (prior-turn operation tokens) — issue #88 / #85 direction 2

Decision-point prompts are prose with zero lexical overlap with the operation-adjacent lessons that matter; the retrieval signal lives in the tool commands the session is executing. The PostToolUse hooks (convention-capture on the coding hosts, post_tool_call on Hermes) append each Edit/Write/Bash event to a per-session ring at <data>/ops/<session>.log, storing ONLY the tool name plus allowlisted tokens (git subcommand chains, test-runner verbs, edited-path basenames) — never a raw command dump, stdout, or argument values that are bare words; secret-shaped tokens (common credential prefixes like ghp_/sk-/xox?-) are dropped as well. The ring is byte-capped: past 64 KB it is trimmed to the newest 64 lines. The store-owned passive selector composes that ring only for the pretool moment, with the ops tail occupying a fixed reserved slice INSIDE the 500-char cap (never appended past it; the separator shares the slice, so no token is severed). UserPromptSubmit and Hermes prefetch no longer append a caller-owned ops tail. ZMEM_QUERY_CONTEXT=0 is the lane kill switch — it stops BOTH composition and ring collection (an operator disabling the lane expects no sidecar writes). The ring resolves through the same unified data-dir chain as the decision log (hooks/lib/zmem-recall-body.py::_data_dir(), which resolves the decision-log data directory: a directory-qualified ZMEM_STORE parent first, then ZMEM_DATA / CLAUDE_PLUGIN_DATA / ZCODE_PLUGIN_DATA, then ~/.zmem) — read_ops_ring(data_dir, ...) reads the ring from exactly the directory it is given, including plugin-data directories by #88 design. Operation derivation remains store-owned; no operation count crosses the exact hook envelope. Explicit surfaces (recall --query, search) are unchanged. Limitation (deliberate, see #88): this helps LATER turns only — the first tool call of a turn still runs before any operation context exists. Cost note (#93 B4): credential-prefix-shaped tokens (sk-, npm_, AKIA, …) are dropped from the ops query EVEN when they are legitimate filenames — fail-safe direction (signal loss, never a leak); rename a real path that collides. The eval composer ignores ZMEM_QUERY_CONTEXT by design (#93 B6): evals must stay deterministic and immune to ambient env, so the kill switch does not change their queries.

Pre-tool checkpoint phrases — issue #99

PreToolUse passive recall also enriches the query from the current raw tool_input object before the tool runs. The store serializes that object as sorted compact JSON (sort_keys=True, separators , and :), examines exactly its first 150 Unicode characters, and selects at most one entry from this immutable ordered table:

Event Checkpoint phrase
stash-consume foreign-stash conflict verify stash list
reset stale tree fetch main rebase verify diff
force-push stale tree fetched base force-with-lease
branch-publication stale tree fetched base force-with-lease
base-rewrite base drift citation re-pin
path-test basename ratchet citation re-pin local battery

The recognized forms are git stash pop|apply|drop, git reset --soft|--hard, git push --force|--force-with-lease|-f, ordinary git push, git merge --squash, and test/citation paths. git stash list, unknown operations, non-object input, and matches beyond character 150 add no phrase. Current-event operation tokens take precedence over an older session ring; the ring remains the fallback. The operation-token tail retains its 150-character reservation, the checkpoint phrase is never split, and the final query remains bounded to 500 Unicode characters. Raw tool input crosses only the private bounded hook-to-store stdin channel and is neither logged nor persisted. The ZMEM_QUERY_CONTEXT=0 kill switch disables both token and phrase enrichment.

This feature shipped under issue #155's published measurement decision (the 2026-09-19 fail-closed INSUFFICIENT outcome); #155's predeclared measurement of record has since landed (eval/real-corpus-2026-09-19.json — an audit over a frozen cohort, not a live efficacy claim), so the gate is satisfied without an efficacy claim by this feature.

Passive-injection kill switch (ZMEM_INJECT=0) — issue #110 / P0-5

ZMEM_INJECT=0 disables every passive recall-injection surface: the shared recall body (all modes — user_prompt, pretool, subagent, precompact/recent), the SessionStart hook (Tier 2 recall AND Tier 0 — under the switch SessionStart emits its empty {} envelope), the Hermes provider (prefetch, the zmem_session_start tool twin, and the system-prompt core.md block), the Hermes reflect hook's delivery paths, and MCP session_start. Each silenced surface emits its empty envelope and logs status=silent reason=disabled (reason=disabled is written only by this switch, never by silent-reason classification — it lives beside injected as INJECT_REASON_DISABLED, not in INJECT_SILENT_REASONS). Only the literal 0 (whitespace-tolerated) disables — the ZMEM_QUERY_CONTEXT convention; false/no/empty leave injection enabled. Capture paths never consult the switch: correction capture, the ops ring, convention counters, failure capture, and session-cadence maintenance keep writing — and the capture-side PROMPT hooks (zmem-reflect.sh, zmem-subagent-reflect.sh, zmem-convention-capture.sh, capture-failure/correction prompts) stay active by design (#110: they prompt capture, they do not inject recalled memory; the Hermes reflect hook is the documented divergence — a named gated surface whose delivery carries recalled query-context). Parked pre-tool fences and armed nudge markers are left in place and deliver on the first enabled run. doctor shows the state (inject-switch line, WARN when disabled). Near-miss spellings (0.0, 00, False, off) do NOT disable — the parser matches the exact literal 0; verify with doctor after setting.

Capture kill switch (ZMEM_CAPTURE=0) — issue #123

ZMEM_CAPTURE is a string environment variable with default "1". The failure, convention, Stop, SubagentStop, and Hermes compatibility surfaces read it before payload parsing, marker/queue writes, lesson queries, operation appends, or other stateful work. Only a trimmed literal 0 disables capture; undefined, empty, whitespace, false, and 00 stay enabled. Disabled shell surfaces emit their exact empty sentinel and Hermes emits {}. Audits and probes set ZMEM_CAPTURE=0 so those five covered surfaces cannot teach the system.

This is independent of ZMEM_INJECT (delivery) and ZMEM_QUERY_CONTEXT (operation-ring collection). Under ZMEM_INJECT=0, capture can still run; under ZMEM_CAPTURE=0, no capture subprocess or state mutation runs on those five surfaces. The pre-existing UserPromptSubmit correction queue and Hermes convention-compatibility cadence are separate legacy paths with their own capture-mode/interval controls. Complete <<<ZMEM_UNTRUSTED_FENCE>>> through <<<END_ZMEM_UNTRUSTED_FENCE>>> blocks are removed before transcript-derived content reaches deduplication, queue synthesis, or closeout review.

Pre-tool repository-state predicate — issue #397

Since 0.124.0 the pretool lane carries a bounded, fail-open repository-state predicate. When the DERIVED pretool query contains any restore/destructive verb as a substring (checkout, restore, reset, stash, clean, rebase, merge, push, cherry-pick, revert, filter-branch — a substring match, so benign text like "cleanup" also arms it; the cost is one extra git status), the hook body runs git status --porcelain --untracked-files=no once — TRACKED-change dirt only, exactly the wiped-by-git checkout -- hazard — in the EVENT cwd when it names an existing directory (process cwd otherwise), with the timeout read from ZMEM_STATE_PROBE_TIMEOUT_S (float seconds, default 2.0, clamped at 8.0; malformed values fall back to the default). When the verb gate does not arm, the probe does not run and the decision line carries NO state= field at all — absence means "no probe ran", distinct from state=unavailable ("ran and failed"). The outcome travels as --state-hint dirty|clean on the pretool recall argv (omitted entirely when the probe fails — never an unknown value) and is recorded on the decision line as state=dirty|clean|unavailable. Store-side, a candidate row receives a +0.15 rank bump — on both the sort score and _score — ONLY when the hint is exactly dirty AND the row's content matches the closed hazard lexicon (uncommitted, dirty tree, dirty working tree) AND carries a condition marker AND negation polarity. Clean, unavailable, and absent hints are byte-identical to today; the global relevance floor and trust gates still withhold gated rows (a bump never rescues them). The hint rides the recall query branch only — the queryless recent lane stays state-blind. Test seam: ZMEM_TEST_REPO_STATE=dirty|clean|unavailable overrides the probe entirely.

Snapshot-refresh refusal decision lines — issue #449

Since 0.146.0 a refused snapshot refresh (any shipped refusal class: hub-unreachable, hub-refused, payload-invalid) appends ONE bounded line to the guarded decisions log beside the unchanged #437 stderr line: [<ts>] zmem-hook status=silent reason=<class> ids=[] all=[] snapshot_refresh=refused. The line is parseable by the shipped decision-log reader via the additive TERMINAL snapshot_refresh= optional group (appended after state= so every earlier positional group index is preserved); parsed rows carry snapshot_refresh verbatim (None on lines that predate the field). No enrichment trio (lane=/ver=/t_ms=) rides the line, so the reader's enrichment veto stays untriggered. The two writes are failure-independent: rotation problems and a closed/raising stderr never suppress the persistent line (and vice versa); the whole writer body is guarded so a refused/readonly data dir only loses the line, never the refresh ladder.

Known bounds (PR #451 review, disclosed): a FULL hub outage writes ZERO refusal lines — the broker takes its offline-snapshot branch and returns BEFORE the refresh attempt, so the stamp (not the decision log) is the outage-window evidence; refusal lines appear only when a refresh was ATTEMPTED and then refused (fresh-pair transport failures retry per moment, one line each — the rotation family bounds the growth). The rows dilate hook-decision aggregates that count all parsed lines (sid_coverage_pct, period bounds, attribution counters — a refusal row carries no sid=); no shipped report branches on snapshot_refresh yet (parsing is the shipped contract; a dedicated refusal surface is future work). Mixed version: a pre-0.146 decision-log READER skips these lines whole, and a pre-0.146 scripts/eval_replay.py REFUSES the log (ReplayError on the unknown terminal field) — do not point an older replay evaluator at a 0.146 data dir.

Write-path capture guards and the session closeout — issues #398 / #246 / #247

  • Conditional-contradiction notice (#398): an add whose content opposes a recent same-namespace row (negation-polarity disagreement + condition marker + >= 2 shared significant tokens, 200-row window) stores normally with a STRUCTURED notice naming both rows — notice only, never an edge or trust mutation.
  • Lesson phrasing advisory (#398): a --type lesson row with no trigger-condition marker (when/whenever/if/while/before/after/until) stores with an advisory suggesting the conditional phrasing.
  • Hostless namespace near-miss (#246): a project:<owner>/<repo> key with a dotless first component stores, with a namespace_project_near_miss warning naming the canonical project:github.com/<owner>/<repo> form (assumption A7: warn-and-store — rejecting would break legitimate hierarchical namespaces).
  • Session closeout (#398): every successful attributed store write (add/update/supersede/invalidate/entity-card when a card was written) stamps <sha256(session-id)[:32]>.wrote under <data>/ops/; refused writes stamp nothing. The session hooks surface exactly one bounded closeout reminder while .wrote exists without .closeout — at session end, and (because SessionEnd output has no live model turn on most hosts) re-surfaced once at the NEXT SessionStart in model-visible context. A recorded closeout silences it: store.py closeout-ack --session-id <id> (store-independent; the closeout workflow's Step 7 runs it). The ops age sweep reaps .wrote/.closeout/.nudged markers after ZMEM_SENTINEL_SWEEP_DAYS.
  • Harvest-paste gate (#247): a user message over 400 chars whose dominant fenced block parses as JSON (array or single object) carrying

    = 3 of the harvest keys (namespace, type, content, signal) is NOT captured as a correction candidate — it belongs to /ingest-harvest. The gate runs on the manual correction hook lane and the auto-retain and history-mine lanes; line endings are normalized (CRLF pastes are gated too).

Pre-tool inject — issue #90 / #85 direction C

On hosts whose pre-tool contract was probed and confirmed (ZCode: documented; Claude: emitted — issue #117 retired the sidecar default in favor of the per-session delivery ledger), a PreToolUse hook (zmem-pretool-recall.sh, matcher Edit|Write|MultiEdit|NotebookEdit|Bash|apply_patch|Agent|Task since 0.31.0 (apply_patch added later; #338) — issue #119; matching the live registration in hooks/hooks.claude.json. the Agent branch passes task text when the event carries it AND, when it does not, parks the delegating prompt store-side for the child's SubagentStart (issue #253), otherwise using the store-owned recent path) derives the recall query from the tool input ITSELF — the command or file path about to run — and injects matching hazard lessons before the tool executes. Pre-tool additionalContext is documented on Claude Code (since 2.1.9 it lands alongside the tool result; pausing is permissionDecision-driven only) — issue #158 makes the store-owned selector the only passive path: delivered ids now live in a bounded per-session ledger (<data>/ops/<sha256-of-session-id>.ledger, atomic, window- and cap-bounded — plus per-call <hash>.ledger.seg.<hex> JSONL segments that publish without a lock and are merged into the base by a best-effort compaction; reads always merge base + segments, issue #252) that EVERY injection moment consults, so the same row is not re-delivered within the window — with one deliberate exception: PreToolUse re-delivers when the operation tokens about to run strongly match the row (the session-start hazard still fires before the dangerous command). The ledger clears at PreCompact and the following SessionStart uses the ordinary session_start selector moment. The retired ZMEM_PENDING_SIDECAR fallback is no longer produced or consumed. The host event's session_id (without it the adapter stays fail-open) is passed to the selector. The hook NEVER denies (a surfaced hazard is information, not grounds to block a legitimate command) and stays fully silent when nothing qualified. ZMEM_QUERY_CONTEXT=0 silences every query-context lane, this one included. Hermes delivers the equivalent on pre_llm_call (after the fact of the producing call), best-effort per ring CURSOR (ts, event-count) — same-second events still deliver; a transient store failure after the at-most-once marker skips that cursor's delivery. Hermes delivery's namespace follows the hook chain ZMEM_MCP_NAMESPACE → ZMEM_NAMESPACE → user:global (issue #71 review: one chain for prefetch, recall, and correction capture — Hermes hook events themselves carry no namespace); project-scoped operation context delivers on the coding-host PreToolUse surface. All query-context persistence (rings, delivery markers, and the session delivery ledger) lives under <data>/ops/ sidecars and never grows the store's tables. Codex pre-tool injection is WIRED (issue #95): hooks.codex.json registers PreToolUse with matcher Bash|apply_patch — dumped live from codex-cli 0.153.0 (Windows, 2026-09-09: shell operations emit the hook tool name Bash, file patches emit apply_patch; Codex treats an all-alphanumeric/pipe matcher string as EXACT alternation, so it matches precisely those two names). MCP tools (mcp__<server>__<tool>) and write_stdin are deliberately OUT — pre-tool recall derives its query from shell/file-patch inputs, not arbitrary MCP arguments. Upstream accepts hookSpecificOutput.additionalContext on PreToolUse (model-visible, non-blocking); Codex hooks reference: https://learn.chatgpt.com/docs/hooks. Codex envelopes are additionally capped at 8000 encoded bytes (≈2000 tokens at the plugin's 4-chars/token estimator; dense multi-byte (CJK) content has less real headroom) — 20% margin under upstream's 2,500-token hook-output spill limit (DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT, codex-rs output_spill.rs, verified against tag rust-v0.153.0); the cap applies even when an operator sets a larger ZMEM_CTX_BUDGET. The unset ZMEM_CTX_BUDGET default is host-dependent: 25000 on the ZCode launcher path, 9000 on the Claude Code path and the ZCode non-launcher path. Keep the verification-first convention: re-probe with a live tool_name dump before changing the matcher.

Inject surface parity (host facts, not aspirations): Claude Code registers SubagentStart (task-text recall — issue #119, probe 2026-09-10: neither host's SubagentStart payload carries task text, so the query ladder is payload-field-if-present → the delegating prompt parked store-side by the parent's PreToolUse(Agent) call when the event yielded nothing (issue #253) → the queryless recent selector last). The store-side bridge restores the #119 intent inside the #158 architecture: the hook body passes the delegating prompt to the store subprocess it already makes (--delegation-prompt, argv surface bounded at 4000 chars so the store can redact before capping the parked bytes at 800, never a storelib import in the adapter), the CLI parks it via park_task_text (secret-redacted through the canonical redact_text, advisory and fail-open; 16-entry cap, and a stale entry stops being CONSUMED after the 6h window — that prune is in-memory only; issue #336 (0.124.0) added the disk bounds: the TERMINAL session-end clear now unlinks the stash beside the data dir it serves (the mid-session ledger-clear keeps it), an all-expired consume unlinks immediately, and the real bound on untrusted host prose on disk remains the ops age sweep, ZMEM_SENTINEL_SWEEP_DAYS, default 7 days; purge scrubs the stash by needle and reaps orphaned *.tasktext.tmp.*; 0600-at-open + atomic replace, with owner-only access on Windows resting on the operator-profile ACL rather than chmod; since 0.138.0 written WITHOUT fsync (#340), so a host crash can lose the whole sidecar (the reader discards an unreadable stash) or re-deliver an already-consumed delegation — disclosed, regenerable-cache posture), and the selector consumes it FIFO at the subagent moment only when the payload ladder produced no query. Correlation key (issue #428, 0.148.0): a host that names the child's id in the delegating call (tool_input.agent_id, forwarded by the hook body as --delegation-agent-id) parks a TAGGED entry, and the child's SubagentStart agent_id (forwarded as --consume-agent-id beside --consume-tasktext) takes exactly its own tagged prompt regardless of age or newer siblings. Ids are normalized identically on both sides (string, stripped, 1–128 chars, no control characters, no leading -; anything else is treated as absent). RESERVATION RULE: a consume without a matching id never takes, ages out, or counts a TAGGED entry — the FIFO + aged-head rule below runs over UNTAGGED entries only. No probed host supplies an id at PARK time today (Claude Code assigns the child id at SubagentStart), so every production stash is untagged and the rule below is byte-identical to 0.138.0; the id the child carries simply finds no tag and falls through. For untagged entries FIFO is the correlation rule and out-of-order arrival of truly concurrent children is the documented limitation; the consume lands before the selector runs (#337), so a selector failure after a successful consume loses that delegation (disclosed residual); the accepted (unlocked, #330) races are the parallel-park last-writer-wins (jittered N=8 parks left 2-7 survivors; CORRECTED from "drop-not-swap" in 0.138.0), a parallel-consume overlap (two concurrent children can load the same head entry before either rewrite lands — on UNTAGGED entries both receive that same delegation (duplicated delivery — the text is served twice, not lost); on TAGGED entries each consume drops its own index and rewrites the whole file, so the loser's stale rewrite resurrects the winner's already-consumed entry (last-writer-wins; a tagged entry stays inert until its own id arrives); degraded relevance only, never a crash), and the parked-against-consume overlap in BOTH directions: a fresh park loses its entry to a concurrent rewrite (unlink-wipe) or an already-consumed entry is re-delivered (resurrection). An ORPHANED park — a delegation whose child never starts (permission denied, cancelled, tool errored) — would sit at the FIFO head and shift EVERY later child's prompt by one; since 0.138.0 the consume applies an AGED-HEAD SKIP (issue #329) over untagged entries: a leading entry older than 300 s is dropped in favor of a strictly newer one (an exact agent_id match still outranks the heuristic; malformed timestamps are never aged; a sole entry inside the 6h window is always served — the window prune still applies). Two residuals stand for UNTAGGED entries (issue #329, maintainer-ratified 2026-11-02): an orphan younger than 300 s still shifts by one until it ages out, and a live child delayed past 300 s can lose its prompt to a strictly newer delegation - a failure mode the heuristic introduces (pre-0.138.0 a delayed child consumed its own head entry). A host that supplies the correlation id on both sides retires both residuals for its delegations (issue #428); the 300 s band is unchanged. PR #453 review residuals, disclosed: the 16-entry park cap evicts UNTAGGED entries first (a tagged entry is only evicted when the stash holds no untagged entry to drop — an ALL-TAGGED stash over the cap still drops its oldest), and a TAGGED entry with a corrupted unparseable timestamp survives the 6h window prune (park always writes a finite timestamp; bounded by the cap, the session-end unlink and the ops age sweep). The 6h window and 16-entry cap bound how long any shift lasts, not how many children it affects. Since 0.138.0 the consume is CALLER-GATED (issue #339): only the SubagentStart hook lane (--consume-tasktext on its recall/recent argv) and the fleet broker's SubagentStart prefetch are INTENDED to pass the grant; the gate is default-off but CALLER-ASSERTED — any caller (including any authenticated hub principal via the MCP prefetch param) may self-grant, so the boundary is convention, not enforcement (the stash is keyed by session id only; trusted-fleet assumption). An ungranted --moment subagent empty-query caller is byte-identical to a no-stash session. ZMEM_QUERY_CONTEXT=0 or ZMEM_INJECT=0 disables parking via the early exit. A bridged child is a fresh context: its pull drops the delivered-derived exclusions entirely, so the parent's pretool deliveries cannot starve it and a fresh child re-reads rows an earlier sibling already rendered (over-delivery of already-seen rows, never starvation — deliberately NOT narrowed to sibling-moment rows, which would let one sibling starve the next; issue #331). Claude's matcher remains Edit|Write|MultiEdit|NotebookEdit|Bash|apply_patch|Agent|Task since 0.31.0 (apply_patch added later; #338) — issue #119; Task is accepted as the pre-rename delegation tool name per community issue 29677, closed stale and not vendor-confirmed. Claude Code also registers PostToolBatch (issue #120, 2026-09-11 — post-edit checkpoint recall, the batch sibling of PreToolUse): after one completed batch, the hook parses ONLY tool_uses[].name plus input.command / file_path / notebook_path / path (plus the singular tool_name/tool_input compatibility shape), bounds every retained field to 150 chars and the joined query to 500, derives the existing operation tokens, and makes ONE store.py recall --for-injection --no-bump decision with the #117 ledger exclusion — raw tool_response/result payloads are never parsed, stored, or logged (the decision log carries tool NAMES and path BASENAMES only). The internal mode is posttoolbatch, but the runtime moment recorded in the decision log and ledger is the closed-set pretool — no posttoolbatch moment is emitted anywhere. PostToolBatch is deliberately UNREGISTERED on Codex (no such upstream event exists in codex-rs hooks as of the 0.153.0 probe) and is a documented host gap on ZCode (see the seven-event note below); no lane reads tool_response — convention-capture on PostToolUse parses the same tool_name/tool_input-shaped fields, not the response payload. Codex registers SessionStart, UserPromptSubmit, PreToolUse (matcher Bash|apply_patch, probe 2026-09-09, codex-cli 0.153.0), PostToolUse (matcher Edit|Write|MultiEdit|NotebookEdit|Bash|apply_patch, per the shipped hooks/hooks.codex.json manifest), Stop, SubagentStart, SubagentStop, PreCompact, and SessionEnd — the Codex DELEGATION tool surface is UNVERIFIED by #119 (2026-09-10): the #95 dump covered only the shell and patch/apply tools and cannot say whether a delegation facility fires PreToolUse at all, so no matcher entry was added and Codex SubagentStart rides the payload-field / recent rungs (there is no transcript-tail rung — that parser is retired; a fresh delegation-tool dump is deferred to the live-probe owner #96). Upstream drops additionalContext on PreCompact (decision control only, verified 2026-09-09 from codex-rs source), so zmem's Codex PreCompact entry exists to clear the delivery ledger before compaction; post-compaction re-injection rides the registered SessionStart, which upstream fires with source=compact after every compaction. PostCompact stays UNREGISTERED on Codex (issue #118 decision, 2026-09-10: upstream Codex PostCompact carries only trigger: manual|auto — no compact_summary, so there is nothing to stash). The compact branch (issue #158): SessionStart branches on source == "compact" only — the launcher exports the payload field as ZMEM_SESSION_SOURCE, clears the delivery ledger at PreCompact, and then calls the store-owned selector with moment session_start on the next SessionStart. The local decision log may retain moment=session_start_compact for diagnostics, but that label is never sent to storelib. There is no compact-summary sidecar or special second budget. The payload block lives in hooks/lib/zmem-session-start-payload.py — NEVER inline it back as python -c: the string outgrew the Windows ~32K CreateProcess command-line limit and silently degraded the hook to {}. Whether the PreCompact fence itself survives a live /compact is UNPROBED — no claim either way until #96's live canary lands (the host-capability rot convention from #103/#104). ZCode supports exactly seven hook events — SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, Stop — so SubagentStart, PreCompact, and PostCompact are host gaps on ZCode (an unsupported event name would be dead config under the host's strict schema, so they are documented here instead of registered; likewise ZCode's PreToolUse matcher deliberately omits Agent — with no SubagentStart event, event-provided task text has no consumer). If ZCode grows either event, wire zmem-subagent-recall.sh / zmem-precompact.sh / zmem-postcompact.sh immediately. PostToolBatch shares that ZCode gap (issue #120): until the host grows the event, zmem-posttoolbatch-recall.sh stays Claude-registered only, and the launcher's translation mapping for the verb is inert on hosts whose manifest omits the entry; if ZCode grows the event, wire zmem-posttoolbatch-recall.sh immediately (same convention).

Decision-point checkpoints (REQUIRED skill contract) — #85 direction E

When an agent is about to run one of the named hazardous operations, the skill/workflow driving it MUST first run an explicit recall and treat any hit as blocking review (read the lesson before proceeding; do not skip it because the task feels urgent):

  • before git stash pop / any stash-consume — recall --query "git stash pop foreign stash conflict" --include-global (a blind pop can apply a foreign stash)
  • before git reset --soft (squash assembly) — recall --query "git reset soft origin main stale tree" --include-global (a fetch may have moved the base)
  • before git push — recall --query "git push stale tree fetch rebase verify" --include-global (verify the tree against the fetched base first)
  • before editing any file named by a stored citation/ratchet lesson — recall --query "<path basename> ratchet citation re-pin" --include-global (cited-file edits have local gate batteries)

These checkpoints complement the pre-tool hook; they are not a substitute for it (an agent improvising a raw command is exactly who the hook covers).

Schema forward-compatibility (issue #65 follow-up)

A client refuses a store whose schema_version is above its ceiling. FORWARD_COMPAT_SCHEMA_VERSION (schema_meta) is that ceiling: a maintenance release of an older client line may raise it to an ADDITIVE-ONLY newer schema (new side tables, no memory changes), letting the older client keep storing and recalling memories — with a one-time stderr NOTICE — until it updates. Above the ceiling the refusal names the update, and ZMEM_ALLOW_NEWER_SCHEMA=1 overrides at the operator's own risk.

Schema 16 — entity kinds v2 (issue #175, UPGRADE-REQUIRED)

Schema v16 widens the entity kind vocabulary from five to exactly twelve kinds, enforced by a SQL CHECK: person, project, tool, preference, host, service, model, repo, file, command, error_class, other (schema_meta ENTITY_KINDS; the deterministic extractor admits each kind as a kind:Name tag prefix). The v15→v16 migration REBUILDS the entity table (create entity_v16 with the new CHECK, copy every row, drop the old table, recreate entity with the new CHECK, copy back) in one BEGIN IMMEDIATE transaction with PRAGMA foreign_key_check before commit; every entity, entity_alias, and memory_entity row survives unchanged. Since 0.136.0 every entity-kind CHECK is rendered from the single schema_meta.ENTITY_KINDS authority (issue #356) — no DDL site hand-copies the vocabulary — and the schema lock gained holder-liveness (issue #357): the first open after an upgrade runs the rebuild under the lock (expect seconds, proportional to entity count; later commands wait up to ZMEM_SCHEMA_LOCK_WAIT_SECONDS then refuse with a clear message), while a holder that DIED mid-migration has its lock broken automatically by PID liveness (SQLite rolls back the uncommitted transaction, so the store is never half-migrated). Recovery path for a bad migration: stop sessions, store.py restore the newest good snapshot from ZMEM_BACKUP_DIR (default <store dir>/backups), re-run; import-store.py only replaces its destination after every acceptance gate passes. This is NOT an additive bump: a changed SQL constraint is outside the forward-compat window, so per the v16 contract SUPPORTED_SCHEMA_VERSION is 16 while FORWARD_COMPAT_SCHEMA_VERSION deliberately stays 15 — every client older than the v16 release REFUSES a migrated v16 store (the actionable refusal names ZMEM_ALLOW_NEWER_SCHEMA=1, the explicit operator override that proceeds at the operator's own risk and never downgrades or migrates the store). The #172 taxonomy decision for entity_card is REJECT: that memory type does not exist and ALLOWED_TYPES is unchanged by this release. The #172 decision was reject; the #179 workstream contract reverses it and ships the type — cards are born only through the refresh path. Since 0.107.0 ALLOWED_TYPES carries entity_card (the #175 kinds precedent: the RFC map is a frozen record, not a veto); see "Entity cards (issue #179)" below for the full card contract.

Schema 18 (issue #178) is purely additive (the event type plus the memory_occurrence side table — no memory column or constraint change), so the forward-compat window REOPENS: SUPPORTED_SCHEMA_VERSION is 18 and FORWARD_COMPAT_SCHEMA_VERSION is 18. Clients whose forward ceiling is still 15 (every 0.10x-era install) refuse a migrated v18 store until updated — on shared-store machines, update every zmem client before the first v18 client opens the store (ZMEM_ALLOW_NEWER_SCHEMA=1 is the stopgap; see CUTOVER.md and the CHANGELOG operator note).

Schema 19 (issue #288) adds the applied_ops op-queue table — purely additive, so SUPPORTED and FORWARD both moved to 19. The compat gate refuses any store newer than the client's FORWARD ceiling: a 0.116-era client (FORWARD 18) REFUSES a v19 store with the standard ZMEM_ALLOW_NEWER_SCHEMA=1 message — update all zmem clients on shared-store machines before the first v19 client opens the store (a FORWARD-15 client refuses it the same way). NUMBERING: #179's entity_card bump owns schema 17 as a STAMP-ONLY step (no DDL); this PR's memory_occurrence DDL therefore renumbered to 18 at rebase — one integer can never carry two schema definitions, or the ver < N guard would skip the DDL on every store a released client had already stamped.

Injection token budget (issues #65 10.9, #116)

ZMEM_INJECT_TOKEN_BUDGET (default 1500, measured at 4 chars/token — no tokenizer is bundled) is a HARD CEILING on the memories injected by the hooks (UserPromptSubmit / PreCompact / SubagentStart / SessionStart Tier 2) and by the session_start MCP/Hermes tools. Admission charges each row its FULL rendered fence contribution plus the fence shell, with the same estimator the reporting uses — the rendered fence never exceeds the budget. When the ceiling is hit, admission skips past rows that do not fit (a later smaller row is still admitted); decision/constraint rows are never dropped while they can carry information: a row that fits renders whole, one that does not is TRUNCATED with an explicit …[budget-truncated] marker, and only a row too large for even a minimal stub is dropped — never silently. When anything was omitted, the fence carries a machine-readable line # [budget: dropped N rows, truncated M] and the shared hook body's decision line (UserPromptSubmit / PreCompact / SubagentStart / SessionStart tier-2 python path) reports tokens=<rendered>/<budget> plus the distinct labeled fields rendered_estimate=, admission_budget=, budget_dropped=, budget_truncated=, budget_dropped_protected= (the same order as the field-order list above; emitted only when budget accounting ran). The separate zmem-session-start.sh bash writer keeps its legacy tokens=<used>/<budget>-only line. Every read --json envelope reports tokens_used/tokens_budget and, on the --for-injection lane, budget_dropped/budget_admission/budget_truncated/ budget_dropped_protected/budget_note. The B-1 report (doctor --miss-rate) counts over-budget N decisions so the ceiling can be verified on a live log window. ZMEM_CTX_BUDGET (character cap) remains the hard outer truncation on the rendered block.

--include-global (opt-in) ALSO surfaces up to --global-limit query-relevant rows from the user:global tier, merged project-first so a global row never crowds out a project row. The three automatic hooks pass this so cross-project lessons reach project-scoped sessions. Without it, behaviour is strict-namespaced (byte-identical to before). When you want the global tier unioned in but still want a per-tier budget, use recall --namespace project:<x> --include-global rather than going unscoped.

Scoped five-tier recall (issue #167)

Ordinary implicit recall and recent calls without --namespace resolve the current project, fleet, and host through the shared #166 scope resolver and use five independent reservations in this order; hostnames are normalized to lowercase for host: scope lookup: project, domain, fleet_host, cross_project, user_global. The default slot caps are 5/2/2/2/3. Library callers opt in explicitly with a scopes= map on recall_memory, recent_memory, or the read-only explain_recall API; the resolver's agent value is ignored. user_global is admitted only when include_global=True.

ZMEM_TIER_SLOTS overrides the caps per call as exactly five comma-separated ASCII nonnegative integers in that order. Empty, malformed, signed, or Unicode numerals fail before the scoped pipeline opens a SQL lane. The scoped cross_project slots remain reserved until a public admission policy is available. While no policy is wired, recall and recent skip the unfiltered candidate scan. This is separate from the legacy #98 --include-cross-project hazard lane. Scoped rows are stable-deduplicated by id and carry their tier in JSON; generic fenced output prefixes [tier=<name>], and a tierless generic row is explicitly prefixed [tier=unknown]. Ordinary plain-text recall/recent output includes that marker too. Legacy passive injection retains its established tierless wire bytes. The legacy tier=cross renderer marker remains a suffix. Explicit --namespace, search, hook, and injection calls retain their legacy route and limit arguments (issue #235 adds only the gated-moment user_global relevance floor on top: below-floor global candidates are withheld before the merge, the slot stays empty, and the envelope reports the count as global_withheld). On implicit ordinary recall and recent, --include-global opts into the scoped user_global reservation and preserves tier labels. Explicit --namespace calls retain their legacy union behavior; --include-cross-project retains its legacy hazard semantics. Direct scoped recall_memory/recent_memory calls reject for_injection=True; scoped explain remains read-only and accepts it. Programmatic callers use scopes= and include_global=True for the same labeled global tier. The domain reservation is available to programmatic callers that supply a domain scope; shipped CLI, hook, and MCP resolvers do not currently create one. Scoped recall/recent reject the legacy include_cross_project=True flag.

Cross-project hazard tier (issue #98)

A fourth, precision-gated tier (--include-cross-project, wired automatically on the passive injection surface) can deliver up to 2 live rows from FOREIGN project:* namespaces — a lesson another project already paid for, surfaced exactly when you are about to repeat its incident. Admission requires ALL of:

  • the running operation is hazardous: the derived ops tokens (derive_ops_tokens, the #88/#123 allowlist) whole-token-intersect the hazard-verb set — ops_tokens._HAZARDOUS_SUBS (git push/reset/stash pop/ rebase/...) by default, overridable via ZMEM_CROSS_PROJECT_HAZARD_VERBS (comma-separated, trimmed, case-folded, de-duplicated; unknown or empty verbs are dropped with a one-shot stderr warning and an override with no usable verb falls back to the default set);
  • the row's signal is grounded: one of test, compile, lint, reviewer;
  • the row passes the standard score/confidence floor (#113) and is live (superseded_at IS NULL);
  • the row's namespace matches project:* and is OUTSIDE the current project's alias set (user:global rows stay in their own tier).

Surface policy (ZMEM_CROSS_PROJECT): unset → pretool only (PostToolBatch maps to the pretool moment); 0 → off everywhere (wins even over an explicit --include-cross-project); 1 → pretool and user_prompt (on an env-enabled user_prompt surface the store-side selector derives ops tokens from the prompt event itself — the #158 hook boundary keeps the hook a thin flag forwarder); any other non-empty value → pretool only plus a one-shot stderr warning. The tier is query-time — the queryless recent pull never admits cross rows. Scoped MCP principals keep their ops_tokens; the selector's cross-project lane intersects its foreign namespace set with the principal's authorized allow-list (issue #283), so foreign projects outside the allow-list are excluded before rendering, context construction, or delivery-ledger updates, while allow-listed cross-project rows can be admitted when operation tokens ground them.

No-copy rule: a cross row is never copied, rewritten, or mirrored — the store's own row renders in place, inside the untrusted fence, tagged [ns=<source namespace>] [tier=cross] (with tier: "cross" on the JSON row), and cross rows never consume project or global slots. Delivered cross rows count once in surfaced_count under the same telemetry law as every other tier. The #155 real-corpus replay baseline has landed: the lane ships with these conservative defaults, and the predeclared measurement record eval/real-corpus-2026-09-19.json (see the read-only replay evaluator section) is the calibration reference — an audit over a frozen cohort, not a live efficacy claim. recall --explain does not include the cross tier (the read-only debugger predates it and is not extended by #98).

Hybrid is the DEFAULT when embeddings are available (issue #58 3.3): the query is embedded and matched against stored embeddings (sqlite-vec KNN), then both lanes' rankings are fused with Reciprocal Rank Fusion (RRF, k=60). --hybrid is kept as an explicit no-op alias for older invocations; --no-hybrid forces the lexical (FTS5/BM25-only) lane. The embedding runtime (onnxruntime + tokenizers, model lazy-downloaded and checksum-verified) is optional and fails open: when it is unavailable, recall silently uses plain keyword ranking — never an error.

Entity matching is the THIRD RRF list and needs no model (v10, issue #60 5.3): at recall the query is run through the same deterministic entity extractor used on write, plus plain query tokens are matched against stored entity aliases. Memories linked to a matched entity join the fusion as a third ranked list (rank = number of matched entities, then recency). This is how the alias rg recalls ripgrep-linked memories even when BM25 terms miss — and it is live on model-absent stores by default. An unknown alias is simply an empty third list; the lexical (and, when available, vector) lanes fuse exactly as before.

MMR diversity is the DEFAULT (v10, issue #60 5.5): after RRF fusion and composite scoring, the candidate set is re-ordered with Maximal Marginal Relevance before --limit is applied, so a cluster of near-paraphrase duplicates cannot crowd out a distinct fact. Similarity is embedding cosine when both rows have embeddings, else Jaccard on normalized content tokens — so MMR also works model-absent. --no-mmr returns pure composite-score order (four paraphrases instead of three + the distinct fact). The tradeoff knob is lambda: default 0.7, env ZMEM_MMR_LAMBDA (0.0 = maximize

Embedding profiles & rerank (ZMEM_EMBED_PROFILE, ZMEM_CROSS_ENCODER, ZMEM_CROSS_ENCODER_MODEL): ZMEM_EMBED_PROFILE selects a row from the registry in skills/memory/scripts/embed_profiles.py — unknown values refuse with exit 2 before any store work, and a value whose dimension differs from the store's committed vectors refuses until reembed --all converts it. The cross-encoder rerank is enabled by default (issue #126 flip); set ZMEM_CROSS_ENCODER=0 to disable it. The EXPLICIT lane covers CLI recall invocations that are not --no-bump/ --no-hybrid (never recent / search-aliases); pointing ZMEM_CROSS_ENCODER_MODEL at a LOCAL pair-scoring .onnx plus sibling tokenizer.json keeps the operator-vouched load path. Issue #125 adds a checked-in mini-pair-scorer profile (skills/memory/scripts/cross_encoder_profiles.py, exact SHA-256 pins for BOTH the model and its tokenizer) used when ZMEM_CROSS_ENCODER_MODEL is unset — resolved from ZMEM_MODELS_DIR or the shared models dir, and loaded ONLY after the digest of the exact BUFFER each runtime object is constructed from matches its profile pin at scorer BUILD time (#418; there is no verify-then-reopen window — the path-level verify_profile_file helper is a diagnostic API, no longer the load gate); ZMEM_CROSS_ENCODER_MODEL_URL plus exact ZMEM_MODEL_AUTODOWNLOAD=1 permits one digest-verified, atomic-download attempt at scorer-load time. A second opt-in, ZMEM_CROSS_ENCODER_PASSIVE=1, lets the PASSIVE injection lane score its final admitted set through rerank_final_injection_set (issue #125) in shadow-only mode: ZMEM_CROSS_ENCODER_SHADOW=1 logs bounded rank deltas to ${ZMEM_DATA}/cross-encoder-shadow.jsonl and the lane's order is unchanged until the recorded promotion gate (PASSIVE_PROMOTION_GATE: #111 precision > 0.8978333333333333, p95 <= 250 ms, #129 false-injection <= 0.0) is measured and flipped. Every rerank attempt is budgeted (250 ms default, ZMEM_CROSS_ENCODER_BUDGET_MS) and emits exactly one terminal [zmem] cross-encoder reason=<...> line on stderr; any failure degrades to un-reranked results and rerank can never fail a recall. There is still NO unverified-load escape hatch (ZMEM_MODEL_ALLOW_UNVERIFIED does not exist). diversity, 1.0 = no diversity — identical ordering to --no-mmr). --no-mmr and --no-hybrid are independent flags and can be combined.

Recall rows carry entity cards: recall --json rows include entities: [{id, kind, name}], and the fenced hook render shows at most three entity NAMES per row (never ids). get --id shows the same links.

1-hop link expansion is the DEFAULT (v11, issue #61 6.3): after MMR, each recalled memory's related/supports links are walked one hop and up to --link-budget (default 2) extra neighbor rows are appended — a memory that never matched the query but is associative-neighbor of a match still surfaces. contradicts neighbors are included only when they survive the confidence floor and are tagged [CONTESTED LINK: <id>] in the fenced render — the id names the opposing row, the parent the neighbour was expanded from (contested_link: true + link_of in JSON; issue #254). contradicts edges are stored in both directions, so the named id is one end of the contradiction, not a fixed direction; when the id is not a single safe token the bare [CONTESTED LINK] renders instead, and a named row that a floor or the token budget dropped from the fence can appear in a marker without its own bullet. Expansion rows carry link_relation / link_of / link_score / contested_link keys; rows that matched the query themselves never carry them, and expansion rows never advance telemetry (popularity rewards query matches, not link neighbors — so expansion cannot distort later rankings). On the passive injection lane an already-delivered parent contributes no neighbours (exclude_ids seed skip, issue #250); the plain recall path is unaffected because its pre-filter has already removed those rows. --link-hops 0 disables the walk; --link-budget 0 is equivalent. search and recent never expand.

--no-bump (opt-in) makes a recall passive: it records a surface event (surfaced_count/last_surfaced) instead of advancing retrieval_count/ last_retrieved. The explicit-vs-passive split is the system contract: the three automatic hooks and the Hermes provider prefetch are passive surfaces (a background injection is not a usefulness signal); explicit recall — this CLI without --no-bump, the MCP recall/search tools, and the Hermes MemoryProvider search tool — intentionally bumps retrieval_count, because an explicit read IS evidence the memory was useful (issue #21).

Issue #114 sharpened that contract in two ways. First, the automatic hooks and SessionStart now pass --for-injection (with --no-bump): the selective inject gate and the token budget run INSIDE that one store call, the returned rows are exactly the rendered set, and surfaced_count advances only for rendered QUERY-MATCHED rows (link neighbors render but never count; unfold is explicit-recall-only and never runs on this lane) — one subprocess, one decision, no second ack process. Second, ranking popularity now reads retrieval_count ONLY: passive surfaces are still recorded (promote/prune/consolidate consume them) but they no longer feed the composite score, so passive pulls cannot inflate their own ranking (the live-store shape surfaced_count=371, retrieval_count=0 was this loop). The weight itself is unchanged; #124 will repoint it at applied/violated counters.

Store-owned passive selector and envelope — issue #158

All passive consumers use the single storelib entry point select_and_budget_for_injection. It owns selection, delivery-ledger exclusion and recording, pre-tool operation-token composition, the hard token budget, and the canonical untrusted fenced rendering. The recall hooks, SessionStart, and Hermes provider are subprocess-only adapters: they consume only the envelope's string rendered field and never read SQLite, the operation ring, a delivery ledger, or memory rows themselves. The selector's closed moments are session_start, user_prompt, pretool, subagent, and precompact; its lanes are claude, codex, zcode, deepseek, hermes-provider, and hermes-compat. A compact restart is logged locally as session_start_compact, but the selector and ledger always use session_start.

The exact session-aware CLI forms are:

<interpreter> <store.py> recall --query "<text>" --for-injection --json \
  --session-id=<id> --moment <session_start|user_prompt|pretool|subagent|precompact> \
  --lane <claude|codex|zcode|hermes-provider|hermes-compat> \
  [--ops-token <token>]... [--authorized-namespace <ns>]...
<interpreter> <store.py> recent --for-injection --json \
  --session-id=<id> --moment <session_start|user_prompt|pretool|subagent|precompact> \
  --lane <claude|codex|zcode|hermes-provider|hermes-compat> \
  [--ops-token <token>]... [--authorized-namespace <ns>]...
<interpreter> <store.py> ledger-clear --session-id=<id> [--principal <id>] [--harness <id>]
<interpreter> <store.py> delivery-clear --session-id=<id> [--principal <id>] [--harness <id>]
<interpreter> <store.py> closeout-ack --session-id=<id>

The passive recall and recent commands accept the additive attribution flags --session-id=<id>, --moment, --lane, and repeatable --ops-token when called with --for-injection --json, plus the payload flag --delegation-prompt (issue #253), --consume-tasktext (issue #339: SubagentStart-lane-only grant of the store-side task-text consume), the issue #428 correlation ids --delegation-agent-id (tags the park) and --consume-agent-id (also on prefetch; selects the tagged entry) — both refused by name in remote mode (ZMEM_REMOTE_URL set), as is recall's --state-hint, because the hub read tools carry none of them and the fleet lane never parks — and repeatable --authorized-namespace (issue #283: the caller's namespace allow-list — the selector's user:global and cross-project lanes are filtered to it; omitted means unscoped). On the pretool passive lane, recall additionally accepts --state-hint dirty|clean (issue #397): it REQUIRES the passive shape (--for-injection --json --session-id) and --moment pretool on recall — either missing is a stable exit-2 refusal — and is refused on any moment other than pretool; the hook is the intended caller. --delegation-prompt is honored ONLY on --moment pretool with a session id, is secret-redacted before it touches the sidecar, and exists to park the delegating Agent/Task prompt for the subagent moment; every other moment ignores it. An empty query dispatches to recent selection. An omitted --ops-token list lets the store read the pre-tool ring; the ring is composed only for pretool, not for UserPromptSubmit or other moments. For passive recall and recent, a session id requires a moment, and a moment requires a session id. The equals form keeps session ids beginning with - unambiguous. ledger-clear --session-id=<id> clears one session's delivery ledger without opening SQLite. delivery-clear --session-id=<id> clears the ledger and legacy pending sidecar for SessionEnd cleanup, also without opening SQLite; both commands are idempotent when their target sidecars are absent.

The old hook-owned pending and compact-summary sidecars are no longer produced or consumed by these adapters (task-text parking returned in 0.98.0 as a STORE-side bridge — issue #253: the pretool store call parks via park_task_text, the selector consumes at the subagent moment; the hook adapters themselves still never touch any sidecar). The D-2 #118 compaction-snapshot handoff is documented above. This reflects the ownership history: issue #117 superseded the pending sidecar with the per-session delivery ledger, and #158 completes that retirement for the passive adapters. ZMEM_PENDING_SIDECAR=1 remains only as a documented no-op for operators upgrading from the compatibility path. Precompact clears the ledger; the next SessionStart performs ordinary session_start selection. The MCP server's passive surface (prefetch, the reworked session_start below, and the Hermes twins) now rides the same store-owned selector. This ownership move is schema-free: neither schema-version constant changes.

Query-aware passive prefetch — issue #159

prefetch is the query-aware passive lane: one select_and_budget_for_injection call and one complete JSON envelope — JSON is the command's only output mode, and there is no consumer-side renderer or second budget run.

<interpreter> <store.py> prefetch --query "<text>" --namespace <ns> \
  --session-id <id> --moment <session_start|user_prompt|pretool|subagent|precompact> \
  [--lane <claude|codex|zcode|hermes-provider|hermes-compat>] \
  [--ops-token <token>]... [--exclude <memory-id>]... \
  [--authorized-namespace <ns>]... [--reserve] \
  [--principal <id>] [--harness <id>] [--consume-tasktext]
  [--consume-agent-id <id>]
<interpreter> <store.py> prefetch-ack --reservation <id>

--query, --namespace, --session-id, and --moment are required; --exclude (memory ids the caller already holds) is repeatable. --for-injection, --no-bump, and --json are accepted as explicit markers of the (only) passive mode the selector pins. The selector owns the relevance/trust gate and the 1,500-token budget; prefetch never advances retrieval_count (only surfaced_count and the delivery ledger may move). --reserve (issue #284, T-05) returns the selection with a reservation id and records nothing in the delivery ledger — commit with prefetch-ack --reservation <id> (exactly-once; duplicate ack is a no-op within the reservation TTL — default 120 s via ZMEM_RESERVATION_TTL_S; an expired reservation commits nothing and an unknown or malformed id exits 2); --principal/--harness key the delivery ledger as principal|harness|session (omitted keeps the legacy session-only key). Delivery is session-attributed: a second turn for the same session whose ledger already holds the candidate rows returns the silent already-delivered envelope; ledger-clear --session-id resets the ledger, while SessionEnd uses delivery-clear --session-id to retire both sidecars.

The MCP server exposes the same one-call envelope as the prefetch tool (query, namespace, session_id, moment required; optional lane — validated against the five-value tuple, never defaulted to a host lane —, ops_tokens, and (issue #339) the caller-asserted consume_tasktext), enforcing namespace scope and the ZMEM_INJECT=0 kill switch before any store subprocess, and returning the complete selector envelope plus the additive context alias equal to rendered. Scoped principals keep their ops_tokens: the selector's cross-project lane intersects its foreign namespace set with the principal's authorized allow-list (issue #283), so foreign projects outside the allow-list are never admitted before rendering or ledger updates, while allow-listed cross-project rows can be admitted when operation tokens ground them. The MCP session_start tool rides the same store-owned queryless selector path via recent --for-injection --json --session-id <id> --moment session_start, returning that envelope (additive context alias; result/namespace/ ids retained as back-compat aliases); an omitted session_id falls back to the legacy for-injection envelope without the ledger key.

add — capture a memory

<interpreter> <store.py> add \
  --namespace "project:<basename>" \
  --type <fact|lesson|convention|preference|decision|constraint|state> \
  --content "<the knowledge, specific and actionable>" \
  --tags "comma,separated" \
  --signal <test|compile|lint|reviewer|user|none> \
  [--taint <trusted_internal|untrusted_tool|untrusted_web>] \
  [--source-ref "file:<path>" | "session:<id>" | "db:<table>:<rowid>"] \
  [--expiry-class <endpoint|setting|metric>] \
  [--inject-until <UTC ISO8601, e.g. 2026-11-12T09:00:00Z>]

state and injection expiry (v15, issue #173). A state row records a piece of the operating environment — an endpoint URL, a setting, a metric — that is only worth injecting while it is CURRENT, unlike a lesson or fact that stays true. --type state requires --expiry-class, one of:

class default TTL env override
endpoint 30 days ZMEM_STATE_ENDPOINT_DAYS
setting 90 days ZMEM_STATE_SETTING_DAYS
metric 14 days ZMEM_STATE_METRIC_DAYS

The override must be a positive decimal integer. With no --inject-until, the expiry is computed as now + TTL and stored in the additive memory_expiry side table (memory_id, inject_until, expiry_class); an explicit --inject-until must be UTC ISO-8601 (...Z or +00:00, normalized to Z) and bypasses the TTL. The side table is INJECTION-ONLY: it records when a row should stop being injected, it never deletes the row or hides it from explicit recall/search. Passing either flag with a non-state type, or omitting --expiry-class on a state add, is refused exit 2. update carries the same two flags and the same rules, and (issue #317) resolves the TARGET's effective type at action time: --type may be omitted on an update that carries the expiry flags — a state target accepts them, and a genuinely non-state target is refused by the writer with the same exit code.

Since 0.98.0 (issue #174) the recorded expiry is ENFORCED on read: passive injection (recall --for-injection, recent --for-injection, the Hermes prefetch lane) never delivers a row whose inject_until <= now — the comparison is by parsed true instant (fractional seconds order by their real time), inclusive at the boundary. Expired rows never reach results, budget accounting, or surfaced/retrieval telemetry; on an engaged lane the envelope reports the pre-expiry candidate pool — candidate_ids (arrival order) and candidate_lanes over the SAME population — plus an expired count over that population, and a fully-expired-but-eligible pool names the expired silent reason. Since 0.119.0 (issues #342/#343/#349) the gate also fails CLOSED on unknown expiry state: a side-table read failure (lock/interrupt with the table present) withholds the un-read candidates, keys expiry_read_failed: <count> into the envelope (registered in INJECTION_ENVELOPE_OPTIONAL; mirrored in the Hermes fallback set; keyed only when nonzero — a store without the failure keeps byte-identical wire), and prints one [zmem] stderr line; belief heads resolve their deadline through their source rows' earliest inject_until (a head whose sources are all live still delivers; an all-sources-expired head is withheld and counted). doctor --expiring keys expiry_read_error on a failed side-table read (the scan is per-leg guarded, so a successful first read is never discarded) and names unparseable deadlines in unreadable_expiry_states (memory_id, raw value, namespace, preview; keyed only when nonempty; both keys scoped to --expiring invocations). A pool whose every candidate was withheld by a read failure reports expiry_read_failed carrying the explanation; the reason label follows the pre-existing classifier (a never-engaged gate names empty-pool, an engaged drained gate may name expired — the read-failure count is the authoritative disambiguator). Present-time explicit recall/search return expired rows marked [EXPIRED] with expired: true instead of filtering; --as-of neither filters nor marks. --for-injection + --as-of is refused exit 2 before the store opens. Two read-only operators complete the surface: doctor --expiring 7d (rows with now < inject_until <= now + N days, parsed-instant ordered) and store.py classify-state --dry-run [--json] (deterministic endpoint/metric/setting proposal; read-only URI + PRAGMA query_only=ON, never a writer lease).

Fake-clock seam. Reads honor ZMEM_TEST_NOW (the scoring clock _now_epoch): the expiry gate, the [EXPIRED] markers, and doctor --expiring are all deterministic under it. WRITES never do — the writer clock is schema.now_iso() alone (issue #173's contract), so a pinned ZMEM_TEST_NOW can only move read-time judgments, never a stored timestamp. Signal sets default confidence: test/compile=0.9, lint=0.85, reviewer/user=0.6 (medium), none=0.2 — deliberately BELOW the 0.25 recall floor: an ungrounded lesson is the agent's self-opinion and never surfaces in default recall (still reachable via search --text, which applies no confidence floor, or recent --min-confidence 0) (#36 M3); for a verbatim probe of a stop-word-only string, search --exact reaches it literally. Dedup-on-write: near-identical live content in the same namespace refreshes the existing entry instead of duplicating. Dedup has a polarity guard (v11, issue #61 6.2): a near-identical hit whose negation polarity DISAGREES ("always X" vs "never X") is NOT merged — both rows stay live, link as contradicts, and each loses 0.10 trust. A polarity-AGREEING duplicate re-add is corroboration: the existing entry refreshes as before AND gains +0.05 trust. Dedup also has a subject guard (issue #376): two rows whose task:<key> tags carry explicitly DIFFERENT task subjects are never merged by similarity ALONE — each statement, its tags, source and evidence stay independently addressable on add, update, ingest and consolidation, the veto is logged with the stable code subject_veto, and a subject-compatible runner-up can still absorb the fold. Absent/malformed identity is compatible (unknown subjects dedupe as before), two rows BOTH typed lesson generalize across task origins (a single lesson row does not opt itself out of the guard), and subject-incompatible neighbors no longer receive each other's tags through link propagation. An admitted merge unions the survivor's task identities (bridge growth): a row that legitimately absorbed across compatible subjects can later merge with either subject. Link generation on write (v11, issue #61 6.2): after every add/update, the same namespace-aware neighbors dedup already computed (embedding cosine when the model is present, else Jaccard token overlap — model-absent stores link too) above ZMEM_LINK_THRESHOLD (default 0.75) become related edges (stored both directions). Each linked neighbor also absorbs the new row's TAGS and re-derives its entity links (attribute evolution) — content, confidence, signal, and retrieval_count are never touched. Deterministic; no LLM (a ZMEM_LINK_LLM knob deliberately does not exist). add --json (v13) prints a structured write result — {"id", "result": "stored"|"deduped", "warnings"} — with warnings as structured objects ({"type": "redacted", "count": N} for automatic secret redactions); the MCP and Hermes add surfaces use it. Default output is unchanged. Namespace validation: obvious misspellings of the global namespace (global, userglobal, users:global, …) are rejected at add time AND on ingest-jsonl sync import with a message naming the canonical user:global — such rows would be unreachable from the automatic hooks. New writes accept project:<x> and user:<x> (including later colons in their values), plus strict fleet:<x>, host:<x>, agent:<x>, and domain:<x> forms. The latter four require one non-empty, whitespace-free, colon-free value. ingest-jsonl retains its legacy near-miss-only validation until the explicit rekey work. Legacy rows already stranded under a near-miss namespace (before this guard existed) can be remediated with rekey-namespace --near-miss-global --confirm (see below).

update / invalidate — append-only revision and contradiction corrections (v9, issue #59)

<interpreter> <store.py> update --id <full-uuid> --content "<new content>" \
  [--namespace NS] [--type ...] [--tags ...] [--source-ref ...] [--confidence F]
  [--signal ...] [--taint ...] [--capture-mode manual|reviewed|auto]
  [--expiry-class <endpoint|setting|metric>] [--inject-until <UTC ISO8601>]
<interpreter> <store.py> invalidate --id <full-uuid> --reason "<why the fact is no longer true>"
  • update is append-only knowledge revision: it creates a NEW live row with the new content, tombstones the target row (superseded_at=now, valid_until=now, supersede_reason='updated'), and links the new row back to it via update_of. Namespace/type/tags/source_ref/confidence/signal inherit from the target unless overridden; the old row's content is NEVER mutated. An unknown or already-superseded id is refused (exit 2, nothing written). Dedup runs against OTHER live rows (the replaced row is excluded, so even an unchanged-content "update" creates history). Point-in-time recall (--as-of before the update) returns the OLD content; after returns the NEW.
  • invalidate is supersede with a REQUIRED --reason — the preferred way to record "this fact is no longer true" so the correction is auditable (issue #59, 4.3). supersede remains for general tombstones (consolidated/pruned rows) where a reason is optional. The reason must be NON-EMPTY (whitespace-only is refused, exit 2), and tombstones are write-once: a second invalidate/ supersede on an already-tombstoned row is refused (exit 2) — re-tombstoning would move valid_until forward and falsify point-in-time history.
  • Large content: --content - (the literal dash) reads the content from stdin on both add and update. Windows caps command-line argv far below the 65536-char content limit, so pipe near-limit payloads instead of passing them as an argument (the Hermes/MCP surfaces do this automatically). Piped bytes are always decoded as UTF-8, whatever the process stdio codec (cp1252 on a default Windows install), locally and in remote mode; invalid UTF-8 bytes store as U+FFFD. Line endings are normalized: \r\n (and a lone \r) store as \n, so a text-mode parent's piped LF text is stored as LF (issue #460). The argv path performs no translation: an argument containing \r\n is stored verbatim.

Provenance trust (--taint) — v9 (issue #59, 4.7)

Every memory carries a taint rank marking the trust of its ORIGIN: trusted_internal (human/closeout/test-grounded) < untrusted_tool (agent or MCP/Hermes-authored) < untrusted_web (web-fetched). There is deliberately no fourth rank: an unknown taint value is refused at every write surface (the write path never silently coerces it). The closed enum is enforced by a CHECK constraint AND by application-side validation (CLI argparse choices, ingest validator, Hermes/MCP boundary checks) — all sourced from the single schema_meta.ALLOWED_TAINTS.

The taint default depends on the surface:

  • CLI add: derived from --signal — grounded signals (test/compile/lint/ reviewer/user) default to trusted_internal; signal=none (the agent's self-opinion) defaults to untrusted_tool. An explicit --taint overrides.
  • Hermes/MCP tools (zmem_add / the MCP add tool): default to EXPLICIT untrusted_tool unless the caller passes a taint (e.g. untrusted_web for a web fetch, or trusted_internal for a human-grounded note). Taint propagates worst-of forward through lineage: update re-creation and consolidate absorb both set the surviving row's taint to the worse of the two merged sources (a merged row never DOWNGRADES a riskier member), and a duplicate add / ingest-jsonl row that folds into an existing keeper applies the same worst-of to the keeper. A tombstone (supersede/invalidate) preserves the row's taint — it creates no new row.

Recall surfaces the taint so trust is visible, mirroring prompt-injection-risk:

  • The explicit text path prefixes rows with [UNTRUSTED TOOL] (taint untrusted_tool) or [UNTRUSTED WEB] (taint untrusted_web).
  • The passive/auto-inject path (--no-bump — used by the automatic hooks and Hermes prefetch) OMITS untrusted_web rows entirely, exactly like prompt-injection-risk rows (an untrusted web-sourced memory is not trusted enough to inject passively). untrusted_tool rows DO surface passively — they are agent-authored and thus safer than web content — and are flagged on the explicit path instead. The hook scripts need no edits: they already pass --no-bump and inherit this store-side filter.

recent / search / supersede / update / invalidate / list / get / stats

<interpreter> <store.py> recent [--namespace NS] [--limit 5] [--min-confidence 0.5]
                         [--include-global] [--global-limit 3] [--as-of ISO-8601]
                         [--json]
<interpreter> <store.py> search --text "<text>" [--exact] [--namespace NS] [--limit 10]
                        [--include-global] [--global-limit 3] [--no-bump]
                        [--as-of ISO-8601] [--json]
<interpreter> <store.py> supersede --id <full-uuid> [--reason "..."] [--expected-namespace NS]
<interpreter> <store.py> invalidate --id <full-uuid> --reason "..." [--expected-namespace NS]
<interpreter> <store.py> update --id <full-uuid> --content "<new content>" [overrides...]
                       [--expected-old-namespace NS]
<interpreter> <store.py> list [--namespace NS] [--include-superseded]
<interpreter> <store.py> get --id <uuid>
<interpreter> <store.py> stats

Read envelope (v13, issue #65 10.8): recall/recent/search --json print {"results", "count", "omitted", "injection_risk", "tokens_used", "tokens_budget"} instead of a bare list — omitted counts rows the --no-bump inject filter dropped (injection-risk + untrusted_web), so hosts stop guessing from stderr. Library callers still get the bare row list from the Python API.

update --json mirrors add --json ({"id", "result", "created_new", "warnings"}). get (always JSON) includes the v13 episodes key — the episodes this memory belongs to (always a list, possibly empty).

recent/search accept the same --include-global/--global-limit pair as recall (project-first global tier union). recent now ALSO honours v5 migration aliases (so recent --namespace <old pre-v5 key> finds rows migrated to the new key). search now accepts --no-bump for a passive query that records a surface on surfaced_count (never advancing retrieval_count) instead of bumping like recall (issue #21).

search --exact (issue #263) switches the matcher from FTS5 to a literal substring existence check: --text is matched verbatim (case-sensitive, contiguous) against memory.content, so stop-word-only literals, FTS5 query syntax (OR, quotes, leading -k), and word order all stay literal instead of being normalized into a token query. Use it to confirm an exact id, path, error string, or redaction marker is or is not present verbatim; plain search (no --exact) is unchanged byte-for-byte. --exact honors the same --namespace, --include-global/--global-limit, --as-of, and --exclude filters and the same --no-bump (issue #21) semantics as plain search, needs no embedding model, never link-expands, and orders results most-recent-first. The literal match defeats every index (SQLite instr byte-scans each row in scope and the sort runs before LIMIT), so cost scales with the scoped row count — fine for operator existence probes, not a hot-path substitute for ranked search. An empty --text trivially matches every row in scope (Python "" in content semantics).

--as-of ISO-8601 (v9, issue #59 4.4) on recall/recent/search returns rows valid at that instant: valid_from <= as_of AND (valid_until empty OR valid_until > as_of). valid_from is INCLUSIVE, valid_until is EXCLUSIVE, and empty valid_until means "never expires". Because a tombstone writes valid_until at the same instant as superseded_at, a point-in-time as-of may return rows that were later superseded (they were valid then) — only rows whose validity interval contains as_of surface. This is what makes update history readable: --as-of before the update returns the OLD content.

--as-of recoverability is lane-bounded in hybrid mode: a historically tombstoned row that was valid at as_of is reachable only through the lexical (FTS5/BM25) lane. The vector lane's candidate pool always filters superseded_at IS NULL (live-only), so a row superseded before as_of cannot surface via semantic KNN even though it was valid at that instant. Model-absent runs are lexical-only and unaffected; a hybrid run that misses old content via the vec lane should be re-run with --no-hybrid to include the FTS lane.

reembed — backfill or rebuild semantic embeddings

<interpreter> <store.py> reembed
<interpreter> <store.py> reembed --all [--profile NAME] [--batch N] [--dry-run]
<interpreter> <store.py> reembed --check

Flagless form (unchanged contract): backfills embeddings for live memories that are MISSING them when the optional embedding runtime and model are available. Existing embeddings are preserved; no runtime means a graceful skip.

--all rebuilds EVERY live memory's vector under the selected profile — the operator-grade converter when you switch profiles:

  • --profile NAME selects from the shipped registry (minilm, fake; see "Embedding profiles" below). Default: active ZMEM_EMBED_PROFILE or minilm. Requires --all — --profile without it refuses with the exact conversion command, never a silent no-op. --all --profile fake requires --confirm ONLY when the store already holds non-fake committed vectors (the overwrite is irreversible-in-practice without a working model later); fresh/empty stores convert freely.
  • If the profile's dimension differs from what the store holds, memory_vec is recreated at the new dimension INSIDE one transaction — a crash mid-run rolls back to the pristine pre-run state, so a half-dim index is impossible. Every command then re-verifies profile-vs-store dimension before it embeds (mismatch = exit 2 with the exact remediation line).
  • Idempotent: a second identical run changes 0 rows. retrieval_count, content, and every other column stay untouched — only embedding bytes, their embedding_model marker, and embedded_at are rewritten, plus the embedding_profile meta key recording the last completed conversion.
  • --batch N paces stderr progress lines ONLY (display chunks inside the single transaction — batches are never separate commits). For very large stores run during an idle window: the single transaction briefly grows the WAL by roughly the size of all rebuilt vectors (~1.5 KB/row at 384-dim); the default batch size is 64.
  • --dry-run reports how many of the live rows would change and writes nothing (no writer lease, no meta write).

Idempotency detail: change detection keys on embedding IS NULL OR blob dim mismatch OR embedding_model marker differs, so switching --profile back and forth always reports honestly instead of silently treating rows as current.

--check is an exclusive, read-only consistency census. It reports live rows missing a memory_vec row, vector rows whose memory no longer exists, and live embedding blobs whose exact byte length differs from the declared vec0 dimension. It exits 0 with reembed check: 0 inconsistencies; otherwise it prints the three counts and exits 1. It never downloads a model, takes a writer lease, performs auto-rekey, or changes the store. A store with an active -wal sidecar is refused with exit 2 so the census cannot recover or create shared-memory files; checkpoint the store first.

Embedding profiles — shipped registry (embed_profiles.py)

Profile hf_id dim sha256 Purpose
minilm Xenova/all-MiniLM-L6-v2 384 bbd7b466…46f0c5 (original operator-box ONNX export; see notes) Operator default; semantic recall/dedup
fake — 16 — (no files/network by design) Model-absent tests/CI ONLY; deterministic placeholders; doctor warns on non-temporary stores

Notes:

  • The minilm pair's SECOND executed artifact, tokenizer.json, ships its own pin: TOKENIZER_SHA256 = da0e7993… (the full 64-hex constant in embed_profiles.py — the checked-in tokenizer artifact's byte digest). The loader digest-verifies it from the same buffer the Tokenizer is constructed from (from_buffer), and embeddings._TOKENIZER_SHA256 re-exports the same constant (#418).
  • The published pin covers the ORIGINAL OPERATOR-BOX ONNX export of all-MiniLM-L6-v2 — preserved byte-exact in this repo's Git LFS history at commit 3240ea1a0cf3d972f6a7d472cee73abaddfc038d (skills/memory/models/minilm.onnx, 90,387,630 bytes) and served as the never-retargeted GitHub release asset model-minilm-bbd7b466/minilm.onnx (the sha-256 check after fetching — not platform immutability — is the trust boundary). It is NOT the public Xenova HF-repo export (onnx/model.onnx, oid 759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e, 90,387,606 bytes) and NOT the sentence-transformers PyTorch weights — different builds of the same checkpoint hash differently. checksum_mismatch therefore means "wrong build installed"; restore the pinned bytes from the release asset (or point ZMEM_MODEL_URL at it); doctor prints this note verbatim. Verification has NO escape hatch: ZMEM_MODEL_ALLOW_UNVERIFIED does not exist and never will.
  • A third ONNX profile was evaluated and deliberately OMITTED per the release rule: no Qwen3/Nomic local ONNX artifact with a personally verified hf_id+dim+sha256 could be pinned at authoring time. "A name with empty sha256 is a stub" is forbidden — add one only with verified facts.

Compatibility ledger for profile conversion (read before switching)

  • Older zmem releases on a converted store: versions without this change hardcode float[384] and do not know about embedding_profile. On a store converted to another dimension they keep opening it, keep writing rows, and silently lose/degrade vector recall (the vec lane's failures are swallowed there). Conversion is per-machine deliberate maintenance — coordinate box upgrades around it.
  • Hook surfaces during mismatch: while the active profile's dim differs from committed data, every hook that recalls (--no-bump) receives an EMPTY inject envelope; the refusal reason lands only on the child's stderr, which hooks do not surface. This refuse-over-wrong-vectors posture is deliberate: lexical-only degradation inside a wrong-dim store would surface misplaced confidence rather than silence. Run reembed --all (or restore the prior profile) to clear the state.
  • doctor default-run behavior changed by design in the same release: the embeddings check now deep-verifies the model pin (mtime-keyed cached hashing), so its JSON can newly report checksum_ok:false where older versions stayed silently null.
  • Concurrency during reembed --all: the whole rebuild holds one SQLite write transaction for its duration; concurrent writers wait (busy-timeout), readers proceed on WAL snapshots. Schedule large conversions for idle windows.

Cross-encoder trust note (issue #63 review round; updated by issues #125 and #418): the operator-supplied ZMEM_CROSS_ENCODER_MODEL path stays operator-vouched (no checksum pin — treat it with the same caution as any executable), while the DEFAULT mini-pair-scorer profile path digest-gates BOTH artifacts (model AND tokenizer) at scorer BUILD time — each runtime object is constructed only from the buffer whose SHA-256 just matched its profile pin, closing the verify-then-reopen window (#418) — and any download is digest-verified before an atomic install. doctor's embeddings_health.cross_encoder block now surfaces profile/model_path/tokenizer_path/checksum_state (verified|mismatch|missing|unreadable)/tokenizer_state (not-applicable|missing|unreadable|verified|mismatch)/load_policy (explicit-operator-vouched|profile-pinned)/passive/shadow/ autodownload so a missing-model silent degrade and the passive/shadow opt-in state stay visible.

episode-open / episode-add / episode-close / episode-list — session containers (v13, issue #65 10.7)

<interpreter> <store.py> episode-open --namespace "project:<basename>" [--json]
<interpreter> <store.py> episode-add --episode <uuid> --memory <uuid> [--json]
<interpreter> <store.py> episode-close --episode <uuid> [--summary] [--json]
<interpreter> <store.py> episode-list [--namespace NS] [--json]

An episode groups the memories captured during one working session (schema v13: episode + episode_memory tables). It is a CONTAINER, not a memory type — episode is deliberately absent from --type values.

  • episode-open creates the row (ended_at empty until close) and prints its id; this is the only creator — no empty-table seeding.
  • episode-add attaches a LIVE memory (superseded_at IS NULL; a tombstoned id is refused exit-2) to an OPEN episode. Idempotent per pair; memberships are append-only (a later supersede never removes one).
  • episode-close sets ended_at, computes token_count (the same row_token_cost the injection budget uses, summed over LIVE members at close), and with --summary writes one extractive first-sentence summary row via the standard add path (capture-mode auto, so the shared redaction helper runs) linked by summary_memory_id. A closed episode cannot be re-closed or re-opened (append-only).
  • episode-list prints episodes newest-first with member counts (--json for rows). get --json on a memory lists its episodes.

Episodes round-trip through export-jsonl/ingest-jsonl via a kind discriminator ("memory" on memory rows; separate episode and episode_memory records). Legacy kind-less files still ingest as memory rows; an older client consuming a new export should filter to kind == "memory" lines. Doctor reports counts (episode-tables).

session_start / session_end — MCP + Hermes pairing tools (v13, issue #65 10.5)

Both surfaces expose session_start / session_end (MCP tools) and zmem_session_start / zmem_session_end (Hermes tools) with the same contract:

  • session_start(namespace?, limit=3, session_id?, lane?) — passive prefetch returning a fenced, provenance-tagged context block (Phase 3 fence + selective-inject rules, 0.5 recent floor). It runs recent --for-injection --json: with a session_id the store-side selector (moment session_start) owns the gate, the budget, the delivery ledger, and the rendered fence, and the tool returns that complete envelope plus the additive context alias and the back-compat result/namespace/ids fields (issue #159); an omitted session_id takes the legacy for-injection envelope. retrieval_count NEVER advances (only the surface event is recorded — pinned by tests/test_session_tools.py), injection-risk and untrusted_web rows are omitted, and ZMEM_INJECT_TOKEN_BUDGET is honored. Namespace omitted resolves to the surface's own default — user:global on MCP, the session namespace on Hermes (a deliberate divergence: the Hermes provider is session-scoped, the network server is not).
  • session_end(note?, namespace?) — default is a NO-WRITE acknowledgement so clients can pair start/end freely; it NEVER organizes or consolidates (run the explicit CLI for that). With a note, exactly one row is written via the standard add path (fact, signal none, taint untrusted_tool, capture-mode auto so redaction runs), defaulting to the server/session namespace.

Scoped MCP tokens (v13, issue #65 10.2)

ZMEM_MCP_TOKEN (env) is always an UNSCOPED operator token — full access to every namespace, exactly the pre-v13 behavior. To scope it, point ZMEM_MCP_TOKEN_FILE at a JSON object:

{"token": "<secret>", "namespaces": ["project:zmem", "user:global"]}

Allowed namespace forms are project:name, user:name, fleet:name, host:name, agent:name, and domain:name. Project and user values retain later-colon compatibility; existing local project path keys may also contain internal spaces. The fleet, host, agent, and domain forms require one non-empty value without whitespace or colons.

Requests outside the allow-list fail closed with the stable namespace_not_allowed error. supersede/invalidate are namespace-guarded too (issue #109): for a scoped token the server reads the target row's namespace, denies with the same namespace_not_allowed shape when it is outside the allow-list, and pins the verified namespace on the store mutation via --expected-namespace (a new supersede/invalidate CLI flag that makes the tombstone UPDATE conditional on the row's namespace — refuse exit 2 with the stable [zmem] namespace guard: stderr line, nothing written, on mismatch; update --expected-old-namespace pins the replaced row the same way; omit for the historical unguarded local-operator behavior). The empty string counts as a supplied (never-matching) expectation and fails closed, not as "no guard". Unscoped operator tokens keep full tombstone powers everywhere. Scoped tokens MUST pass an allowed namespace explicitly on every read — a namespace-less read spans the whole store and is denied — and the implicit user:global union on scoped reads is suppressed unless user:global is itself in the list. Malformed JSON, an empty namespaces list, or near-miss scopes like "global" are hard startup errors (exit 2). A bare (non-JSON) token file stays unscoped, as does a JSON object with namespaces absent or null. Doctor warns unscoped_token: true on operator tokens (issue #65, 10.10) and never prints the token value.

promote — review and install a reusable skill

<interpreter> <store.py> promote --dry-run [--namespace NS]
<interpreter> <store.py> promote --id <uuid> --confirm [--description "..."]
<interpreter> <store.py> promote --id <uuid> --confirm --install-approved

--confirm writes a review candidate; --install-approved is the additional explicit gate that installs the reviewed skill into Codex, Claude Code, Zcode, and OpenCode skill directories. Promotion is never an unattended hook action.

The promote ladder (issue #64, schema v12) decides which lessons are candidates. A lesson is eligible only when its EXPLICIT usage feedback says it works and nothing says it doesn't:

  • applied_count >= 3 AND violated_count == 0 → eligible (see feedback).
  • Any violated_count > 0 blocks eligibility; the 2nd violation has already dropped the row's trust_score by TRUST_VIOLATION_FLOOR_DROP (0.15, clamped at 0.0, applied exactly once at the violated_count 1→2 crossing). signal is NEVER changed by feedback — trust_score is the usage ledger, signal is provenance.
  • Passive exposure does NOT count: retrieval_count/surfaced_count (hooks, --no-bump recall) are not eligibility inputs. Only the explicit feedback CLI advances the counters, so nothing auto-promotes.
  • The other candidate bars are unchanged: type=lesson, grounded signal (test/compile/lint), confidence >= 0.85, live row. promote --dry-run with no eligible rows is a no-op that prints [zmem] no promotion candidates found (exit 0).

feedback — record explicit usage feedback (Voyager counters)

<interpreter> <store.py> feedback --id <uuid> --applied
<interpreter> <store.py> feedback --id <uuid> --violated

Increments exactly one of the row's applied_count / violated_count (schema v12) and prints a one-line JSON summary (id, verdict, both counters, trust_score, trust_dropped). This is the ONLY writer of those counters anywhere — hooks, --no-bump recall, PreCompact, and Hermes prefetch never advance them. Exit codes: 0 success; 1 target missing or not live (tombstoned rows refuse); 2 usage error (both or neither of --applied/--violated, missing --id).

eval — offline recall-quality harness

python scripts/eval_runner.py --store <path> [--gold eval/gold.jsonl] [--k 5] [--fail-under X] [--json-out PATH]

eval-inject — injection precision gold (issue #111)

python scripts/eval_inject_runner.py --store <path> [--gold eval/injection_gold.jsonl] [--k 5] [--fail-under-precision X] [--fail-under-false-injection X] [--compare-baseline eval/baseline-injection.json] [--json-out PATH]

Runs every injection-gold item through the REAL --for-injection lane — the same selective-inject gate and token budget the hooks execute — and scores the RENDERED set with negative controls: precision_at_k, false_injection_rate, empty_pool_rate, hit_at_k, overall and per moment (user prompt, pre-tool operation tokens, subagent task text, pre-compaction). Refuses (exit 2) if the gate or budget is stubbed out. --gold accepts a local labeled JSONL (e.g. derived from your own decision log) without code changes. Record-only by default; the ratchet flags are the one-switch CI gate.

The score-margin replay/baseline evaluator is available for the #155/#183 read-only audit. CI runs the committed scripts/eval_replay.py snapshot with eval/baseline-replay.json on both platform legs. The evaluator is record-only unless an explicit --fail-under ratchet is supplied; it never resolves the operator store or claims live-host efficacy. Runs every gold item through the REAL recall pipeline and prints one JSON report: hit_at_k, mrr, as_of_accuracy, injection_omit_rate (+ per-bucket and per-item detail). --store is REQUIRED — the runner never resolves the default home store; point it at a throwaway path and it auto-builds the deterministic 64-row fixture corpus (the ids eval/gold.jsonl names) there. The gold set covers the six issue-#64 buckets (as-of knowledge updates, injection omission, entity aliases, namespace isolation, contested/superseded guidance, ordinary FTS hits; >= 5 items each) plus the issue-#82 honesty buckets (>= 3 items each): retraction (an invalidate --reason-ed fact must vanish at current time yet surface under --as-of before the tombstone), polarity (two LIVE contradicting rows — both sides surface), and change-intent (explicit-path lineage unfold). An optional "explicit": true gold flag runs an item on the explicit path (no_bump=false, still zero-write via no_telemetry) so the change-intent unfold is measured; passive twins on the same rows pin that hooks never unfold. The original 30 ids (rowids 1-50) are the frozen #64 contract; #82 appends rowids 51-64. Exit 0 regardless of scores (quality is collected, never gating); --fail-under is optional and OFF in CI; 2 on operational errors (invalid gold set names the offending item). The run is model-absent by construction: the fake embedder (ZMEM_EMBED_PROFILE=fake) and a pinned clock (ZMEM_TEST_NOW) make ranking deterministic on any machine. Evaluation is passive (no_bump) and measures retrieval quality — link expansion and MMR presentation are excluded (link_hops=0, no_mmr). Self-corpus probe — measure recall against YOUR corpus (issue #82):

python scripts/eval_self_corpus.py --store <store-copy> [--k 5,20] [--limit 100] [--json-out PATH]

--store is REQUIRED and the runner REFUSES the host default store path (exit 2 with a remediation: take a store.py backup snapshot and probe the copy) and refuses nonexistent paths before creating anything. Probes are templated deterministically from the store's own live rows (first sentence / distinctive tokens / entity aliases — domain-agnostic, no LLM). Every probe is fully passive (no_telemetry, no_bump, link_hops=0, no_mmr; the unfold structurally cannot fire) so the probed store stays byte-identical. The report may embed operator content — --json-out is the only write and reports are gitignored; never commit one. Public-corpus adapters convert on-disk corpora into gold JSONL and skip cleanly when the corpus is absent (CI downloads nothing):

python scripts/eval_adapters.py --adapter longmemeval --input <path> --out <path>
python scripts/eval_adapters.py --adapter locomo --input <path> --out <path>

A missing --input prints skipped: ... and exits 0. Synthetic 3-row toy fixtures under tests/fixtures/adapters/ prove both converters.

tune-weights — suggest recall scoring weights (dry-run only)

<interpreter> <store.py> tune-weights --dry-run --gold eval/gold.jsonl [--k 5]

Evaluates the shipped composite weights (W_BM25/W_CONFIDENCE/W_RECENCY/ W_POPULARITY) against a gold set, hill-climbs a small deterministic candidate set, and prints JSON with current and suggested weight vectors (always summing to 1.0) plus the metrics each achieves. The evaluation is read-only and it WRITES NOTHING — there is deliberately no --apply (the store must already be at the current schema: opening any store.py subcommand migrates it, as always). Applying a suggestion is a MANUAL edit of the W_* constants at the top of skills/memory/scripts/storelib/recall.py, keeping the sum at 1.0. Uses the CURRENT store (env-resolved like every subcommand — in tests/CI that is a fixture store via ZMEM_STORE, never the operator store). Exit 0 even when the current weights already win; 2 on an invalid gold set, a missing --dry-run, or an evaluation failure.

consolidate — merge near-duplicate memories

<interpreter> <store.py> consolidate [--threshold 0.80] [--prune] [--dry-run] [--namespace NS] [--force] [--merge-contested] [--json]

Clusters live memories by embedding cosine similarity (Jaccard token overlap when embeddings are unavailable), picks a keeper, merges metadata, and supersedes the rest with a persisted reason. The automatic SessionStart cadence now invokes organize (issue #62, 7.7) — which runs consolidate's EXACT same cluster logic on a bounded episode — so the 7-day / 20%-growth gate below protects the schedule REGARDLESS of entry point: organize and consolidate share the same last_consolidation meta keys and the same single-flight lock. An automatic run is skipped only when the last run was <7 days ago AND growth was <20%; both bounds are env-tunable via ZMEM_CONSOLIDATE_MIN_INTERVAL_DAYS / ZMEM_CONSOLIDATE_GROWTH_THRESHOLD. The consolidate CLI remains available for explicit/manual scope runs. When the gate declines, the run prints a single [zmem] consolidate: skipped by cadence gate (...) line (never silent) and changes nothing. Use --dry-run to preview clusters and the exact merge decision per row without mutating the store — --dry-run models the cadence gate too, so a gated dry run reports would skip by cadence gate rather than would merge N, and a dry run that says "would merge" implies a real run that merges. Pass --force to bypass the cadence gate and run consolidation now (this is the only intentional bypass; --threshold does not affect the gate).

Keeper selection: within a cluster, the survivor is the row with the highest confidence * (retrieval_count + surfaced_count) product — total surface events, blend-aware for hook-surfaced memories (ties broken by confidence, then earliest ingestion) (issue #21). Absorbed rows are NOT lost on merge: a near-duplicate that is not fully subsumed by the keeper has its ENTIRE text appended to the keeper's content (live-recallable + FTS-indexed), and its id is recorded in the keeper's merged_from provenance column, so consolidated memory is non-lossy (v11, issue #61 6.6: the column is maintained as a DE-DUPLICATED, first-seen-order id list — the same absorbed id is never recorded twice, and the v11 migration normalized pre-existing duplicates losslessly). Note this is a whole-text append, not a unique-tail diff — a partially-overlapping row duplicates the span it already shares with the keeper (deliberate over-preservation, bounded by the size cap below). Reordered same-token phrasing (e.g. "call foo before bar" vs "call bar before foo") is likewise preserved as distinct content — only genuinely subsumed/duplicate text is omitted.

Size cap: a keeper's content is never allowed to exceed INGEST_MAX_CONTENT_CHARS (the JSONL round-trip ceiling, 65536). An absorb that would push the keeper over the cap is NOT appended — its id is recorded with a :truncated marker in merged_from and the absorbed row is still superseded; that is the one bounded case where an absorbed row's text is not migrated live (it remains only in the tombstoned history row). --dry-run shows would-lose tokens for this case. --prune also removes low-value NEVER-surfaced and never-retrieved memories (retrieval_count = 0 AND surfaced_count = 0, low confidence, old, signal = none; opt-in, never automatic). A hook-surfaced memory is protected — retrieval_count = 0 alone is not evidence of unused (issue #21). Unrecalled extension (issue #62, 7.6): a live row whose last_surfaced is OLDER than ZMEM_UNRECALLED_DAYS (default 30) qualifies EVEN with surfaced_count > 0 — a surface event that has itself gone stale loses its issue #21 protection; NULL last_surfaced keeps the surfaced_count = 0 rule unchanged and signal != none is never pruned.

Consolidation is single-flighted: concurrent runs (several sessions starting at once) take an advisory lockfile in the store dir, and the losers skip cleanly and exit 0 rather than clustering the same rows twice.

organize — sleep-time organization (SessionStart cadence job)

<interpreter> <store.py> organize [--prune] [--dry-run] [--force] [--json]

The SessionStart sleep-time maintenance job (issue #62, 7.7): the session-cadence batch that the start hook launches detached runs ORGANIZE, not consolidate. It bounds an "episode" of work to the most recent live rows, backfills any enrichment those rows are missing, runs consolidate's identical cluster/absorb/contested machinery on exactly that set, then adds sleep-time deliverables: a topic hierarchy, hierarchical extractive summaries, and extractive compression of the rows consolidation just grew. Everything is deterministic and LLM-free by default; there is no --threshold/--namespace to keep the two maintenance entry points aligned (see the consolidate section: organize and consolidate SHARE one cadence gate implementation, one cadence clock, and one single-flight "consolidate" lock, so on a given store at most one maintenance run happens per cadence window — whether it was triggered from SessionStart, a manual CLI call, or the Hermes session-end hook).

Pipeline (in order):

  1. Shared cadence gate — the SAME 7-day / 20%-growth gate as consolidate (ONE shared implementation, _cadence_gate_skipped, so the two entry points can never drift), using consolidate's last_consolidation meta keys (two entry points, one clock). --force is the only bypass. A gated run prints [zmem] organize: skipped by cadence gate (...) and changes nothing; --dry-run models the gate (would skip by cadence gate).
  2. Optional idle gate — ZMEM_ORGANIZE_IDLE_HOURS (default 0 = off). When set, organize refuses to run unless the store has had no live-memory activity (max of ingestion_ts/last_retrieved/last_surfaced) for at least that many hours: sleep-time jobs should not churn a store that is mid-use. A skip prints [zmem] organize: skipped by idle gate (...).
  3. Working set / episode bound — the N most recent live rows (ZMEM_ORGANIZE_EPISODE_BOUND, default 256, clamped to the per-namespace consolidation cap), box-wide. 0 disables real work. organize's own summary rows are EXCLUDED from the episode — keyed on the structural source_ref organize: prefix, never the (mutable) tags column, so summaries are the pipeline's OUTPUT and never its input. The organize: prefix is therefore RESERVED for summary rows: a manually-added row whose --source-ref begins with organize: is excluded from episodes the same way, and consolidate additionally EXCLUDES every live organize:% row from its candidate set so the pipeline's output can never become its input (issue #77). Every CLI write surface (add, update, ingest-jsonl, including per-row source_ref fields in ingested JSONL) warns when a non-empty source_ref carries the reserved prefix — [zmem] WARNING: source_ref prefix organize: is reserved for organize summaries on stderr; the write is NOT rejected and stdout (including --json output) is untouched.
  4. Entity backfill — every working row missing memory_entity links gets them via the deterministic extractor (idempotent; rows with nothing extractable are counted as candidates but link nothing). The card sweep's env configuration (ZMEM_CARD_MIN_MENTIONS / ZMEM_CARD_TOP_N) resolves BEFORE this loop — before ANY write of the run (PR #385 review F-4); an invalid value prints one stderr line, skips the card stage with zero counts, and never aborts the run. Immediately after the backfill (issue #179): the entity-card cadence sweep — see "Entity cards (issue #179)" below. Per entity (ascending entity-id order) one deterministic card is created, refreshed, or tombstoned; the report (and organize --json) carries the five counts under entity_cards: created / refreshed / unchanged (SHA-256 no-op — no SQL write) / removed (a live card that fell below the threshold) / skipped (non-qualifying entity with no live card, AND per-entity apply failures — one bad entity rolls back only its own transaction, logs one stderr line, and the sweep continues). --dry-run computes the same counts read-only; the session-cadence JSON envelope carries them under entity_cards too.
  5. Link backfill — every working row missing memory_link edges gets them via the standard write-time linker (related/contradicts at ZMEM_LINK_THRESHOLD).
  6. Episodic consolidation — consolidate on EXACTLY the working set (keeper selection, absorb, contested guard + optional NLI judge, bounded by the same per-namespace cap). The optional NLI judge is bounded by a RUN-WIDE budget (issue #77): ZMEM_NLI_MAX_CALLS (default 64) judge subprocess invocations and ZMEM_NLI_MAX_SECONDS (default 30.0) seconds of wall time per run; invalid, non-finite, or non-positive values fall back to the defaults. When the budget is exhausted the judge stops, one [zmem] consolidate: NLI judge budget exhausted (...) warning names the reason, and affected contested clusters stay unmerged — even under --merge-contested. Judge diagnostics carry member ids, pair indexes, and the verdict only, never memory content. organize's synthesized writes (compression replacements and summaries) declare capture_mode="auto". The vector neighbor lookup is bounded to the episode too — an out-of-episode near-duplicate in the global vec0 index is never pulled in. See the consolidate section for semantics.
  7. Compression — the keepers consolidation actually GREW, when their content exceeds ZMEM_KEEPER_COMPRESS_CHARS (default 4000; 0/negative degrades to the default — it is a cap, not a switch), are compressed deterministically: order-preserving UNIQUE sentences, dropping only from the TAIL on whole-sentence boundaries (never mid-sentence; the pre-compress text stays on the superseded history row and merged_from provenance is carried to the replacement). Compression runs BEFORE topic identity is recorded: compression replaces the keeper's id (append-only update), so keying topics first would leave every later run with a different key and a duplicate summary.
  8. Topics + summaries — a related-graph over the POST-COMPRESSION live working rows using the SAME neighbor predicate at the same threshold consolidate would use; leftover singletons are grouped by SHARED entity (A-MEM lite). Every live NON-summary working row lands in exactly one topic. Topics of ≥3 members get a hierarchical extractive summary: a REAL row (type=fact, tags exactly summary,topic, signal=none, confidence 0.5, source_ref=organize:<members>, merged_from=<members>), built from the first sentence of each member (de-duplicated, capped, never truncated mid-sentence). Members stay live; the summary is FTS/entity/search recallable. Idempotent: a re-run finds the existing summary by its STRUCTURAL identity (source_ref + merged_from — never the mutable tags column) and UPDATES it (Phase 4 of the issue) instead of duplicating; when a topic's membership legitimately changed, the stale overlapping summary is superseded (reorganized into a changed topic) rather than left as a live orphan. Summary writes never propagate the summary,topic marker onto neighboring user rows. If the update/create would fold into a dedup target, organize logs and skips rather than rewriting a stranger's row.
  9. Unrecalled prune (pass-through) — --prune enables the consolidate --prune rule, extended (issue #62, 7.6): a live row also qualifies when its last_surfaced is OLDER than ZMEM_UNRECALLED_DAYS (default 30) even with surfaced_count > 0 — a surface event that has itself gone stale loses its issue #21 protection (both boundary comparisons are made in a format-agnostic way, so the store's ISO-8601 timestamps compare correctly against the cutoff). signal != none is never pruned; never automatic.

--dry-run writes NOTHING (no meta, no backfill, no summaries) and the --json report carries per-step would-be counts (entity_backfill, link_backfill, entity_cards, topics, summaries, compressed, pruned, episode_ids, consolidate sub-report). A concurrent organize/consolidate loses cleanly (exit 0, a - skipped notice, or {"error": "organize lock busy"} under --json).

Optional local NLI judge (7.5): when ZMEM_NLI_CMD is set to an argv template (e.g. ZMEM_NLI_CMD='python /path/to/your/local-judge.py'), consolidate's contested branch consults it for mixed-polarity clusters; only an entailment verdict on EVERY polarity-flagged pair — ANY two members whose negation polarity differs, not only pairs anchored on the keeper — un-parks the merge, and any other verdict or failure parks it (never auto-merges; a failure is reported on stderr as a distinguishable NLI diagnostic). The judge receives the two first-sentences on stdin (one per line, UTF-8) and prints a verdict word. On Windows, backslashes in the template are normalized to forward slashes before parsing (POSIX-style backslash stripping would otherwise mangle C:\path\judge.py; quote paths containing spaces). Unset = byte-identical behavior (no NLI); a deterministic regex polarity remains the fallback for a response you judge ambiguous.

Schema compatibility: organize NEVER changes the schema. All of its outputs use pre-existing columns (merged_from, source_ref, tags, confidence, source_hash), so a store stamped at an older schema version keeps opening normally in an older client until the new version is installed — verify a template store with store.py organize --dry-run before upgrading.

Entity cards (issue #179)

One deterministic entity_card memory row per qualifying entity — a continuously refreshed summary card with provenance, built and maintained entirely store-side. The #172 taxonomy decision REJECTED this type; the #179 workstream contract reverses that decision (the #175 kinds precedent: the RFC map is a frozen record, not a veto), and ALLOWED_TYPES carries entity_card as the 8th type. A card is an ordinary memory row keyed by source_ref=card:<entity_id>; the schema stamp is 17 (0.107.0, PR #385 review F-1): the bump is STAMP-ONLY (no DDL), but it is upgrade-required — see the operator note below.

KEY OPERATOR NOTE (schema 17, upgrade-required; FORWARD since superseded — see the schema 18 note above). For THAT bump FORWARD_COMPAT deliberately stayed at 15: every pre-0.107 client (<= 0.106) sharing a store refuses it, loudly at open, once the first 0.107 client migrates the stamp to 17. Without the bump those clients would fail SILENT on entity_card rows (an unknown type raises in their type preference and their hooks swallow the error — a fleet-wide memory blackout the review round executed end-to-end). On shared-store machines update every client BEFORE the first 0.107 host runs a cadence; ZMEM_ALLOW_NEWER_SCHEMA=1 is the operator stopgap (proceed at your own risk; it never downgrades the store).

Type and birth invariant. entity_card is CARD-REFRESH-ONLY: card rows are born ONLY through store.py entity-card build|refresh and the organize cadence sweep. Every interactive add surface refuses the type (add --type choices derive from ALLOWED_TYPES minus entity_card; the Hermes/MCP add tools validate against the same exclusion), jsonl ingest refuses it line-numbered (ingest bypasses add_memory and must never mint cards), an ordinary add_memory call with the type but without the refresh path's force_append=True raises ValueError, every ordinary write rejects any tag carrying the reserved entity_card_sha256= prefix BEFORE mutation, and (since the PR #385 review round) the card: source_ref prefix is reserved the same way — an ordinary add/update cannot claim a card identity. The SAME guards cover the update path (review F-2): update --type entity_card on an ordinary row is refused (type conversion into the reserved type), an explicitly supplied watermark-prefix tag is refused, and a card: source_ref claim is refused. Content-only updates of pre-existing card rows keep the full type enum and succeed — the state precedent; a transient watermark desync from such an edit self-heals at the next refresh, which recomputes the digest over the new bytes (disclosed).

Configuration. ZMEM_CARD_MIN_MENTIONS (default 5, inclusive) is the live non-card mention count an entity needs to qualify; ZMEM_CARD_TOP_N (default 8) caps the card's provenance rows. An absent variable selects its default; a present empty, non-integer, zero, or negative value raises before any card write starts (CLI exit 2, store bytes unchanged).

Rendering. The card is UTF-8, LF-terminated, fixed field order: Entity: <canonical_name> / Kind: <kind> / Aliases: <sorted alias_norm> / Mentions: (one - score=<six decimals> last_seen=<ingestion_ts> id=<memory_id> source_ref=<ref> text=<single-line> per selected row) / Last seen: <max ingestion_ts over EVERY live non-card source row linked to the entity> / Sources: (one - <memory_id> per selected row, same order). Mention text collapses CR/LF/whitespace runs to one space. Source rows are live (superseded_at IS NULL, empty valid_until), non-card, linked to the entity across ALL namespaces, NULL confidence reads 0.0, and ordered confidence DESC, ingestion_ts DESC, id ASC.

Provenance and lifecycle. Every card row lives in user:global with source_ref=card:<entity_id>, signal=none, confidence 0.5 (above the recall floor — a signal=none row's inherited 0.2 would be invisible; the summary precedent), capture_mode=manual, and the reserved entity_card_sha256=<hex> tag (SHA-256 over the card's exact UTF-8 bytes — the no-op watermark). The card's TAINT is the WORST OF its own signal-derived default (signal=none -> untrusted_tool) and every SELECTED source row's taint (PR #385 review F-8): one untrusted_web mention makes the card itself untrusted_web, so the passive untrusted fence withholds the card exactly as it withholds the source it aggregates. The card row is linked DIRECTLY to its entity — that is its only entity link. REDACTION TRADEOFF (disclosed): the card is written with capture_mode=manual — the pinned mode that keeps the watermark tag from being dropped — so its machine-assembled content is NOT re-redacted at card write time; the source rows were already redacted at their own writes, and their rendered mention text is the already-redacted form. Refresh is append-only and fail-closed: identical content bytes AND matching watermark return (existing_id, False) with NO SQL write; changed content tombstones the old row (superseded_at AND valid_until both set — history stays queryable) and inserts the replacement with update_of=<old_id>; any failure (tombstone, insert, link, watermark) raises and rolls back the whole transaction. The liveness decision is made on a RE-READ inside the BEGIN IMMEDIATE (the pre-lock read is only the no-op fast path), and the entity-card CLI single-flights on the shared consolidate advisory lock with the cadence sweep, so two card writers cannot interleave (PR #385 review F-6). Writer timestamps come from one schema.now_iso() value per sweep and NEVER read ZMEM_TEST_NOW. The cadence sweep (organize step 4b) enumerates entity ids in ascending byte order and returns exactly the five counts created/refreshed/unchanged/removed/skipped; a live card whose entity fell below the threshold is tombstoned and counted removed; skipped also covers per-entity apply failures (an oversized card, a stale-plan supersede) — one failure logs one stderr line, rolls back only that entity's transaction, and the sweep CONTINUES (PR #385 review F-4).

CLI. store.py entity-card build ENTITY --json creates a missing qualifying card (created), refuses a live card with exit 2 and no write, and reports no_card (no write) below the threshold; entity-card refresh ENTITY --json returns created / updated / unchanged / no_card (below-threshold refresh tombstones the live card first). ENTITY resolves an exact entity id first, then an exact canonical name (zero or multiple name matches: exit 2). --json stdout is one object with the exact key order result, id, entity_id, source_ref, warnings (id/source_ref are JSON null for no_card); errors before the write exit 2 with store bytes unchanged. The command single-flights on the shared consolidate advisory lock with the organize cadence (a lock-busy run exits 1 with a stderr notice, never a silent skip).

Cards never travel. export-jsonl omits entity_card rows from every export shape (live-only and --include-superseded): cards are store-local derived state, every ingest surface refuses the type, and the destination re-derives them via its own organize cadence sweep (PR #385 review F-3).

Same-fence recall suppression. With prefer_cards=True (the default, entity_match_ids), a live card of a query-matched entity enters the entity arm with that entity's relevance. One suppression pass resolves the selected card rows by exact source_ref=card:<entity_id> plus a direct entity link — never by matching card text — and removes ALL live non-card rows linked to those entities from the fence under the active namespace and as_of predicates: the fence renders the card, not the mentions it summarizes. The pass runs at EVERY merge site (complete enumeration, PR #385 review F-5): (1) the scoped five-tier pools when the global tier is included (--include-global); (2) the cross-tier merge (namespace + --include-global); (3) the UNSCOPED default surface (namespace=None, the default recall --query path) — its single all-namespace pool renders one fence, so the card's represented sources leave it too; (4) the cross-project hazard pool, cleaned before its rows splice into the fence; (5) --explain's presented pools, mirroring (1)-(3), AND its deep "scored but beyond --limit" pools, so the verdict stream agrees with the presented fence; and (6) the suppressed-id set is threaded into expand_recall_links' exclusion, so a removed source row cannot re-enter the fence as a 1-hop link neighbour of a surviving row. The PASSIVE prefetch lane (recent / SessionStart pulls) never renders cards at all — entity_card rows are excluded from every recent pool, so the query-less branch can never render card and sources together. A query with no selected card keeps its current result; a project-only or scoped-without-global recall selects no card and applies no suppression. (Disclosed placement: the pass runs after per-tier arm caps, RRF fusion, and MMR — suppressed rows may already have consumed tier slots; per-tier placement is impossible because the card lives in user:global while its represented sources live in project fences.) Cards are NOT in PROTECTED_INJECT_TYPES — they are ordinary recall rows for explicit id retrieval.

rekey-namespace — remediate stranded namespace rows (admin)

<interpreter> <store.py> rekey-namespace --near-miss-global [--to user:global] [--dry-run] --confirm
<interpreter> <store.py> rekey-namespace --from <old-namespace> --to <new-namespace> [--dry-run] --confirm
<interpreter> <store.py> rekey-namespace --map <map.yaml> --dry-run
<interpreter> <store.py> rekey-namespace --map <map.yaml> --confirm

Rewrites the namespace column of live rows. The primary use is remediating legacy rows stranded under a global near-miss namespace (global, userglobal, …) that pre-date the add/ingest-jsonl write-time guard — such rows are unreachable from the automatic hooks. --near-miss-global rekeys EVERY live near-miss row to --to (default user:global); --from rekeys one exact namespace. --to may not itself be a near-miss. --confirm is required to write (without it, or with --dry-run, the command only previews). Superseded rows are left untouched (history preserved).

Since issue #71 C this remediation also runs AUTOMATICALLY: every store-opening command rekeys global-near-miss rows to user:global before dispatching (silent on healthy stores; ZMEM_AUTO_REKEY=0 or --no-auto-rekey opts out). The explicit command remains for previews and for kill-switch deployments. Namespace contract: fleet facts live in user:global; project facts in project:<canonical-git-remote>; MCP add without a namespace uses ZMEM_MCP_DEFAULT_NS when the operator set it, else user:global — a bare global is never invented.

--map is separate from the legacy selectors. Its file is UTF-8 without a BOM and contains only unindented quoted pairs such as "db:source-prefix": "fleet:scope"; blank lines and full-line comments are allowed. Entries are evaluated in file order, so the first matching source_ref prefix wins. A source prefix must also exclude C0/C1 controls, whitespace, and = so it cannot forge key/value fields in the decision log. Every target must satisfy the shared namespace grammar; its value is percent-encoded in the log, so valid project/user names containing spaces or = remain safe. Map mode requires exactly one of --dry-run or --confirm and cannot be combined with --from, --to, or --near-miss-global.

Both map modes enforce the store's forward-schema compatibility gate through the connection they use for the operation. If the store has a newer unsupported schema, upgrade this plugin or use the documented compatibility override before previewing or applying the map.

The preview opens the existing store read-only and prints one count per map entry plus unmapped, with no backup, log, lease, migration, or auto-rekey. Apply takes a verified snapshot before its single transaction, updates and relinks only IDs whose namespace changes, then appends one decision-log line per map entry with separate matched and moved counts. A post-commit decision-log failure reports exit 3 and explicitly says that the map changes are committed; pre-commit failures return nonzero without that committed-state diagnostic. Restore the verified snapshot if recovery is required. Apply loads the optional sqlite-vec extension when available and checks vector bytes in the transaction; link endpoints are checked on every apply.

promote-store — merge a leftover second store (admin, issue #71 E)

<interpreter> <store.py> promote-store --from <path-to-store.sqlite> [--dry-run]

One-shot merge of a leftover second store (e.g. ~/.zcode/memory/store.sqlite) into the canonical one. Read-only on the source; source ids are PRESERVED so re-runs are no-ops; a NEWER source schema is refused. doctor's second-stores check fails when canonical is missing live rows that exist outside it (rows already tombstoned in canonical are only a WARN — nothing to promote) and recommends this command.

backup — verified snapshot with retention

<interpreter> <store.py> backup [--retention 7] [--out-dir DIR] [--if-due]

Writes store-<UTC timestamp>.sqlite into the backup dir (--out-dir, else $ZMEM_BACKUP_DIR, else <store dir>/backups) using SQLite's Online Backup API, which is safe while other sessions are writing to the store. The snapshot is verified (PRAGMA integrity_check and a total/live row-count comparison against the source) before it counts as a backup — if either check fails the snapshot file is deleted, the command exits non-zero, and neither the last_backup marker nor retention rotation happens.

Retention deletes only the oldest files matching store-*.sqlite beyond the newest N. Nothing else in the directory is ever touched — including the prerestore-* safety copies restore leaves behind. --retention 0 disables pruning.

--if-due makes the command a no-op unless $ZMEM_BACKUP_INTERVAL_DAYS (default 1) has elapsed since the last successful backup; the SessionStart hook uses it so the automatic snapshot is cheap almost every session. Without the flag the backup always runs. Also single-flighted (its own lockfile).

restore — recover the store from a snapshot

<interpreter> <store.py> restore --from <snapshot.sqlite> [--force] [--out-dir DIR]

Refuses unless --force when a store already exists. Verifies the snapshot's own integrity_check before touching the destination, then takes a prerestore-<timestamp>.sqlite copy of the current store (your rollback path — deliberately outside the retention glob so rotation can never prune it), clears stale -wal/-shm sidecars, copies, and re-verifies the restored store.

Takes the full maintenance lock ladder — maintenance, schema, backup, and consolidate — for its whole duration, and refuses when a live writer lease exists, so it cannot race the automated background snapshot/consolidation the SessionStart hook fires, and refuses a destination that is not on a local filesystem (no UNC/network/OneDrive path). If any lock is held it exits 2 without touching the destination — a skipped restore must never look like a completed one. A live interactive session's own add/recall writes are blocked for the restore's duration (writers wait on the maintenance gate and fail clearly after a short timeout): still run restore when no session is actively writing.

Passive-lane credential withhold (issue #256)

The hook-driven recall --for-injection path re-scans every selected row with the credential patterns from the write-time registry (SECRET_CREDENTIAL_PATTERNS — key=value shapes, PEM headers, gh*_ tokens, AKIA keys; plus whatever shapes issue #180 adds). A matching row is delivered as an id+type-only bullet marked [WITHHELD: SECRET]: its content, source_ref, tags, and entity names never reach the hook payload or the model context, it is not counted in surfaced_count, and the delivery ledger entry for it carries no credential text. The JSON envelope gains secret_withheld (a count) only when a withhold happened — clean runs stay byte-identical. Ordinary rows are untouched: rows carrying plain 40-hex git SHAs render normally (the generic hex/base64 detectors are deliberately NOT used at read time). The re-scan runs BEFORE the token budget, so a credential straddling the budget cut is withheld whole — never leaked as a clipped fragment — and --for-injection applies it on the no-session lane too. Since 0.142.0 the explicit lanes are filtered as well: recall/recent reduce credential-shaped rows to the [WITHHELD: SECRET] stub (PR #440, issue #352 AC3); since 0.144.0 list (both the human line and the --json payload, including the hub/remote read route) and export-pack (body bullets, the pack title, and the empty-pack refusal) reduce credential-shaped rows the same way and blank a credential-shaped namespace at render (issue #443). get by id remains an unfiltered operator surface. Sibling envelopes the selector builds for silent/kill-switch reasons never carry the key. Known residuals: a credential stored ONLY as an entity canonical_name/alias is not detected — it renders in the fence's entity line and rides into the delivery ledger via the entry text (issue #180-adjacent; purge covers the store rows, not the entity tables, for those shapes); and the withheld bullet's metadata (id, type, namespace, confidence, signal) plus the secret_withheld counter still reveal to the model THAT a secret exists, by design. To remove a confirmed secret from the store entirely, use purge (#255); rotation of any credential that was ever passively delivered remains mandatory.

rescan-secrets — live secret rescan/remediation (issue #181)

<interpreter> <store.py> rescan-secrets --dry-run | --apply

Re-evaluates every LIVE row through apply_capture_policy (auto mode). --dry-run prints one compact JSON line (mode, rows_scanned, rows_needing_review, ascending ids) through a read-only handle — the scanned store stays byte-identical. --apply redacts each candidate via update's append-only lineage (tombstone + [REDACTED_SECRET] successor carrying update_of) in ONE transaction; any policy refusal or dedup fold rolls the whole batch back (exit 2, store untouched). The tombstoned predecessor keeps its plaintext (FTS-indexed, --as-of reachable) — run purge (below) on the reported ids for durable removal.

embedding-health — print embedding lane health as JSON (issue #131)

<interpreter> <store.py> embedding-health [--json]

Read-only vector-lane diagnostic. --json prints exactly one compact sorted-key JSON line (reason, vector_status, notice_created, notice_present, models_dir, profile, last_successful_embed, last_successful_index, unembedded_backlog) through a read-only store handle — the scanned store stays byte-identical. When the lane is degraded the command creates the once-per-data-dir sentinel (<data dir>/embedding-degraded.notice, canonical four lines: reason/models_dir/profile/timestamp) via an O_CREAT|O_EXCL write, prints vector lane: degraded (<reason>) to stderr, and still exits 0 — a degraded vector lane is a supported state; only an unreadable store exits 1. The sentinel is never rewritten (the O_EXCL write records the first degradation in a data directory exactly once), but the vector lane: degraded (<reason>) stderr line prints on EVERY degraded run.

purge — durably remove a memory's content (issue #255)

<interpreter> <store.py> purge --id <id> [--id <id2> ...] [--scrub-backups --out-dir DIR] [--json]

update/invalidate tombstone a row — the plaintext survives in the row, the FTS index, side tables, derived copies and ledger files. purge is the operator primitive that actually removes content, and it takes ids ONLY. Never pass secret text on a command line in a hooked session: the evidence writer records tool-call input into evidence rows and would re-insert the fragment.

What one purge does:

  • resolves the id plus its verified update_of predecessor chain and deletes those memory rows, their FTS entries, their memory_vec rows (by memory_id) and every id-keyed side-table row (memory_link, both endpoints; memory_entity, episode_memory, memory_evidence, belief_head_source, belief_head_evidence);
  • deletes evidence rows and entity/alias rows the purge orphaned (evidence a surviving row still references is kept);
  • rewrites, deletes, or refuses derived copies that carry the text: consolidation keepers (the \n\n--- merged from <id> ---\n block is stripped and entities re-linked), belief heads (rebuilt from surviving sources, or deleted when sourceless), and extractive episode-summary rows (episodes that contained a purged member lose their summary row — expect deletion, not surgical editing). A keeper refuses the whole purge (exit 4, naming the row, nothing deleted) when merged_from names a purged id but its stored block header is missing (the content was compressed or drifted), or when an 8+-character token of the absorbed block still appears in the keeper's own text — the byte-verify suppresses needles that a surviving row holds, so that residue could not otherwise be distinguished from a clean rewrite;
  • compacts the store (FTS 'optimize', VACUUM, WAL checkpoint) and byte-verifies the result: a case-insensitive scan of store.sqlite and -wal must find no copy of the purged content. Exit 5 means the text still lives somewhere outside the memory rows (the message names the needle): typically an evidence row no memory references — the rescan that owns those is issue #181;
  • scrubs the ids (and any needle-bearing entry) from the <data>/ops/*.ledger delivery ledgers and the issue-#252 JSONL segments (<hash>.ledger.seg.<hex>), and removes orphaned .ledger.tmp.* and .signals.tmp.* partial writes. The .pending/.compact/.feedback.jsonl/.signals ops sidecars are NOT scrubbed (outside the ledger contract; .signals carries only signal names/statuses/counts, and the finished file is swept on the shared ops mtime policy) — treat them as residue surfaces. The <hash>.tasktext delegation stash IS in scope since issue #253: purge needle-scrubs its entries (they carry untrusted host prose and have no memory ids) and reaps orphaned *.tasktext.tmp.*. The residue verify step scans the full delivery-state scope (all eight registered families plus the broad legacy ledger/reserve globs, issue #322), so a stash the scrub SKIPPED because it was unreadable/torn (the reader tolerates those states by design) is no longer exempt: needle bytes in any scanned family fail the purge closed; see #336 for the historical verify-glob trade-off;
  • records the ids in the purged_id deny-list so ingest-jsonl will not re-insert them from a peer export (child records referencing a purged id are skipped or blanked, never fatal);
  • if a post-commit compaction, ledger scrub, or snapshot scrub fails, a later purge --id for the deny-listed id resumes the cleanup phases. The retry derives its scrub needles from each surviving snapshot row, so it does not require the original plaintext as a command-line argument. A scrub retry never re-verifies the live store (the purged rows are absent, so no live needles can be derived) and says so instead of claiming a clean verify;
  • --scrub-backups --out-dir DIR applies the same removal inside every store-*.sqlite and prerestore-*.sqlite snapshot in DIR, rewriting files in place (never deleted, truncated, or renamed) and re-verifying each with integrity_check.

Vector erasure is fail-closed: when a memory_vec table exists, purge must load sqlite-vec and verify that no vector rows remain for the deleted ids. If the extension cannot be loaded, the purge refuses rather than claiming the vectors were erased. --scrub-backups --out-dir DIR also requires DIR to be an existing local directory.

Locking: the full restore posture (maintenance → schema → backup → consolidate locks, plus a live-writer refusal), so nothing can write between the deletes and the byte verification.

Exit codes: 0 purged and verified clean; 2 bad usage; 3 unknown id (named); 4 refused (maintenance/schema/backup/consolidate lock or live writer); 5 residue remains in the store or ledgers — OR transient delivery-ledger lock contention (re-run; the message names which); 6 compaction/scrub step failed.

Post-commit cleanup failures leave the deny-list entry in place by design; re-run purge for that id after fixing the named surface. import-store.py --force and restore from a pre-purge snapshot are explicit whole-store replacements — they supersede both the removal and the deny-list, so re-run purge after either (a restore's prerestore-* safety copy of the pre-restore store preserves the deny-list rows recorded between snapshot and restore, so the ids to re-purge are recoverable by SQL from that copy). Purge is also honest about a structural carve-out in the byte verification: memory content that is a substring of SQLite's own schema DDL text (contrived single-word contents like namespace) cannot be distinguished from schema bytes, so no needle is scanned for it. Surviving rows that still quote purged-content tokens (e.g. a mid-chain update successor, kept by design) are listed as WARNINGs in the summary. Exit 5 has two shapes: (a) residue detected after deletion — the deny-list rows are already recorded while content may remain: clean the named surface, then delete those rows from purged_id (or re-purge the surviving carrier) so peer imports stay denied only while content exists; (b) the delivery-state skew refusal (issue #322) — <data>/ops held delivery-state files from a family this client does not support, and the purge refused BEFORE touching the store: nothing was deleted, no purged_id rows exist, and the named files must be handled per the refusal's recovery text (upgrade this client, or remove the listed files after quiescing writers). Exit 2 also covers an invalid ZMEM_TEST_LEDGER_FAMILIES value (test seam misconfiguration). Detection scope note: the read-time-style scan here covers the row's content, source_ref and tags; a credential stored ONLY as an entity name is not detected by purge's needle scan or by the passive lane's withhold (issue #180-adjacent residual). Keeper rewrite note: a consolidation keeper whose own base text shares words or short phrases with the absorbed text keeps those shared fragments after the strip — they are indistinguishable from the keeper's own vocabulary; the full purged text is always removed or the purge refuses, and the byte-verify's novel-token needles cover genuinely novel residue. Peer imports skip evidence rows that were junctioned to a purged memory (including their episode junctions), so purged excerpts stay gone.

sweep — prune stale per-session cooldown sentinels

<interpreter> <store.py> sweep [--marker-dir DIR] [--max-age-days 7] [--dry-run]

Removes the .capture-prompted-<session> / .convention-prompted-<session> cooldown markers the capture/convention hooks leave in the data dirs (issue #23). A marker is only meaningful for the session named in its filename, so anything older than --max-age-days (default $ZMEM_SENTINEL_SWEEP_DAYS, else 7) is garbage. Sweeps the union of every dir the two hooks can write into (their resolution chains differ), never touching anything that is not a sentinel-prefixed file. Idempotent and fail-open. The SessionStart hook fires it detached each session so the markers stay bounded; --dry-run counts without deleting.

corrections — mine user corrections from a transcript (read-only)

<interpreter> <store.py> corrections --transcript <path> [--json]

Scans a Claude Code transcript JSONL for user corrections. (User rejections are a separate signal — reported by store.py failures and surfaced by the Stop / SubagentStop reflection hooks.) Prints {"count": N, "items": [{message, type, patterns, confidence, sentiment, decay_days}]}. Read-only — this command never opens or writes the store; candidates are reviewed by an agent/human before any add (signal honesty). Corrections are detected by the ported correction-pattern library (skills/memory/scripts/corrections.py, from the MIT claude-reflect project). This parses Claude Code transcript format only; other hosts' histories are out of scope. In ZMEM_CAPTURE_MODE=auto likely-secret text is redacted; in manual matching items are annotated "secret_warning": true but kept verbatim for review. (--json is accepted for parity with the issue's syntax; output is always JSON.)

signals — recognized runner signals for a session (read-only)

<interpreter> <store.py> signals [--session SESSION] [--transcript PATH] [--db PATH]

Reports the session's tracked runner signals as {"signals": {"test": "pass", "lint": "fail", ...}} — the LAST status of each recognized runner family in the transcript, classified by capture_quality.infer_signal (test/compile/lint; everything else, including the db substrate where tool_usage carries no command text, is untracked). A user-REJECTED runner call records no signal (the run never executed — same rejection split as failures), and an error-shaped toolUseResult sibling counts as fail even when the block's is_error flag is false. This is the read command behind the Stop hook's signal-change gate (issue #258). Read-only and store-independent like failures — dispatched before connect(). Exit-code contract: checked results (including a missing transcript path, which falls to the db leg) exit 0; a genuine substrate error (transcript present but unreadable) prints {"signals": {}, "error": ...} and exits 2, so the hook can suppress without touching its persisted state.

source-exists / ops-append — capture adapter bridges

<interpreter> <store.py> source-exists --namespace NS --source-ref REF --json
<interpreter> <store.py> ops-append --namespace NS --session-id SESSION --tool TOOL --operation OP --json

source-exists prints {"exists":false} with exit 0 for a missing store and otherwise opens the store read-only without creation or migration. ops-append prints {"ok":true} after appending the bounded operation-ring record; a no-token operation is the declared successful no-op (issue #163). --namespace is REQUIRED (old invocations without it exit 2 with no stdout payload). Invalid arguments or unavailable state print {"ok":false} and return nonzero; adapters translate that through their documented fail-open envelopes. With memory.provider=zmem the plugin's native post_tool_call / pre_llm_call / pre_verify callbacks run BESIDE any installed compatibility shell hooks (both paths are operational; suppression is operator-managed). Neither command authorizes a hook to import store internals directly.

ZMEM_CONVENTION_INTERVAL remains the legacy cadence for the Hermes convention-compatibility hook only. It does not control the commit-only zmem-convention-capture.sh prompt.

queue-list / queue-clear / queue-add — review live-captured corrections (read-only / clear / author a note)

<interpreter> <store.py> queue-list --namespace NS [--json]
<interpreter> <store.py> queue-clear --namespace NS [--id ID ...] [--all] [--drop-stale]
<interpreter> <store.py> queue-add --namespace NS --message TEXT --type lesson \
  [--patterns P] [--confidence 0.7] [--sentiment note] [--decay-days 90] [--json]

The capture-correction hook (UserPromptSubmit, Claude Code / ZCode / Codex) queues mid-session user corrections ("no, use X", "remember: ...") into a namespace-scoped sidecar file (<store-data-dir>/queue/<ns>.json, default ~/.zmem/queue/<ns>.json; follows the store's ZMEM_STORE/ZMEM_DATA override chain, not a fixed path) — these recall/capture hooks never add rows and write nothing else to the store themselves (the one sanctioned exception is the DETACHED SessionStart session-cadence maintenance task, which runs organize/backup/sweep on its own lease). queue-list shows the pending candidates for review (emit them into the store via add --signal user only if they clear the closeout rubric); queue-clear removes processed ids (--id), empties the queue (--all), or prunes stale low-confidence items (--drop-stale) — exactly one selector is required (a flag-less invocation is rejected, not a silent full wipe). All three are store-independent (they never open or write the store), so they work even if the store is locked or missing.

queue-add (issue #260) is the write-side sibling: a closeout session can jot a cheap, low-ceremony note for a FUTURE closeout to review — cheap now, fully processed later — instead of running the full add ceremony (embedding, dedup/supersede matching) on a note whose whole point is to be reviewed first. It appends through the same capture queue machinery the hook uses (same atomic write, same MAX_QUEUE_SIZE oldest-drop cap) and stamps source: "closeout-note" so queue-list consumers can tell a closeout-authored note from a live-captured (source: "live-capture") or mined (source: "history-mine") candidate. Secret redaction covers the whole note: the message follows the capture policy (ZMEM_CAPTURE_MODE — auto stores the redacted form, manual keeps the original with secret_warning), and the free-text --patterns/--sentiment metadata is always stored redacted (flagging secret_warning when redaction fired). Input guards mirror the promotion path: --message is bounded by the store's MAX_CONTENT_CHARS, --confidence must be a finite value in [0, 1], and --decay-days must be at least 1 so every item stays reachable by queue-clear --drop-stale. Because --message rides the process command line, route secret-bearing content through the capture-correction hook (stdin transport) rather than pasting tokens into a note. A queue-add item is reviewed under the SAME closeout Step 0.5 rubric as any other queue item — there is no fast track; --type accepts exactly the add command's type enum, so an accepted note needs no type conversion when it is promoted.

retain / retain-adapt — evidence-gated automatic retention (issue #177, default off)

<interpreter> <store.py> retain [--input PATH|-] --namespace NS --session ID \
  --host claude|codex|zcode|hermes-provider|hermes-compat [--dry-run]
<interpreter> <store.py> retain-adapt --input PATH|- --namespace NS \
  --session ID --host claude|codex|zcode|hermes-provider|hermes-compat

The automatic-retention surface (Workstream K PR 6). retain classifies normalized JSONL evidence rows through the deterministic extractor (storelib/retain.py::extract_candidates) into REVIEW-QUEUE CANDIDATES — never memory rows: explicit remember: rows (class explicit), decisions carrying a rationale marker (because / so that / therefore / rationale: / reason:, class decision), Actually,-style corrections (class correction, priced at the classifier's pattern-table confidence — the live-capture short-message boost deliberately does not apply), and twice-repeated runner errors (class outcome via aggregate_errors). Every candidate keeps the full legacy review shape plus class, evidence_ids, signal=none, a stable 32-hex SHA-256 id, and a retain_hash; writes go through the same capped, atomic queue sidecar as manual capture (a failed write keeps prior writes and reports [zmem] retain: queue write failed; written=<k-1>; total=<N>).

The gate (default OFF): retention runs only when <store_dir>/config.json (the file sits beside the store file; the queue itself lives in a queue/ subdirectory beside it — read by the store-side storelib/config.py reader) contains a literal boolean {"auto_retain": true}, or ZMEM_AUTO_RETAIN=1 forces it on. Exact ZMEM_AUTO_RETAIN=0 forces off; any other non-empty value prints [zmem] retain: invalid ZMEM_AUTO_RETAIN with {"count":0,"ok":false} and exits 0. A non-boolean auto_retain in config.json is REFUSED and leaves the gate CLOSED — a config meant to disable retention cannot enable it. Exact ZMEM_CAPTURE=0 is byte-passive — no input read, no queue file, no store file. The Hermes provider's auto_retain config key (issue #161) is a SEPARATE provider-home setting that sync_turn consults BEFORE spawning a child; the store subprocess never reads provider home. Both commands dispatch before the store opens: a disabled run never creates or migrates the store, and the stage driver checks the gate BEFORE it reads the transcript, so a default install never parses a transcript. The Stop/SessionEnd seams still start one interpreter to evaluate the gate before declining, because the shell cannot read the store-side config without one.

Both retention verbs answer with {"count":N,"ok":bool} on stdout. ok is false on a partial write, a classify failure or an invalid override, so a caller can tell a successful no-op from a failure even though every hook path still exits 0.

retain-adapt normalizes a raw host payload (Claude transcript records — isMeta may be absent and message.content may be block-form —, Codex id/timestamp/role/content records, ZCode event_id/ts/role/text records, or a Hermes payload) into evidence rows stamped with the CLI provenance triple. Timestamps are accepted with or without millisecond precision and canonicalized to ...THH:MM:SSZ.

Host wiring — which lanes share the subprocess seam:

  • Claude/Codex/ZCode Stop and Claude SessionEnd run hooks/lib/zmem-retain-process.py (10s per-child deadline, exit 124 on timeout), which spawns store.py retain-adapt then store.py retain.
  • The Hermes provider (sync_turn, on_pre_compress checkpoint API v2) calls store.py retain DIRECTLY with its own duplicate deadline copy; it does not import the shared seam.
  • The Hermes compatibility pre_llm_call hook uses subprocess.run with its own ZMEM_MCP_TIMEOUT clamp (8s default), not the shared seam.
  • The shared SessionEnd script does NOT run on the Codex SessionEnd lane — the Codex fast path returns before that script spawns — so the retention stage there is currently unreachable for Codex.

Hooks and providers consume child JSON only — no storelib import, no SQLite, no direct queue access. Every failure path exits 0 so a retention problem can never break a host hook. Diagnostics are appended to a zmem-retain.log sidecar in the store data directory and echoed to stderr where the host preserves it — the Stop hook suppresses its own stderr, so the sidecar is the channel that always survives.

Retention is idempotent: candidates are keyed by a stable id, already-present ids are skipped, and the batch runs under a per-namespace lock, so repeated Stop/SessionEnd fires over one transcript add nothing on the second run. The remember: classifier is unanchored, so material that reads as quoted or machine-authored (a pasted answer, a project instruction file, a diff) is skipped rather than promoted as the user's own standing fact. The decision classification needs a kind field no wired adapter emits, so it is currently reachable only by callers that set kind explicitly.

convention-drift — surface conventions a queued correction contradicts (read-only)

<interpreter> <store.py> convention-drift --namespace NS [--json]

Read-only drift scan (issue #261): loads the namespace's queued correction items (the same sidecar queue-list reads — every item, regardless of its stale flag) and the namespace's live rows, keeps type == "convention" (filtered in Python — list has no --type flag), and reports each convention row some queued item's message CONTRADICTS: the write-time dedupe guard's negation-polarity signal (polarity flip) plus at least one shared content stem (the stem term is specific to this scan; token-disjoint notes never flag). --json emits {"candidates": [{"id": ...}, ...]}; plain mode prints one - <id> :: <preview> line per candidate, or (no convention-drift candidates) when there are none; both modes exit 0. On an existing store it writes nothing — no invalidate, update, supersede, or telemetry, and the store file stays byte-identical (a missing store is created by the ordinary open path, exactly like list) — surfaced ids are update candidates for the reviewer's deliberate update/invalidate at closeout (false positives are acceptable; a missed real contradiction is the failure mode it minimizes). The queue loader fails open: a missing or corrupt queue file reads as an empty result. Unlike the queue family it opens the store (it reads live rows), so it dispatches after connect like list; it is exempt from the automatic near-miss rekey (a pure-read surface must not trigger the rekey's writes). Works without an embedding model — the predicate is lexical only.

export-pack — render a Tier 1 markdown memory pack

<interpreter> <store.py> export-pack --namespace NS [--out FILE] [--project-limit 50] \
  [--global-limit 15] [--min-confidence 0.6] [--max-bytes 32768]

Renders live memories from --namespace and user:global (confidence DESC, retrieval_count + surfaced_count DESC, ingestion_ts DESC) as a hand-off markdown pack, e.g. for a cloud/remote session with no store access. --max-bytes is a UTF-8 byte budget over the whole rendered pack (structural framing counts toward the cap): a bullet that would push past it is omitted whole (never truncated), smaller rows behind it are still emitted, and only framing appended after the walk (an empty section's heading and the trailing omitted-count note) can exceed the budget. Refuses (exit 2) if both sections are empty.

export-jsonl — export Tier 3 sync JSONL

<interpreter> <store.py> export-jsonl [--out FILE] [--namespace NS] [--include-superseded]

Writes one memory row per line (no embeddings) for box-to-box sync via ingest-jsonl. Default: all namespaces, live rows only; --namespace scopes to one namespace, --include-superseded also exports tombstoned rows. Entity links are deliberately NOT carried in the JSONL (they are store-local derived data, like embeddings and content_norm): the receiving store rebuilds them by re-running the deterministic extractor on ingest — see below.

Governed training capture and export (issue #135)

Automatic Claude, Codex, ZCode, and Hermes hooks may create a partial training capture. Capture governance defaults to deny and a denied or missing policy stores metadata only; local capture consent permits redacted partial storage but does not authorize export. A capture is exportable only after the actual injection result has been snapshotted, a trusted workflow has acknowledged the delivery, and a verifier has completed the capture with a supported outcome, evidence id, scoped memory ids, current export governance, and any required reviewer correction fields. Hook observations, Stop, tool success/failure, and displayed text never complete a capture.

Completion evidence must match the outcome: test, compile, and lint require test_result evidence; user_acceptance requires turn evidence; reviewer_acceptance accepts turn or correction evidence. correction_closeout requires reviewer_acceptance with correction evidence.

The explicit trusted local adapters accept reviewed JSON input and keep the state transitions separate:

<interpreter> <store.py> capture-training-delivery --input delivery.json
<interpreter> <store.py> capture-training-acknowledge --input acknowledgement.json
<interpreter> <store.py> capture-training-completion --input completion.json

Every reviewer-acceptance outcome requires a separate local review transition, including turn evidence. Reviewer-acceptance corrections additionally require correction evidence:

ZMEM_TRAINING_CALLER_ID=operator-a \
ZMEM_TRAINING_REVIEWER_IDS=operator-b \
<interpreter> <store.py> capture-training-review --input review.json

ZMEM_TRAINING_CALLER_ID is the local caller identity and ZMEM_TRAINING_REVIEWER_IDS is the comma-separated reviewer allow-list. The reviewer must differ from the verifier. These values protect the local workflow against accidental self-review; they do not provide an external reviewer service or a boundary against a hostile local process. Capture ids, session keys, and optional host task ids are correlation metadata only.

The delivery response contains the local capture_id and immutable delivery_snapshot_id; later adapters resolve the capture through the snapshot id. host_task_id is correlation metadata only when the host supplies it. Replayed compatible inputs are idempotent, while conflicting snapshots or completion data are refused. The current redaction masks credential-like, email, and filesystem-path classes; it is not comprehensive PII anonymization. Reviewer trust on this local completion path means a local operator's attestation; no external reviewer registry is implied.

Finalized and revoked capture records, their local delivery/completion/ observation rows, remain for 30 days. The detached session-cadence task attempts the purge before its backup step; a successful purge keeps expired records out of new backups. Doctor reports an intentional expired-retention warning with output status warn and stays read-only; an operator can run the explicit cleanup instead:

<interpreter> <store.py> purge-training-captures --confirm

Before enabling export, install PyArrow in the store-host interpreter:

python -m pip install --disable-pip-version-check \
  -r skills/memory/scripts/requirements-training.txt

The Hermes server environment can install its packaged MCP requirements without installing the optional exporter:

python -m pip install --disable-pip-version-check \
  -r hermes-plugin/server/requirements.txt

Install requirements-training.txt separately in the interpreter that runs export-training; the training extra pins PyArrow 25.0.1 for deterministic Parquet bytes.

The read-only doctor reports the exact requirements-file command when PyArrow is unavailable. Reload the host after updating a plugin cache and run doctor before exporting.

export-training — write reviewed SFT and preference views

<interpreter> <store.py> export-training DIR --snapshot-id ID \
  --reviewer-confirmed [--namespace NS] [--quarantine-raw]

--snapshot-id selects the immutable source export snapshot and --reviewer-confirmed is a required export authorization. Both are checked before SQLite opens or output is created. DIR is the training output directory itself and contains the fixed sft-000.parquet, preferences-000.parquet, manifest.json, and deletion-map.json artifacts directly. deletion-map.json is derived deduplication and exclusion metadata; it does not delete canonical SQLite rows or prior operator-owned output folders. Empty eligible selections still produce schema-only Parquet files. A missing PyArrow dependency or unavailable local model for a nonempty semantic export fails closed before publish.

--quarantine-raw writes a separate bounded, redacted derived dump under DIR/quarantine/ with DIR/quarantine-manifest.json. It uses the same verified, acknowledged completion gate as the Parquet views; partial, denied, revoked, incomplete, or capture-quarantined records never reach any derived artifact. Training vectors are in-memory only and never sync or back up. Output is published from a validated same-volume staging directory, with one advisory per-output lock and the final manifest written last; consumers must reject output without a valid manifest. Parquet, quarantine, deletion-map, and staging files are operator-owned disposable artifacts outside canonical-row retention; purging SQLite capture rows does not delete prior published output folders. Doctor reports abandoned staging directories but is read-only by default. After confirming no export is running, an operator can explicitly remove only stale direct, non-symlink .training-staging-* siblings of DIR:

python <doctor.py> --project . --training-output ./training \
  --cleanup-training-staging --confirm-no-training-export

The cleanup skips candidates newer than 24 hours and rechecks mtimes before removal. For ./training, a candidate is ./.training-staging-*. The remedy for an expired-retention warning is <interpreter> <store.py> purge-training-captures --confirm, followed by another doctor run. The exporter bounds work at 10,000 source captures by default. Set ZMEM_TRAINING_MAX_ROWS to a lower positive limit when needed; exceeding the limit fails before publication and leaves no new manifest. The exporter has no cross-process lock that doctor could use to prove an export is inactive, so the confirmation flag is an operator assertion. Revoked captures are reported as an informational count and remain ineligible for export.

v13 (issue #65, 10.7): every row carries a kind discriminator ("memory"); episodes round-trip as additional "episode" and "episode_memory" records when any exist (memberships are emitted only when both endpoints are in the exported set, so the file is self-consistent). ingest-jsonl treats a missing kind as "memory" (legacy files ingest unchanged) and applies records in dependency order (memory → episode → membership) regardless of file order; re-ingesting the same file is an exact no-op.

ingest-jsonl — import Tier 3 sync JSONL

<interpreter> <store.py> ingest-jsonl --in FILE [--source-ref REF] [--allow-tombstones] [--capture-mode auto|reviewed|manual] [--trust authoritative|untrusted-peer] [--allow-namespace NS ...] [--attribute-host HOST]

Imports a JSONL file written by export-jsonl. Every row is validated before touching the store, and a bad row is counted and reported by line number rather than aborting the file. For files whose sender is NOT authoritative over this store, pass --trust untrusted-peer with one or more --allow-namespace values (issue #282): trust claims in the rows are clamped (taint, signal grounding kept only as claimed_signal: tags, confidence/trust_score ceiling 0.6), host:/agent: attribution and source provenance are stamped from the caller (--attribute-host, peer:<host>), every record kind is gated against the allow-list (evidence records refused outright; tombstone authority refused), and the peer-mode summary line carries namespace_refused=, authorship_refused= and trust_clamped= counts (authorship_refused counts side records aimed at a row this import did not author — a peer is never authoritative over a row it did not land, though restating a lifetime the store already holds is a no-op; absent trust_score resolves 1.0, so trust_clamped fires on most peer rows by design — it reports "the policy altered this row"). --source-ref overrides source_ref on every row inserted this run (default: keep each row's own incoming source_ref). --allow-tombstones lets an incoming superseded row tombstone a live local row with the same id — off by default; use it only when the file is an export of a store you trust as authoritative for those ids.

v11 (issue #61) notes: rows carry trust_score (restored verbatim, default 1.0 for pre-v11 files; a PRESENT but non-numeric/non-finite value makes the row malformed — refused, counted, not stored) and outgoing links (validated fail-closed, applied after every row lands so an edge may reference a later row; links_added/links_skipped in the summary). Ingest deliberately applies NO trust deltas and NO auto link generation — it is deterministic state restore, so re-ingesting the same file is an exact no-op (the +0.05 corroboration is an organic add/update-only signal). The dedup path DOES apply the write-time polarity guard: a remote row that contradicts its dedup hit inserts as its own live row instead of merging (contradictions never merge, on any surface).

--capture-mode routes every inserted row through the same capture policy as add (a sync file is remote-authored data that can otherwise plant a poisoned memory surfacing verbatim into model context, or store secret-like text). The default resolves like add (ZMEM_CAPTURE_MODE env or manual): verbatim content with prompt-injection-risk tagging (a row matching an injection pattern is tagged prompt-injection-risk so it can be reviewed, in ALL modes; issue #82 widened the pattern set with four high-precision instruction-to-the-model shapes — role hijack ("you are now the ..."), concealment ("do not mention this"), instruction-override paraphrases ("ignore all previous rules/guidelines"), and store-mutation imperatives ("update your knowledge base / the memory store") — precision-first, so ordinary coding lessons like "update the lockfile" never tag; the read path re-classifies EVERY row at emit time regardless of store age). Use --capture-mode auto when ingesting an untrusted/remote sync file: it additionally redacts secret-like VALUES in content/tags (value-span replacement, tagged auto-redacted) and QUARANTINES rows whose source_ref looks like a secret (issue #180: counted as quarantined in the summary — after capture_refused — with the original row appended to <store-dir>/quarantine/<UTC-date>.jsonl, NOT stored; the run still exits 0). reviewed RAISES on those refusals (counted as capture_refused, never quarantined — plain ingest-jsonl still exits 0 with the count since the file keeps going; exit 2 applies via --strict or the add/update CLI paths); manual keeps the original text with an advisory notice. A quarantine write failure counts quarantine_failed, stores nothing for that row, and exits 1.

Issue #71 F: in auto mode, source_refs with a structured provenance scheme — db:, hindsight:, session:, zmem-queue:, and file: with a RELATIVE path (or a well-known stem like codex-MEMORY.md) — skip the generic hash-shape refusal (a 32-hex db row id is legitimate provenance). Credential shapes (key=value pairs, PEM headers, gh*_/AKIA tokens) still refuse on allowlisted refs, file: absolute remainders still refuse, and content/tags scanning is unchanged. The write result carries a structured source_ref_allowlisted warning so the relaxation is visible.

Capture policy and quarantine (issue #180)

apply_capture_policy (storelib.write) is THE single capture-policy entry point. Every importer routes through it: CLI add/update (via add_memory/update_memory), ingest-jsonl and ingest-jsonl --strict, import-store.py, rescan-secrets (the store-wide rescan/remediation scan), scripts/ingest_harvest.py, MCP add, Hermes zmem_add (the last three via the store.py subprocess boundary — no adapter re-implements pattern matching). The pattern registry lives in redaction.py as the only source; redact_text and the read-time credential withhold (recall's classifier) iterate the same lists.

Detectors and span semantics — value-span patterns replace ONLY the secret value and keep the surrounding syntax; everything else whole-match-replaces:

shape matches does NOT match
sshpass -p <value> (any whitespace, incl. tabs) value replaced dangling sshpass -p
sudo -S whole-row refusal (unredactable_secret) sudo -s (flag case matters)
--password=<value> (attached) value replaced --password <value> spaced; bare --password
ssh/mysql/psql -p<value> (attached) value replaced -P<token>; other commands' -p; spaced -p <value>
scheme://user:password@host password replaced URLs without userinfo
key=value (password=, api_key=, … compound names like DB_PASSWORD= included) value replaced (8+ chars) bare keywords with no [:=]
PEM block (BEGIN…END) whole block replaced —
token prefixes (gh*_, sk-, AKIA, JWT, …) whole token replaced —

Known accepted over-redaction (issue-mandated matrix, fail-safe direction): for ssh and psql the lowercase -p is actually the PORT flag, so ssh -p2222 user@host IS redacted as if the port were a secret; the matrix positives (ssh -ppw180F) require it. Do not "fix" this without changing the issue contract.

Command names and URL syntax match case-insensitively; option letters are case-SENSITIVE (-S vs -s, -p vs -P). [REDACTED_SECRET] is idempotent: a value already equal to the marker is detected but never rewritten, so re-running auto mode on a redacted row is a fixed point for content, source_ref, and tags (warnings are identical for inputs whose detection set is stable across a re-scan — every issue fixture row; a placeholder value long enough to trip a second pattern re-counts, and an output matching no pattern emits no redaction warning). Known safe-direction caveat: a URL of the shape scheme://host:port/a@b over-redacts the port/a span, and an attached ssh -p<port> is treated as a password (both are the price of the issue's exact matrix) — and because the read-time credential classifier consumes the same shapes, such a stored row is ALSO withheld from passive injection (issue #256's read-time defense), not merely masked at write time.

Quarantine contract: an auto-mode refusal with reason source_ref_secret_like, source_ref_unsafe_path, or unredactable_secret is quarantineable. The importer that caught it appends the ORIGINAL row to <store-data-dir>/quarantine/<UTC-date>.jsonl (the directory of the resolved STORE — every importer writer resolves the same sink, never a divergent ZMEM_DATA; directory 0o700, file 0o600) — one compact JSON object per line, keys in order quarantined_at, reason, source_ref, row — via quarantine_import_row (write failures raise OSError: the row is rolled back, counted quarantine_failed, and the run exits nonzero). The store's writer lease does NOT cover the quarantine file; writers serialize on an exclusive advisory file lock (fcntl.flock on POSIX, msvcrt.locking on Windows) held across each append — bare O_APPEND and the buffered text-mode open("a") idiom both measurably dropped or tore records under concurrent Win32 writers (review-round probe). Review quarantine files with restricted filesystem access.

Importer results and counters:

  • store.py add --capture-mode auto --json prints {"id": null, "result": "quarantined", "warnings": [{"type": "quarantined", "reason": "<reason>"}]} and exits 0 on a successful quarantine; reviewed-mode and namespace refusals exit 2 with [zmem] capture policy refused: <reason> (namespace refusals keep their prose guidance; manual never raises on capture grounds — namespace validation can refuse in any mode).
  • ingest-jsonl summary carries quarantined= and quarantine_failed= after capture_refused=; quarantine_failed>0 exits 1. ingest-jsonl --strict treats a quarantined row as a rejection (all-or-nothing stays fail-closed).
  • import-store.py stages a sanitized copy (source read-only mode=ro+query_only=1; every memory row through the policy in id order; refused rows deleted with their related rows — belief heads sourced only by a refused row are deleted with it, shared heads rebuild from a surviving source — and quarantined; redacted rows keep ids with FTS maintained by trigger and stale memory_vec embeddings dropped), verifies PRAGMA integrity_check and staged_count = source_count - quarantined_count, flushes its refusal records (one locked, all-or-nothing batch; records the ledger already holds — compared without the per-call timestamp — are skipped, so re-imports never duplicate them) BEFORE the destination is touched, and only then atomically replaces the destination (os.replace); the source's sha256/size/row-count are proven unchanged before and after. Its report carries added, redacted, quarantined, quarantine_failed.
  • scripts/ingest_harvest.py subprocesses store.py add --capture-mode auto --json per row (a record may carry its own source_ref; the CLI --source-ref is the fallback), classifies each child result as stored/deduped/quarantined/failed, and reports all four counts; any failed exits 1.
  • MCP add and Hermes zmem_add forward the store envelope verbatim — result: "quarantined", id: null, one structured warning — and never expose the local quarantine path, the source row, the credential value, or any filesystem detail.

Operator runbook (a stored credential is captured — the field trigger was row b513b21c): invalidate the row with reason credential leak; rotate the affected SSH password AND every credential that shared it; search backups, exports, quarantine files, and logs for copies; verify the row is tombstoned and absent from live recall; never print the credential; review future whole-row quarantine records with restricted filesystem access. Invalidate tombstones rather than purges (durable purge is #255); read-time credential withhold on the passive lane is #256. This runbook is operator-only and never targets a test store.

Every ingested row ALSO runs the deterministic entity extractor (the same one add uses), so entity identity is rebuilt locally instead of carried (v10, issue #60): two stores ingesting the same rows derive the same entities, keyed by normalized alias, with no cross-store id collisions.

Evidence (schema v14, issues #169/#170)

Evidence is bounded observation data, not instructions for the model. The additive evidence, episode_evidence, and memory_evidence tables use the closed lane/moment/kind sets and second-precision UTC timestamps. evidence write validates the payload before insert; it redacts first — except the flagged delegation mode (issue #164), whose validated digest-only payload skips redaction — caps the final excerpt at 400 Unicode characters, hashes kind|ts|final_excerpt, and leaves the transaction commit to its caller. The strict JSONL importer verifies a row's hash against its exported excerpt verbatim first, so redaction-exempt rows round-trip. The CLI writer commits its own one-row transaction. When the zmem provider is active (issue #163), the plugin natively registers post_tool_call, pre_llm_call, and pre_verify. The registered post_tool_call sends a bounded private payload through the existing local store bridge and fails open on malformed input, unavailable host support, queue saturation, or writer failure; pre_llm_call and pre_verify complete the #163 provider surface (transport recall with strict envelope validation, and the once-per-session reflect-before-stop nudge). Since issue #164, on_delegation records each subagent delegation as one bounded, fail-open evidence write (task/result SHA-256 digests plus child/parent session ids). All of these fail open.

<interpreter> <store.py> evidence write < payload.json
<interpreter> <store.py> evidence write --kind delegation --session-id PARENT --lane hermes-provider --moment subagent --ref-path delegation:PARENT:CHILD < payload.json  # issue #164; answers {"id": ..., "ok": true}
<interpreter> <store.py> evidence list --namespace NS [--session-id SID] [--lane LANE] [--moment MOMENT] [--json]
<interpreter> <store.py> evidence show --namespace NS --id UUID [--json]
<interpreter> <store.py> evidence for MEMORY_ID --json
<interpreter> <store.py> evidence for --memory-id MEMORY_ID --json  # legacy alias
<interpreter> <store.py> evidence scoped-show --namespace NS --id UUID [--json]
<interpreter> <store.py> evidence associations --id UUID --json  # hidden operator helper

The required --namespace argument on legacy CLI list/show is a compatibility/context marker and is ignored: evidence rows have no namespace column, so this unscoped list/show path has no namespace filter or authorization boundary. evidence list filters only by --session-id, --lane, and --moment; evidence show selects by --id. The hidden unscoped evidence associations helper is used by the operator MCP bridge to expose association rows for an evidence ID. evidence scoped-show is the store-owned scoped lookup: it requires both --namespace and --id, returns the row only when an association reaches that namespace, and emits namespace_not_allowed for a missing, unassociated, or foreign ID.

The Hermes MCP evidence_for(memory_id) tool derives the memory namespace. For a scoped token, a missing or foreign memory returns the same structured namespace_not_allowed response (namespace: null, with detail that the memory is not associated with an allowed namespace); an allowed memory returns its hash-free evidence rows. The MCP evidence_show(id, namespace) tool uses the scoped lookup for scoped tokens, so missing, unassociated, and foreign evidence IDs all return the same structured namespace_not_allowed response for the requested namespace. Unscoped operators retain the exact missing-ID responses (memory id not found and evidence id not found) and legacy association behavior.

The session-cadence maintenance transaction runs evidence retention after its normal organize/backup work. ZMEM_EVIDENCE_DAYS defaults to 30 and removes rows strictly older than the supplied cadence clock minus that many days; ZMEM_EVIDENCE_CAP defaults to 50,000 and keeps the newest rows by stable ts,id order. Associations are deleted with their evidence and stale associations are swept. Invalid retention settings disable the evidence sweep rather than selecting a surprising limit.

add and update accept --evidence ID[,ID...] with at most 256 evidence IDs per memory write. Capture-policy advisories are emitted before the transaction opens; the writer then validates each supplied ID before memory mutation and attaches the ids to the resulting memory before commit; existing pairs are ignored. Explicit JSON recall, recent, and recall --explain rows include sorted evidence_ids. Passive injection and human fenced output do not gain that field. evidence for MEMORY_ID --json (or the legacy evidence for --memory-id MEMORY_ID --json form) returns the memory namespace with its hash-free evidence rows; if both forms are supplied, conflicting IDs exit 2. Strict import prevalidates all memory_evidence endpoints and reports a missing endpoint using its physical source line before association insertion.

export-jsonl reads one consistent snapshot. An unscoped export includes all evidence rows and only associations whose parent rows are in that export. A namespace-scoped export includes only evidence reachable through the memory/episode rows selected for that namespace and omits unassociated evidence, since it cannot be attributed to a namespace. Records with the new top-level table discriminator use one staged read, duplicate-key/hash/reference validation, and one outer transaction. Use the explicit all-or-nothing form with bounded staging for evidence transfers:

<interpreter> <store.py> ingest-jsonl --in evidence.jsonl --strict

The automatic discriminator path also chooses strict staging when it can parse a table record. Legacy memory-only JSONL retains its best-effort per-row behavior; a wholly malformed source cannot be classified automatically. Strict staging parses and applies the same captured bytes, never reopens a mutable source between validation and mutation.

Deterministic passive-query rewrite (issue #183)

The 0.43 passive user_prompt path may append bounded local context before calling the existing selector. A prompt is ambiguous only when it has fewer than the positive configured minimum of case-folded content terms and no exact anchor. The default minimum is 4 and the positive override is ZMEM_AMBIG_MIN_TERMS. Slash/backslash/dot/underscore, namespace, flag, Error, and Exception tokens are exact anchors and bypass the rewrite. Context is deduplicated in source order from the first 12 safe operation tokens and the newest three safe edit basenames; it is capped at 150 context characters and the total query at 500 characters. Host consumers fail open silently on missing/invalid sessions or evidence, read/timeout failures, and malformed output. The standalone query-rewrite CLI preserves the original query with rewrite=0 and emits one sanitized warning when its store or context is unavailable. ZMEM_QUERY_CONTEXT=0 is an exact kill switch at each host/store boundary. Explicit search and non-user_prompt moments are unchanged, and this bounded capability is not a claim to implement all of issue #163.

Read-only replay evaluator (issue #155/#183)

Replay uses explicitly supplied offline inputs (the CI invocation uses the committed fixture) and is not a live efficacy test:

python scripts/eval_replay.py --store tests/fixtures/replay/store.sqlite \
  --log tests/fixtures/replay/decisions.log --days 30 \
  --compare-baseline eval/baseline-replay.json --json-out replay-report.json

The closed report is exactly two lanes (claude, hermes-provider) crossed with session_start, user_prompt, pretool, and precompact (eight rows). Repeatable --transcript PATH inputs are explicit regular bounded JSONL files (at most 16 files, 4 MiB per file, 32 MiB total, and 100,000 physical lines per file); there is no glob, rotation, failure-database, ring, or ambient operator-store discovery. The evaluator derives its clock from the latest valid decision-log timestamp, clears ambient routing/recall ZMEM_* values before store imports, and verifies input digests before publication. If no eligible later same-session observation exists, observation-dependent zero fields are empty-denominator compatibility values and stderr says metrics are unavailable; they are not a measured success, failure, or efficacy claim. See tests/fixtures/replay/README.md for bounds and reproducibility details.

Observational action matching (issue #156) adds a report-only --actions mode to the same evaluator:

python scripts/eval_replay.py --store tests/fixtures/replay/store.sqlite \
  --log tests/fixtures/replay/decisions.log --days 30 \
  --actions --actions-input tests/fixtures/replay/actions.json \
  --json-out actions-report.json

--actions-input is an explicit JSON file of recorded delivered_rows and evidence_rows (delivered rows carry id, session_id, timestamp, and operation; evidence rows carry session_id, timestamp, event_kind, and operation). For each delivered row the matcher selects the first later same-session evidence event inside the fixed ZMEM_MATCH_WINDOW_S = 1800 second window whose derive_ops_tokens normalization shares at least ZMEM_MATCH_MIN_OVERLAP = 2 tokens with the delivered trigger, then labels the row applied (success), violated (failure), or ignored (no qualifying event). Both constants are fixed with no environment overrides and are recorded in the report next to the sorted results. The boundary is strictly report-only: no action counter is written, no write transaction is opened, and the store SHA-256 is unchanged (durable counters belong to issue #124). The oracle pair tests/fixtures/replay/actions.json / actions-expected.json is written only by tests/fixtures/replay/generate_actions.py, which refuses to replace committed bytes when they drift; malformed rows or non-UTC timestamps exit 2 before any output file is replaced.

Counterfactual replay (issue #157) measures with/without-memory divergence:

python scripts/eval_counterfactual.py \
  --tasks tests/fixtures/counterfactual/tasks.json \
  --store tests/fixtures/counterfactual/store.sqlite \
  --model-id recorded-stub-v1 \
  --json-out counterfactual-report.json

(--allow-model-calls exists to opt a non-stub model id into real adapter resolution; without it the run takes the skip path described below.)

Each recorded session replays twice — ZMEM_INJECT=1 through the real passive injection lane (delivery ledger redirected to a scratch directory; read-only store URI; store SHA-256 verified before and after) and ZMEM_INJECT=0 with delivery disabled — through the pinned recorded-stub-v1 model path, which returns the task's recorded successful action exactly when the delivered fence contains that task's memory_row_id and the recorded no-memory action otherwise. The report carries repeated_failure_rate and first_action_agreement per condition (0.0/1.0 with memory, 1.0/0.0 without on the committed fixture) against the Draft 2020-12 contract eval/counterfactual-schema.json. The evaluator refuses the operator home store before opening anything. A real model id is only resolved with --allow-model-calls; without it the run prints exactly SKIPPED: model calls disabled and exits 0 with skipped: true (writing the skipped report when --json-out is given) — no adapter import, no download, no network. The committed task/store/expected fixture trio is written only by tests/fixtures/counterfactual/generate.py (developer-side; CI compares against the committed bytes and never regenerates).

The predeclared private real-corpus measurement of record for issue #155 is committed at eval/real-corpus-2026-09-19.json: a cohort frozen at declaration time (standalone store snapshot, ver=0.49.0 projection of the frozen decision log, and the cohort's one Claude-shaped transcript), declared with SHA-256 digests on the issue before the evaluation ran. The file carries aggregates and digests only; tests/test_eval_real_corpus_record.py pins its digests, schema, and privacy boundary. Future real-corpus replays must freeze the store snapshot and declare its SHA-256 before the measurement — replays over live, undeclared, or outcome-selected inputs are not baselines.

entity-list — inspect entity identity (v10, issue #60)

<interpreter> <store.py> entity-list [--kind person|project|tool|preference|host|service|model|repo|file|command|error_class|other] [--json]

Lists the entities the deterministic extractor minted: id, kind, canonical name, all normalized aliases, and how many memories link to each. --kind filters to one kind; --json emits [{id, kind, name, aliases, links}]. Entity identities are GLOBAL: this listing (and every other entity surface) spans ALL namespaces — the entity tables carry no namespace column, and entity_alias.alias_norm is globally unique — so an alias resolves to exactly one entity no matter which project mentioned it (review PRR-011, PR #454: uniqueness is per ALIAS; canonical names are not unique-keyed — resolve_entity refuses with exit 2 when a name matches several entities). This is the inspection surface for humans and doctor — use it to see what the entity lane actually matches. Issue #455 display masking: credential-shaped canonical names and aliases render as the whole-value marker [REDACTED_SECRET] (full contract under the classifier decision record below).

entity-merge — reconcile duplicate entities (v10, issue #60)

<interpreter> <store.py> entity-merge --from <entity-id> --to <entity-id> [--confirm]

Merges two entities: the --from entity's aliases and memory links move to --to, then --from is deleted. DRY RUN BY DEFAULT — without --confirm nothing is written and the plan (moves, alias/link collisions with the target, the deletion) is printed. With --confirm the write happens in one transaction; a target that already has an alias or link keeps its own (collisions are counted, never overwritten).

Refusals (exit 2, nothing written): unknown id on either side, --from == --to, or a kind mismatch — merging a person into a tool would silently re-classify history. No write path auto-merges entities as a side effect (the issue's "ZMem will not auto-merge people" rule): merges happen only through the two EXPLICIT operator commands — this one (person→person merges are allowed here, manual and --confirm-gated) and reconcile-entity-aliases --apply (whose dry-run previews every merge; a legacy other:person:Name row can merge into a person entity through it).

Typical flow: entity:Name tags or backticked mentions created two entities for the same thing (e.g. rg and ripgrep); find both ids with entity-list, dry-run the merge, then apply with --confirm. Afterwards the alias rg resolves to the surviving entity and recall's third list returns all its linked memories.

Issue #455 display masking: the dry-run plan masks credential-shaped from/to canonical names and the alias-collision join (full contract under the classifier decision record below).

entity-card — build or refresh one entity's card (issue #179)

<interpreter> <store.py> entity-card build ENTITY [--json]
<interpreter> <store.py> entity-card refresh ENTITY [--json]

Direct card operations on one entity (ENTITY = entity id, or an exact canonical name; zero/multiple name matches exit 2). build creates a missing qualifying card and REFUSES a live card (exit 2, no write); refresh returns created/updated/unchanged/no_card and tombstones a live card whose entity fell below ZMEM_CARD_MIN_MENTIONS. --json emits one object with the exact key order result, id, entity_id, source_ref, warnings. Full contract: "Entity cards (issue #179)" above.

reclassify-entities — rekind stored other entities (issue #176)

<interpreter> <store.py> reclassify-entities --dry-run|--apply [--json]

Runs the DETERMINISTIC classifier (pure string rules — no LLM, no shell, no network, no DNS, no filesystem; precedence host → service → model → repo → file → command → error_class → other over the case-folded name) over every entity row currently kinded other and proposes or applies new kinds. Exactly one of --dry-run/--apply is required (both or neither exits 2). Non-other rows are never touched; entity ids, aliases, and memory links are preserved; --apply writes in ONE transaction under the writer lease and rolls back completely on error. Like every entity surface, the scan spans ALL namespaces (there is no namespace filter — entity identities are global). --apply also refuses an unmigrated store up front (a schema stamp below the supported version exits 2 with the migration hint instead of a mid-apply sqlite error). --dry-run runs before the lease on a read-only connection, changes zero database bytes (no -wal/-shm sidecars), and prints the same canonical JSON as apply: {"scanned", "changed", "before_other_link_share", "after_other_link_share", "changes":[{id,name,old_kind,new_kind}]} with changes ordered by entity id; for --apply the scanned/before numbers describe the locked pre-update snapshot inside the write transaction.

Issue #455 display masking: change-row name fields render as the whole-value marker [REDACTED_SECRET] when credential-shaped (full contract under the classifier decision record below).

Writer lease and concurrent applies. The writer lease is advisory — it marks a live writer so restore observes it; it is NOT mutual exclusion. A second apply run after the first commits harmlessly reports changed=0 (idempotent re-run); an apply whose targets were re-kinded by a writer between its scan and its transaction fails closed with a ValueError and zero changes.

Backup before --apply. --apply re-kinds rows in one transaction but takes no snapshot of its own (unlike rekey-namespace). Take a verified backup first — <interpreter> <store.py> backup — and keep it until the new kinds are confirmed; undo is restore from that snapshot (rows re-kinded by mistake come back with their old kinds; there is no per-row undo).

At write time the same classifier assigns kinds to unqualified extracted values (plain tag tokens, untyped entity:Name, CamelCase identifiers); explicit typed tags, the project: namespace rule, and backtick→tool stay authoritative.

Per-kind entity-lane weights. Recall multiplies each memory's entity-lane RRF contribution by the max weight across the kinds of the entities the QUERY matched (ZMEM_ENTITY_KIND_WEIGHTS, one JSON object such as {"other":0.6,"host":1.0}; defaults 1.0 for every kind except other=0.6; an invalid value logs one warning and uses the complete defaults — recall never crashes on the knob). BM25, vector, and graph contributions are unchanged; recall --explain reports the effective weights under entity_kind_weights.

Operator snapshot block. tests/fixtures/entity/operator-snapshot.json records the motivating measurement and the fixture apply's fixed outcome:

{"as_of": "2026-09-10", "memory_entity_links": 26511, "other_links": 24826,
 "fixture": {"memory_entity_rows": 3, "before_other_link_share": 1.0,
             "after_other_link_share": 0.0, "changed": 4}}

The 24,826-of-26,511 other share (0.9364…, exactly 24826/26511) is why other defaults below 1.0; the fixture block is test-pinned (tests/test_entity_fixture_provenance.py cross-checks it against reclassify-expected.json) — the committed proof the reclassification moves the fixture store's other-link share from 1.0 to 0.0 in one apply. Regenerate the three fixed fixtures with python tests/fixtures/entity/make_reclassify_fixture.py (deterministic). The reviewed reclassify-expected.json regenerates OUTSIDE any test in two steps: build the seeded fixture store with python tests/fixtures/entity/make_reclassify_fixture.py --emit-store <dir>, then run the exact dry-run against it — ZMEM_STORE=<dir>/store.sqlite <interpreter> <store.py> reclassify-entities --dry-run --json > tests/fixtures/entity/reclassify-expected.json — the same seeding ReclassifyTest uses (one source, emit_store).

entity-kind classifier decision record (issue #366)

Owner-confirmed 2026-11-11 (verbatim directive: "confirmed on 366", transcribed with provenance in the issue comment; proposed record adjudicated by an independent plan critic). The five precision rows observed in the #365 review are all kept spec-literal — each output matches its documented rule in classify_entity_kind's docstring, and no guard is added ahead of host/service/command (guards would complicate the precedence chain issue #176 owns):

  1. Hyphenated one-dot basenames (my-script.py, build-helper.sh, claude-code.md, app-config.json) stay host — the one-dot host rule runs before file, and persisted kinds are sticky under first-seen-kind-wins.
  2. The slash/doc-suffix asymmetry stands: docs/readme.md → other while README.md → file (the doc-suffix rule is gated on / not in the value).
  3. Bare runner-head words (make, go, node, python) stay command (first whitespace token in RUNNER_HEADS).
  4. Service nouns matching filenames stay service: server.log and server.py are service (whole-word rule); access.log remains file.
  5. CamelCase service identifiers (UserService, DatabaseServer) stay other — the whole-word rule runs on the casefolded token; CamelCase splitting is an issue #176 spec change and stays a non-goal.

The IPv4 fixture sub-decision: classify.json keeps its frozen 30 rows — the ASCII-strict behavior (Arabic-Indic/fullwidth quads classify other) stays pinned by the direct test test_ipv4_octets_are_ascii_strict, and no fixture row is added. The hardening items that motivated #366 landed via PR #365 (squash a3e75178, which carries the review-round fixes): the linear host one-dot rewrite and the ASCII-strict IPv4 octet class.

Companion record from the PR #454 feedback ledger (F-30 / PRR-013): recall --json/--explain carry no LF-only byte contract (PRR-013: an LF-normalized golden exists only for the --for-injection arm and is unaffected) — recorded here so the decision line lives with the other dated records.

Display masking (issue #455). Each entity report surface masks credential-shaped display values (its own names/aliases; the merge dry-run also the alias-collision join; reclassify the change-row names; reconcile the orphan name/suffix/owner_name and merged-row names) with the whole-value marker [REDACTED_SECRET] at render time — display only; stored rows, the classifier, and entity-card construction are untouched, and legal label:port names (localhost:8080) render unmasked. Detection uses NFKC-normalized exact secret-head tokens over whitespace-trimmed [:_="'-]-delimited segments (colon, underscore, hyphen, equals, quote — so attribute-assignment shapes like DB_PASSWORD=12345 and DB_PASSWORD = 12345 mask, as do quoted-key shapes like {"password": "value"}; bare whitespace without punctuation does not separate; fullwidth :/= fold to ASCII and EVERY Unicode format character (category Cf — ZWJ, soft hyphen, word joiner, BOM) is stripped, so glued forms like pass\u200dword:12345 and pass\u00adword:12345 mask) and, for EVERY assignment-separator occurrence, over the DOT-segments of its key prefix (db.password=12345, spring.datasource.password:5432, earlier-separator forms redis:db.password=12345 / --config=db.password=12345, the dot-joined head spelling api.key=12345, whitespace around/inside key material (db. password=…, db password=12345, --config password=12345), and trailing separators with an empty value (db.password=); never a global dot split — api.example.com and acme.corp:8080 stay unmasked; -/_ are hard joins, so db.password-prod=1 stays unmasked like tokenized-service; bare whitespace WITHOUT any assignment separator (password 12345) is not a key/value boundary and stays unmasked) plus the shared redaction registry. The local head list is REGISTRY-PARITY: every key-head family of redaction.py's credential pattern — password, passwd, pwd, secret, token, api-key/api_key/apikey, private-key/private_key, credential, auth — is an exact local token, so short values below the registry's 8-character value floor (pwd:12345, private_key:9) mask locally too. Still unmasked by the exact-token boundary: prefix-shaped words (tokenized-service, author, secretariat) and glued NON-registry spellings (secretkey=…, passphrase=…, pgpassword=…). Masking is fail-closed: the reports never fall back to raw output — a registry that becomes unavailable after startup (or whose call fails at render time) makes the renderer refuse (dry-run exits 2 with empty stdout; apply reports from the committed state), and a registry missing at process start fails the CLI at import before any report runs. The masking predicate is LINEAR in the input — pinned by DisplayMaskPerfTest's scaling-ratio assertion (the pre-feedback form was quadratic in the separator count).

reconcile-entity-aliases — merge pre-widening orphan identities (issue #358)

<interpreter> <store.py> reconcile-entity-aliases --dry-run|--apply [--json]

A vocabulary widening silently re-keys stored aliases on the next relink: a store written before v16 stored a tag like host:acme-prod as one other-kind entity whose alias is the WHOLE token; after the widening, re-deriving that memory mints {host, "acme-prod"} and leaves the old other entity with zero links (the 0.101.0 CHANGELOG discloses the behavior — only the entity lane's grouping changes; content and FTS recall are unaffected). This command reports — and, behind --apply, merges — those orphans back onto the entity that now owns the suffix alias, so BOTH alias forms resolve to one identity. Issue #455 display masking: orphan name/suffix/owner_name and merged-row name render as the whole-value marker [REDACTED_SECRET] when credential-shaped (full contract under the classifier decision record above). (Dry-run's mergeable counts every orphan that HAS an owner; apply additionally refuses cyclic chains, so merged can be smaller than mergeable by exactly the skipped_cycle count.) Run it AFTER upgrading past a widening and BEFORE the next purge (a purge reaps linkless entities and would delete the old alias form with the orphan).

--dry-run is strictly read-only (zero database bytes, no writer lease) and reports every orphaned <kind>:<name> other-entity with its prefix, suffix, and owner (null when no entity owns the suffix yet — EXCEPT two cases: a no-owner orphan that another orphan's chain terminates ON stays alive and IS that chain's merge target; and an orphan that OWNS ITS OWN SUFFIX is reported with owner null and left untouched — self-merge is not a reconciliation). The orphans array means pre-apply candidates in dry-run and the remaining unmerged rows after apply. --apply takes the writer lease, refuses an unmigrated store up front, and performs every merge in ONE transaction: terminal-owner resolution first (a chain like host:service:foo → service:foo → foo merges into the final live owner; any chain that reaches a cycle is refused WHOLE — the cycle's members and any tail feeding into it — reported as skipped_cycle/cycle_ids), then per orphan: aliases move first-delete-then-reinsert onto the owner, links move with UNIQUE(memory_id, entity_id) dedup, and the orphan row is deleted. A merged orphan's live entity_card is tombstoned in the same transaction (a live card naming a deleted entity would render without its recall suppression; the owner's card rebuilds from the owner's links via the cadence sweep). The old full-token alias survives on the owner. Non-other rows are never touched, and the same ALL-namespaces scope as every entity surface applies.

links — inspect (or curate) a memory's associative links (v11, issue #61)

<interpreter> <store.py> links --id <uuid> [--json]           # list every edge
<interpreter> <store.py> links --add --id <a> --id <b> \
    --relation <related|supports|contradicts|updates|extends|derives> \
    [--score S] [--reason "..."]

List mode prints every memory_link edge touching the memory, both directions (out = the memory is the source; JSON rows carry {src, dst, direction, other, relation, score, created_at}). A missing id exits 1 with the same stable stderr line as get.

--add is the operator-curated insertion path for the typed relations (and for the trust-carrying supports/contradicts). Symmetric relations (related/supports/contradicts) insert BOTH directions; typed relations (updates/extends/derives) keep their one authored direction. Self-links and cross-namespace pairs are refused; re-adding an existing edge is an exact no-op (idempotent, no second trust delta). --relation contradicts|supports adjusts trust_score, so those inserts REQUIRE --reason — the same deliberate-use guard contradict enforces (validated and echoed, not persisted).

Links are generated automatically on every add/update (see ZMEM_LINK_THRESHOLD below) — deterministic, no LLM (no ZMEM_LINK_LLM knob exists; link generation never calls a model).

contradict — record a contradiction (v11, issue #61)

<interpreter> <store.py> contradict --id <a> --id <b> --reason "<why they conflict>"

Inserts a contradicts pair (both directions) and applies the trust event to BOTH rows: trust_score −0.10 each, clamped to [0.0, 1.0] (ten contradictions land a row at exactly 0.0, never below). Neither row is merged, deleted, or rewritten — content, confidence, and signal are untouched. --reason is REQUIRED (the invalidate deliberateness convention); the v11 schema has no reason column, so the reason is validated and echoed but deliberately not persisted. Re-running the same contradict is an exact no-op. A polarity-disagreeing add that lands in the dedup window does this automatically (see add).

trust_score semantics (v11): every memory starts at 1.0. A contradicts event lowers BOTH rows by 0.10; a supports link or a polarity-agreeing duplicate re-add (corroborating add, CLI add/update only — sync ingest never re-applies deltas) raises the keeper by 0.05. Always clamped to [0.0, 1.0]; visible in get --json, export-jsonl, and doctor. confidence/signal are never changed by linking — they are provenance inputs; trust_score is the contradiction ledger.

Operation feedback counters (issue #124)

applied_count/violated_count have ONE writer (storelib/write.py::feedback_memory) with TWO callers: the explicit store.py feedback --id --applied|--violated CLI and the observational store.py operation-feedback command that the capture-failure and posttoolbatch hooks invoke after each operation outcome. The command matches the event against the session's delivered rows (1,800 s window, >= 2 token overlap), requires the event's evidence id to be associated with the memory, and records every verdict in a per-session sidecar (<data>/ops/<sha256(session)[:32]>.feedback.jsonl, one compact sorted-key JSON line with exactly event_id, evidence_id, memory_id, overlap, session_id, timestamp, verdict — verdict is applied, violated, or unmatched). Ranking popularity is usefulness only: 0.15*sqrt(applied_count) - 0.25*sqrt(violated_count) clamped to [0.0, 1.0]; retrieval/surfaced telemetry never feed ranking. stats, doctor's voyager-counters check, and the miss-rate report expose seven keys in this order: total_applied, total_violated, nonzero_applied, nonzero_violated, matched_applied, matched_violated, unmatched_operations; the miss-rate JSON adds feedback_associations (session_id, event_id, memory_id, overlap, evidence_id per row).

Live host canary lanes (issue #96)

scripts/host_canary.py also owns nine LIVE host-canary lanes. Each lane runs against an isolated store, writes a schema-valid artifact (canary/<lane>.json, contract in scripts/canary-schema.json), and always exits 0 for a completed run — the verdict lives in the artifact, never in the exit code. A negative observation is a structured fail with the measured values and the exact command outcome in notes; it is never a crash and never a faked pass. Lanes: hermes-gateway, hermes-provider-mode, hermes-compat-mode, claude-compact, codex-trust, zcode-duplicate, exec-form-claude, exec-form-codex, exec-form-zcode. Validate any artifact with python scripts/host_canary.py --validate-result canary/<lane>.json. Measured SHA/version values are recorded only when the host's documented surface emits them (version-unavailable otherwise); hook identifiers are derived from the host's real manifest (PreCompact:zmem-launch.js shape), never invented; four before/after inventory maps prove the run only touched the isolated canary store. Canonical fence bytes come from the store's own rendered envelope (#158) — Hermes wraps them in <memory-context>, so no zmem fixture ever contains that wrapper.

Timeout budget

Issue #121: the hook path runs inside the host's hook timeout (the host configs give SessionStart 15 s) with an internal budget. Canonical shared integer values live in hooks/timeout-budget.json; the private training-capture allocations below are implemented in hooks/zmem-launch.js. Runtime overrides read env vars and fall back to those defaults (an invalid value falls back with exactly one warning).

Stage Value Override env var Notes
Launcher watchdog 12000 ms ZMEM_LAUNCHER_WATCHDOG_MS Kills the child tree at the deadline, emits the retained Tier 0 sentinel, logs outer_timeout=1 reason=omitted, exits 0.
Automatic capture start 5000 ms - Synchronous only when the store-issued capture id is needed; observe/snapshot helpers are detached and fail open. The translated launcher clamps this private allocation to the remaining 12,000 ms watchdog deadline minus a 500 ms output reserve, and never asserts acknowledgement or outcome.
Hermes capture subprocess 5000 ms - sync_turn and session-end capture use their own per-operation cap and may add up to 5 seconds to that callback or maintenance path. The launcher’s full 5-second start allocation can leave roughly 6.5–7 seconds inside the 12,000 ms watchdog, below the 8-second recall budget, so watchdog preemption is an expected degradation. No whole-callback wall-clock guarantee is made; capture remains fail open.
Namespace resolution 2000 ms ZMEM_NAMESPACE_RESOLVE_MS Per interpreter attempt; successful non-empty REMOTE namespaces are cached per process.
Namespace cache TTL 60000 ms ZMEM_NAMESPACE_CACHE_TTL_MS Entry expires at exactly TTL; path-key resolutions are never cached.
Store recall 8000 ms (8.0 s) ZMEM_STORE_RECALL_TIMEOUT_S SessionStart + the shared recall body. Finite positive float; values above 8.0 clamp to 8.0 (one warning); values below 8.0 are honored. ONE store attempt at SessionStart (no retry loop).
SQLite busy timeout 5000 ms - storelib/schema.py (PRAGMA busy_timeout=5000); unchanged.
Hermes manager join 8000 ms - The Hermes MemoryManager joins external-provider prefetch at 8.0 s (external runtime, not vendored in this repo).
Hermes provider deadline 6000 ms ZMEM_HERMES_DEADLINE_S Issue #160: every provider prefetch runs under this deadline (default 6.0; invalid or >= 8.0 values resolve to 6.0 with exactly one warning).

SessionStart emits a complete Tier 0 sentinel BEFORE the first store subprocess (the fast path), so a store stall inside the watchdog window still delivers Tier 0 to the host. The launcher watchdog arms before startup, so namespace resolution counts against the 12,000 ms budget. The scripts/bench_hook_latency.py benchmark reports deterministic injected-clock p50/p95 schedules for the seven stages (launcher, namespace, store, embed, fuse, render, time-last-capture) — a contract-regression gate whose --compare-baseline pins stage keys, per-stage values, and a machine-independent input_digest; it does not measure wall-clock latency.

Store hygiene (read-only, issue #97)

store.py hygiene audits a SNAPSHOT of the store — it never opens or writes the live store, and it never mutates a memory, vector row, namespace, or signal. Hermes-origin MUTATIONS remain owned by issue #168's explicit operator map; this command is strictly report-only.

Operator workflow:

  1. Snapshot copy first. The live store runs in WAL journal mode, so a bare cp of store.sqlite can miss commits still sitting in the -wal file (or open malformed mid-checkpoint). Use a crash-consistent snapshot instead — either the built-in verified backup or SQLite's own backup API:

    # Option A (preferred): the store's verified backup writes a
    # crash-consistent store-<stamp>.sqlite snapshot.
    <interpreter> skills/memory/scripts/store.py backup
    # Option B: SQLite's online backup API.
    sqlite3 ~/.zmem/store.sqlite ".backup '/tmp/snapshot.sqlite'"
    

    Then run the report against that snapshot (the command opens it with SQLite mode=ro, so even an accident cannot write):

    <interpreter> skills/memory/scripts/store.py hygiene \
      --store /tmp/snapshot.sqlite \
      --origin-map origin-map.json --evidence-map evidence-map.json \
      --out report.json --format json
    
  2. Review the report. It contains the snapshot's SHA-256, row totals and live counts, sorted namespaces/signals, the reviewed Hermes-origin ids, the six known junk namespaces (ns1, ns2, project:, test, unfoldtest, user:t), duplicate logical keys (live rows sharing a content_norm), and a sorted signal=none upgrade action plan.

  3. Verified backup (store.py backup) before any remediation, then a dry-run rekey (rekey-namespace --dry-run) if namespaces must move.

  4. Mutation boundary. The action plan emits store.py update ... commands as REVIEW ARTIFACTS — verbatim and unquoted by contract, so read them before running anything; it executes nothing. Every proposed upgrade requires a live later grounded row, a live supports/updates/ extends/derives link, a non-empty proof source reference, and a justification. A rerun omits a target whose current row is superseded.

Invalid input (unreadable files, malformed JSON, duplicate or unknown mapped ids, SQLite errors) exits 2 with [zmem] hygiene: invalid input and creates no output file.

Namespace resolution (host.py resolve_namespace) now distinguishes git remote / absent / error: a successful remote key is cached to disk (<data-dir>/namespace-cache/, TTL 3600 s), an absent checkout keeps its path key, and a git ERROR inside a checkout resolves to the cached remote key or user:global — never a path-shaped key. Cache failures fail open silently. (This is the host-layer disk cache, distinct from the hook's per-process namespace cache in the timeout budget above.)

Hard rules

  • Never put secrets/credentials/PII in the store. It is a local plaintext sqlite file. The write-time filter is advisory only (regex heuristic), not a guarantee.
  • doctor.py is read-only. It should inspect, never repair. Do not let it create the store, rewrite config, or "fix" hook trust for the operator.
  • Legacy namespace cutover: before first v5 use, set ZMEM_NS_MIGRATION_MAP to a JSON object mapping each old namespace to a live checkout path, for example {"project:oldname":"C:/src/owner/repo"}. The migration re-derives canonical remote-based keys from those checkouts; it retries the map on later opens, but an omitted entry remains under its old key until the map is supplied.
  • Signal honesty: signal=none means no external grounding — the lesson is the agent's self-opinion. Never set signal=test unless a test actually ran.
  • Wrap, do not replace: this skill never writes to tasks/<slug>/*.md or issue-traces/<issue>/*.md. Those are durable-session-state's source of truth.
  • High-precision-first: if a recalled memory does not clearly apply, ignore it. Retrieved-wrong memory hurts more than retrieved-nothing.
  • Source refs: prefer immutable sources (db:, session:). For mutable markdown (file:), a content hash is stored and checked on recall — if the source changed since extraction, the memory's confidence is halved and flagged [STALE SOURCE]. file: paths may be Windows (C:\Users\...) or Cygwin (/c/Users/...) — both are auto-normalized. If a file: ref cannot be opened, a stderr warning is emitted.

How recall works

Query hygiene (#112): the lexical query is normalized before it becomes the FTS5 MATCH — tokens are casefolded, deduped, stripped of edge punctuation, closed-class English stop words are dropped, tokens under 3 characters match exactly instead of prefix-wildcard, the term list is capped at 24 (the last terms — the ops-token tail — always survive), and the MATCH is column-filtered to {content tags} so namespace text alone can never make a row a candidate. recall --explain records the resulting shape in its query_shape field.

Retrieval is a four-signal pipeline: FTS5/BM25 keyword match (always), vector KNN over stored embeddings (when the optional embedding runtime is available), entity matching (v10 — always, no model needed; the query's identifiers and plain tokens are matched against stored entity aliases), and the graph-seed arm (issue #136 — always, no model needed): the matched entities seed one bounded hop over seed-safe links (related, supports, updates, derives) so a linked neighbor can enter the pool without sharing any query token. The lanes' rankings are fused with Reciprocal Rank Fusion (RRF, k=60, per-id additive: a memory appearing in several lanes accumulates each lane's contribution), then re-ranked by a composite score that combines:

  • Relevance (55%) — issue #113 lane composition: the max of the lexical lane (term coverage x pool-normalized rank ratio; >= 2 matched terms), embedding cosine, and entity-match fraction (see the per-lane subsection below)
  • Confidence (20%) — grounded by signal tier (test/compile > reviewer/user > none)
  • Recency (15%) — exponential decay with a 90-day half-life
  • Popularity (10%) — retrieval frequency with diminishing returns (sqrt dampening)

Candidates are namespace-filtered (the tier's expanded alias set) and subject to the confidence floor (below 0.25 is dropped before scoring). Staleness demotion halves confidence, which feeds into the confidence component.

Per-lane relevance composition and inject floors (issue #113): a candidate's relevance term is the MAX of its measured lanes — lexical (matched-terms/total-terms x row-|bm25|/pool-best-|bm25|), vec cosine, or the entity fraction — replacing the saturated |bm25|/(1+|bm25|) back-solve, so a lexically-matched row keeps the cosine it earned and the score carries rank information from every lane. The lexical leg counts only when the row matches

= 2 distinct query terms or covers all of them — one shared generic token is not relevance (the measured "git status" failure shape); an ineligible-but- ranked row carries a measured 0.0, a lane with no signal carries null (absent). The inject gate consumes these values with DISJUNCTIVE floors: a trusted row is admitted when ANY measured lane clears its own floor — lex 0.30 / cos 0.50 / entity 0.50 (env ZMEM_INJECT_FLOOR_LEX / ZMEM_INJECT_FLOOR_COS / ZMEM_INJECT_FLOOR_ENT, calibrated starting values); a measured-but-failing lane does not veto a row another lane admits, and ABSENT lanes are exempt (link-expansion rows carry none). A silent inject whose rows passed the trust gate but failed these floors names the new closed-set reason below-relevance ("nothing relevant") instead of below-bar ("nothing trusted"). recall --explain shows the numbers: found/below_limit verdicts carry detail.lanes, the envelope reports the resolved lane_floors, and a row that missed every pool but would ride the 1-hop link walk reports a link_expansion verdict (parent, relation, score) instead of not_in_pool. Recall rows also carry the underscore-prefixed diagnostic keys _rel_lex / _rel_cos / _rel_ent / _rel_graph — the measured lane values the inject gate floors judge (same family as the existing _score key).

Graph-seed arm and per-arm caps (issue #136): recall runs FOUR candidate arms — FTS, vector, entity, and graph-seed — each capped BEFORE fusion so one over-generous arm cannot fill the fused window (named defaults equal the pre-#136 windows, so default behavior is unchanged; ZMEM_ARM_CAP_FTS / ZMEM_ARM_CAP_VEC / ZMEM_ARM_CAP_ENTITY / ZMEM_ARM_CAP_GRAPH override, ZMEM_GRAPH_SEED=0 disables the graph arm). The graph-seed arm resolves the query's entities (and ops tokens, which ride the same alias path) to seed memories, then walks ONE bounded hop over related/supports/updates/ derives edges — never contradicts as a seed — and emits the capped, deterministically ranked neighbors as the fourth RRF list. Each graph-arm row carries a MEASURED _rel_graph lane: the best entry-edge score, judged against the graph floor (default 0.75 = the write path's own LINK_THRESHOLD; env ZMEM_INJECT_FLOOR_GRAPH) by the same disjunctive inject gate — a graph-rescued row injects only when its edge to the query-anchored seed is strong, never by the absent-lane exemption. --explain and the --for-injection envelope report per-arm pre/post/cap counts (arms), and the hook's decision line carries arms=fts:P/Q,vec:P/Q,... so the miss-rate report can attribute a hit to the arm that carried it.

Entity matching (v10, issue #60 5.3) is the third fused list (after FTS/BM25 and vector; the graph-seed arm above is the fourth): the query runs through the same deterministic extractor used at write time, and plain query tokens are additionally matched against stored entity aliases. Memories linked to matched entities join RRF ranked by (number of matched entities, recency), with the same namespace filter and the same --as-of temporal predicate as the other lanes — like the vector lane, an entity match in a FOREIGN namespace never leaks into the querying tier (it is found by its own tier's run, e.g. the global tier under --include-global). Works model-absent by design; an unknown alias contributes nothing.

Link expansion (v11, issue #61 6.3): after MMR and the passive-surface filters, each result's related/supports edges are walked ONE hop and up to --link-budget (default 2) neighbors are appended to the result set (never duplicating an already-present row, never crossing the tier's namespace set, and always respecting the --as-of predicate — a neighbor invalid at the requested instant does not surface). contradicts neighbors must additionally survive the confidence floor and are tagged [CONTESTED LINK: <id>] naming the other end of the contradiction. Expansion is bounded (one indexed lookup per result row, budget-capped) — no PageRank or graph propagation of any kind.

MMR diversity (v10, issue #60 5.5): after the composite sort, and before --limit, Maximal Marginal Relevance re-orders each tier's candidates — picking the best-scoring row first, then trading relevance against similarity-to-already-picked (lambda: 0.7 default, ZMEM_MMR_LAMBDA env, --no-mmr to disable, 1.0 == no diversity). Row-to-row similarity is embedding cosine when both rows have embeddings, else Jaccard on normalized content tokens — so near-paraphrase clusters stop crowding out distinct facts even on model-absent stores.

Entity cards (v10, issue #60 5.4): every recall row carries its linked entities — entities: [{id, kind, name}] in recall --json, at most three names per row in the fenced hook render. entity-list inspects the entity tables; entity-merge reconciles duplicates.

Hybrid recall is the default when the embedding runtime is available (auto-enabled; --no-hybrid forces lexical-only): a vector/embedding lane runs on top of this keyword pipeline and its ranking fuses with the lexical and entity lanes via the RRF above, so a memory BM25 missed can still surface via embedding similarity. It needs the optional onnxruntime/tokenizers runtime and embedding model; without them recall fails open to plain keyword + entity ranking (see the recall command section above for the full contract).

Entity extraction at write time is deterministic (no LLM): explicit entity:Name / entity:<kind>:Name tags, the project:<suffix> namespace suffix, --tags tokens (kind:Name or plain), backtick-quoted spans in content (kind tool), and CamelCase identifiers (2+ humps). Every source that carries NO explicit kind — untyped entity:Name, plain --tags tokens, CamelCase identifiers — is resolved by the deterministic classifier (reclassify-entities documents its precedence); a typed entity:<kind>:Name/kind:Name tag and the backtick-tool rule stay authoritative. Stopwords (the, and, use, …) and path/URL-shaped tokens are never entities; person entities exist only via an explicit entity:person: tag. The first kind seen for an alias wins; reconcile with entity-merge.

The rebuild-fts subcommand rebuilds the FTS5 index from scratch (useful after bulk imports or if the index drifts):

<interpreter> <store.py> rebuild-fts

Sole memory system on Claude Code (replace native)

When running under Claude Code with native memory turned off (autoMemoryEnabled: false in ~/.claude/settings.json, or CLAUDE_CODE_DISABLE_AUTO_MEMORY=1), ZMem is the only memory system in play — it is not layered on top of CC's own memory. In that mode:

  • store.py add / recall (this skill) is the canonical, sole path for capturing and retrieving durable knowledge. Treat it exactly as you would native memory on any other host.
  • Do not hand-write into CC's native ~/.claude/.../memory/ files or MEMORY.md — that mechanism is intentionally disabled, and writing to it would silently resurrect the duplicate-memory problem "replace native" exists to avoid.
  • Project-level Tier 0 is CLAUDE.md (CC's own always-on mechanism, untouched by ZMem) — not AGENTS.md. The SessionStart hook does not inject AGENTS.md on Claude Code for this reason; it still does on ZCode.
  • If a SessionStart nudge appears telling you native memory still looks enabled, that is informational for the user (a plugin cannot flip the setting itself) — no action needed from you beyond surfacing it once.

Codex shared-store mode

For Codex, the safe cutover shape is:

  • Disable Codex native memories yourself in ~/.codex/config.toml; do not let zmem auto-edit Codex config.
  • Use one canonical physical store path shared with the plugin hosts.
  • Treat ZMEM_TIER0=native as the target shape: keep Codex's own project instruction surface native, and use zmem for shared Tier 2 durable memory.
  • Do not assume Codex can write ~/.zmem. If the shared store path is outside Codex's writable roots, add a writable root or use a local broker that owns the store and mediates read/write operations.
  • Reapprove hooks after hook-surface changes. Codex trusts hooks by a SHA-256 over each normalized hook entry (event name + matcher + handler fields). Every new or changed hook entry — this plugin's hooks file changed in 0.28.0 (#95: PreToolUse + PreCompact entries) — carries a new trusted hash, and an untrusted entry is SILENTLY SKIPPED: the hook simply never runs. After upgrading, re-approve the hook surface in the Codex TUI /hooks review (or re-record the hooks.state trusted hashes); for vetted automation only, codex exec --dangerously-bypass-hook-trust runs hooks without persisted trust for that invocation.

The reflection loop (Loop 1)

The zmem-reflect.sh Stop hook checks the episodic db for failed tool calls (status=error or exit_code!=0 on non-read-only tools) in the current session. If found and no lesson references this session, it injects an additionalContext prompt at stop time. It is non-blocking (exit 0) — it only reminds you.

Three env vars control the Stop hook's ZCode-db reader (#194): the reader opens the db read-only (mode=ro URI plus PRAGMA query_only); ZMEM_FAILURES_DB_TIMEOUT_S bounds how long it waits on a busy ZCode db before reporting a substrate error (default 1.0; invalid or unset falls back to the default; clamped to 0.1-5.0; store.py failures --db-timeout overrides); ZMEM_ZCODE_DB overrides the ZCode db path (tests and operators can point the detector at a scratch copy without touching ~/.zcode; empty/unset means the default ~/.zcode/cli/db/db.sqlite); ZMEM_REFLECT=0 (exactly 0) disables the whole Stop hook — unset, empty, or any other value keeps it enabled.

The no-failure nudge fires only on a signal change (#258)

When the session has no tool failures and no rejections, the success nudge ("you may have learned something worth capturing") is gated on a state TRANSITION, not the state itself: it fires on the first Stop of a session or when a recognized runner signal appears or flips (test/compile/lint via capture_quality.infer_signal over the transcript's Bash commands, read through store.py signals), and otherwise stays silent. Rejections need no separate trigger — they have their own always-fire branch above this gate (review PRR-001). "Recognized" is prefix-exact: bare pytest, python -m pytest|unittest, python -m compileall, ruff check, and biome check classify; compound commands (cd x && pytest, uv run pytest) do not, so sessions that only invoke runners that way still see the once-per-session behavior. The last-seen signal state is persisted per session at <ZMEM_DATA>/ops/<sha256(session)[:32]>.signals (atomic tmp-file + os.replace write; the finished file is swept on the shared 7-day ops mtime policy, crashed-write *.signals.tmp.* orphans are reaped by purge) and re-recorded on every quiet Stop until a lesson is captured for the session — the lesson-dedup exit precedes the gate, so the record freezes from that point (silent still means no additionalContext, never suppression of the signal itself): the closeout skill's Step 0.5 candidate-review pass (skills/closeout/SKILL.md) is the intended future reader of that record; a later PR extends it to surface suppressed signals. Substrate notes: on the ZCode db substrate tool_usage has no command text, so no runner signals are tracked and the nudge fires once per session; a missing transcript path reads as a checked-empty scan (the hook treats it as a fresh quiet session); if signals cannot read the substrate (nonzero exit), the hook emits nothing and leaves the persisted state untouched — a broken substrate must neither nag every Stop nor blank the record.

Subagent reflection is parent-side (#204)

The zmem-subagent-reflect.sh SubagentStop hook NEVER prompts the finishing subagent. Claude Code honors additionalContext on (Subagent)Stop by continuing the conversation, so a stop-time prompt would re-run the subagent turn and its reply ("Memory captured", "blocked by sandbox guard, skipping", …) would replace the deliverable the orchestrator receives as the subagent's <result>. Instead, when the subagent's own transcript shows failures (or rendered user rejections) and no lesson exists for session:<sid>:agent:<aid>, the hook writes a hand-off sidecar under <ZMEM_DATA>/subagent-reflections/ and emits the empty envelope. The PARENT's own Stop hook scans that directory for this session's sidecars, renders one reflection prompt covering all pending subagent failures (pending sidecars alone are sufficient — a clean parent transcript still prompts), and deletes the sidecars it rendered. Sidecars are consumed on read, refreshed per agent on re-fire, and pruned when older than 14 days, so at most one parent prompt surfaces per dispatched agent batch. ZMEM_REFLECT=0 disables the subagent hook too (no sidecar). The Stop hook also no-ops on Stop payloads carrying Claude subagent markers (agent_id/agent_transcript_path) — defense-in-depth for hosts where Stop fires bare inside subagent contexts. Retention note: a sidecar persists the subagent's failure details and rendered rejection reasons as plaintext JSON in this directory until the parent consumes it or the 14-day prune removes it (the prune runs from both the parent's Stop scan and each sidecar write).

Capture a lesson only if it generalizes to a future session facing a similar situation. If the failure was a one-off (typo, transient), do nothing — the prompt explicitly allows that. Do not capture in-trajectory refinement tweaks as durable lessons; only capture what would help next time.

04 同仓库更多 Skills