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
- 参考文档models/config.json650 B
- 参考文档models/tokenizer.json131 B
- 脚本scripts/capture_quality.py8.6 KB
- 脚本scripts/correction_queue.py21.0 KB
- 脚本scripts/corrections.py28.8 KB
- 脚本scripts/cross_encoder_profiles.py4.4 KB
- 脚本scripts/doctor.py287.6 KB
- 脚本scripts/drift.py19.5 KB
- 脚本scripts/embed_profiles.py8.7 KB
- 脚本scripts/embeddings.py38.4 KB
- 脚本scripts/fleet/__init__.py316 B
- 脚本scripts/fleet/broker_cli.py2.6 KB
- 脚本scripts/fleet/broker.py34.5 KB
- 脚本scripts/fleet/map_perms.py7.5 KB
- 脚本scripts/fleet/opqueue.py15.0 KB
- 脚本scripts/fleet/snapshot.py40.2 KB
- 脚本scripts/fleet/transport.py39.6 KB
- 脚本scripts/history_mining.py21.6 KB
- 脚本scripts/host.py69.0 KB
- 脚本scripts/import-store.py51.6 KB
- 脚本scripts/opencode_bridge.py6.6 KB
- 脚本scripts/protocol.py4.5 KB
- 脚本scripts/redaction.py11.1 KB
- 脚本scripts/requirements-training.txt170 B
- 脚本scripts/schema_meta.py36.0 KB
- 脚本scripts/store.py3.0 KB
- 脚本scripts/storelib/__init__.py18.4 KB
- 脚本scripts/storelib/backup.py53.9 KB
- 脚本scripts/storelib/beliefs.py40.6 KB
- 脚本scripts/storelib/cli.py361.3 KB
- 脚本scripts/storelib/config.py4.1 KB
- 脚本scripts/storelib/consolidate.py92.1 KB
- 脚本scripts/storelib/cross_encoder.py22.3 KB
- 脚本scripts/storelib/dataset.py59.6 KB
- 脚本scripts/storelib/decision_report.py32.9 KB
- 脚本scripts/storelib/delivery_ledger.py94.1 KB
- 脚本scripts/storelib/entity.py105.5 KB
- 脚本scripts/storelib/episodes.py14.5 KB
- 脚本scripts/storelib/eval_gold.py44.1 KB
- 脚本scripts/storelib/evidence.py20.9 KB
- 脚本scripts/storelib/false_inject.py17.9 KB
- 脚本scripts/storelib/feedback.py10.1 KB
- 脚本scripts/storelib/hygiene.py13.5 KB
- 脚本scripts/storelib/inject.py87.5 KB
- 脚本scripts/storelib/links.py35.5 KB
- 脚本scripts/storelib/log_rotate.py9.5 KB
- 脚本scripts/storelib/mine.py108.6 KB
- 脚本scripts/storelib/miss_rate.py71.2 KB
- 脚本scripts/storelib/namespace_cache.py4.1 KB
- 脚本scripts/storelib/opcmds.py1.2 KB
- 脚本scripts/storelib/ops_tokens.py28.5 KB
- 脚本scripts/storelib/ops.py30.5 KB
- 脚本scripts/storelib/organize.py57.4 KB
- 脚本scripts/storelib/pages.py64.9 KB
- 脚本scripts/storelib/promote.py11.5 KB
- 脚本scripts/storelib/purge.py72.3 KB
- 脚本scripts/storelib/query_ambiguity.py7.5 KB
- 脚本scripts/storelib/readonly.py16.8 KB
- 脚本scripts/storelib/recall.py301.5 KB
- 脚本scripts/storelib/rekey.py16.4 KB
- 脚本scripts/storelib/remote_gate.py29.8 KB
- 脚本scripts/storelib/retain.py29.8 KB
- 脚本scripts/storelib/schema.py120.8 KB
- 脚本scripts/storelib/source.py23.0 KB
- 脚本scripts/storelib/state_expiry.py5.6 KB
- 脚本scripts/storelib/state.py7.4 KB
- 脚本scripts/storelib/subject.py3.9 KB
- 脚本scripts/storelib/sync.py174.9 KB
- 脚本scripts/storelib/temporal.py7.5 KB
- 脚本scripts/storelib/training_capture.py55.5 KB
- 脚本scripts/storelib/training_export.py598 B
- 脚本scripts/storelib/training.py69.7 KB
- 脚本scripts/storelib/tune.py6.1 KB
- 脚本scripts/storelib/write.py142.7 KB
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. Editcore.mddirectly 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 viascripts/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-modeand, since 0.129.0 (issue #326), the expiry pair--expiry-class --inject-untilonaddandupdate(a state add requires--expiry-class; anupdatemay omit--typeand the target's type resolves when the hub applies the op — issue #317). Occurrence flags (--occurred-*,--mentioned-at) and--type eventstill 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"}(orhub-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 withthis command runs on the hub; ZMEM_REMOTE_URL is set— run it on the hub machine. - Config rule:
ZMEM_STOREtogether withZMEM_REMOTE_URLis a configuration error (exit 2).ZMEM_DATAalone 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:
recallrelevant past lessons. High-precision-first: if a retrieved lesson does not clearly apply, ignore it. - After a failure with a generalizable lesson:
adda 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:
addit. - When a memory is stale/wrong:
supersedeit for a general tombstone; use the dedicatedinvalidatecommand when a fact is no longer true (it REQUIRES a reason so the correction is auditable); useupdateto revise a memory append-only (the old row is tombstoned, a new live row links back viaupdate_of, and point-in-time--as-ofrecall 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 withstore.py entity-list - v11 link surface present (
memory_linktable +memory.trust_scorein range [0,1]); inspect deeper withstore.py links --id <uuid> - v13 episode storage present with counts (
episode-tablescheck), and the MCP token scope advisory (mcp-tokencheck: warnsunscoped_token: trueon 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-memorycheck, issue #185):~/.zcode/v2/setting.jsonmemoryEnabled: truefails cutover, explicitfalsepasses; 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-hookcheck, 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 warnuntrusted-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 withpromote-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-driftcheck, issue #107):matched/drifted(warn — first 10 differing paths + both digests)/unknown-skip when the tree predates 0.17.0 and carries no manifest, andunknown-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 onezmem-driftline tozmem-bg.logand 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 whosesid=proves it is this session's decision carries a matched row id.surfaced (legacy)— same evidence on a pre-#94 sid-less line or asid=unknownline (window+overlap attribution only;miss_rate_strict_sidexcludes 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(distinctsid=count),max_per_session(busiest single session), and the row'stier. Lines carrying no usablesid=count towarddeliveredbut contribute no session, and the run says so incaveats. The writers'sid=unknownplaceholder 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(theids=entries) againstwithheld(theall=candidates that were not injected).deliveredcounts only lines the shared injected-line predicate accepts, so ids on astatus=silentline are never reported as deliveries; their candidate pool is still counted aswithheld, 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
addwhose 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 lessonrow 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 anamespace_project_near_misswarning naming the canonicalproject: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-cardwhen a card was written) stamps<sha256(session-id)[:32]>.wroteunder<data>/ops/; refused writes stamp nothing. The session hooks surface exactly one bounded closeout reminder while.wroteexists 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/.nudgedmarkers afterZMEM_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/#123allowlist) whole-token-intersect the hazard-verb set —ops_tokens._HAZARDOUS_SUBS(git push/reset/stash pop/ rebase/...) by default, overridable viaZMEM_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
signalis grounded: one oftest,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:globalrows 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>"
updateis 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 viaupdate_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-ofbefore the update) returns the OLD content; after returns the NEW.invalidateissupersedewith a REQUIRED--reason— the preferred way to record "this fact is no longer true" so the correction is auditable (issue #59, 4.3).supersederemains 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 secondinvalidate/supersedeon an already-tombstoned row is refused (exit 2) — re-tombstoning would movevalid_untilforward and falsify point-in-time history.- Large content:
--content -(the literal dash) reads the content from stdin on bothaddandupdate. 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\nis 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 totrusted_internal;signal=none(the agent's self-opinion) defaults tountrusted_tool. An explicit--taintoverrides. - Hermes/MCP tools (
zmem_add/ the MCPaddtool): default to EXPLICITuntrusted_toolunless the caller passes a taint (e.g.untrusted_webfor a web fetch, ortrusted_internalfor a human-grounded note). Taint propagates worst-of forward through lineage:updatere-creation andconsolidateabsorb 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 duplicateadd/ingest-jsonlrow 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](taintuntrusted_tool) or[UNTRUSTED WEB](taintuntrusted_web). - The passive/auto-inject path (
--no-bump— used by the automatic hooks and Hermes prefetch) OMITSuntrusted_webrows entirely, exactly likeprompt-injection-riskrows (an untrusted web-sourced memory is not trusted enough to inject passively).untrusted_toolrows 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-bumpand 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 NAMEselects from the shipped registry (minilm,fake; see "Embedding profiles" below). Default: activeZMEM_EMBED_PROFILEorminilm. Requires--all—--profilewithout it refuses with the exact conversion command, never a silent no-op.--all --profile fakerequires--confirmONLY 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_vecis 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, theirembedding_modelmarker, andembedded_atare rewritten, plus theembedding_profilemeta key recording the last completed conversion. --batch Npaces 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-runreports 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 inembed_profiles.py— the checked-in tokenizer artifact's byte digest). The loader digest-verifies it from the same buffer theTokenizeris constructed from (from_buffer), andembeddings._TOKENIZER_SHA256re-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 assetmodel-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, oid759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e, 90,387,606 bytes) and NOT the sentence-transformers PyTorch weights — different builds of the same checkpoint hash differently.checksum_mismatchtherefore means "wrong build installed"; restore the pinned bytes from the release asset (or pointZMEM_MODEL_URLat it); doctor prints this note verbatim. Verification has NO escape hatch:ZMEM_MODEL_ALLOW_UNVERIFIEDdoes 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 aboutembedding_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. Runreembed --all(or restore the prior profile) to clear the state. doctordefault-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 reportchecksum_ok:falsewhere older versions stayed silentlynull.- 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-opencreates the row (ended_atempty until close) and prints its id; this is the only creator — no empty-table seeding.episode-addattaches 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-closesetsended_at, computestoken_count(the samerow_token_costthe injection budget uses, summed over LIVE members at close), and with--summarywrites one extractive first-sentence summary row via the standardaddpath (capture-mode auto, so the shared redaction helper runs) linked bysummary_memory_id. A closed episode cannot be re-closed or re-opened (append-only).episode-listprints episodes newest-first with member counts (--jsonfor rows).get --jsonon 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 runsrecent --for-injection --json: with asession_idthe store-side selector (momentsession_start) owns the gate, the budget, the delivery ledger, and therenderedfence, and the tool returns that complete envelope plus the additivecontextalias and the back-compatresult/namespace/idsfields (issue #159); an omittedsession_idtakes the legacy for-injection envelope.retrieval_countNEVER advances (only the surface event is recorded — pinned by tests/test_session_tools.py), injection-risk anduntrusted_webrows are omitted, andZMEM_INJECT_TOKEN_BUDGETis honored. Namespace omitted resolves to the surface's own default —user:globalon 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 anote, exactly one row is written via the standardaddpath (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 >= 3ANDviolated_count == 0→ eligible (seefeedback).- Any
violated_count > 0blocks eligibility; the 2nd violation has already dropped the row'strust_scorebyTRUST_VIOLATION_FLOOR_DROP(0.15, clamped at 0.0, applied exactly once at the violated_count 1→2 crossing).signalis NEVER changed by feedback — trust_score is the usage ledger, signal is provenance. - Passive exposure does NOT count:
retrieval_count/surfaced_count(hooks,--no-bumprecall) are not eligibility inputs. Only the explicitfeedbackCLI 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-runwith 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):
- 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'slast_consolidationmeta keys (two entry points, one clock).--forceis the only bypass. A gated run prints[zmem] organize: skipped by cadence gate (...)and changes nothing;--dry-runmodels the gate (would skip by cadence gate). - 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 (...). - 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 structuralsource_reforganize:prefix, never the (mutable) tags column, so summaries are the pipeline's OUTPUT and never its input. Theorganize:prefix is therefore RESERVED for summary rows: a manually-added row whose--source-refbegins withorganize:is excluded from episodes the same way, and consolidate additionally EXCLUDES every liveorganize:%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-rowsource_reffields in ingested JSONL) warns when a non-empty source_ref carries the reserved prefix —[zmem] WARNING: source_ref prefix organize: is reserved for organize summarieson stderr; the write is NOT rejected and stdout (including--jsonoutput) is untouched. - Entity backfill — every working row missing
memory_entitylinks 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 (andorganize --json) carries the five counts underentity_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-runcomputes the same counts read-only; the session-cadence JSON envelope carries them underentity_cardstoo. - Link backfill — every working row missing
memory_linkedges gets them via the standard write-time linker (related/contradictsatZMEM_LINK_THRESHOLD). - Episodic consolidation —
consolidateon 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 andZMEM_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) declarecapture_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. - 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 andmerged_fromprovenance 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. - 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 exactlysummary,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 thesummary,topicmarker 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. - Unrecalled prune (pass-through) —
--pruneenables theconsolidate --prunerule, extended (issue #62, 7.6): a live row also qualifies when itslast_surfacedis OLDER thanZMEM_UNRECALLED_DAYS(default 30) even withsurfaced_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 != noneis 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_ofpredecessor chain and deletes thosememoryrows, their FTS entries, theirmemory_vecrows (bymemory_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> ---\nblock 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) whenmerged_fromnames 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 ofstore.sqliteand-walmust 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/*.ledgerdelivery 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/.signalsops sidecars are NOT scrubbed (outside the ledger contract;.signalscarries only signal names/statuses/counts, and the finished file is swept on the shared ops mtime policy) — treat them as residue surfaces. The<hash>.tasktextdelegation 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_iddeny-list soingest-jsonlwill 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 --idfor 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 DIRapplies the same removal inside everystore-*.sqliteandprerestore-*.sqlitesnapshot in DIR, rewriting files in place (never deleted, truncated, or renamed) and re-verifying each withintegrity_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 spawnsstore.py retain-adaptthenstore.py retain. - The Hermes provider (
sync_turn,on_pre_compresscheckpoint API v2) callsstore.py retainDIRECTLY with its own duplicate deadline copy; it does not import the shared seam. - The Hermes compatibility
pre_llm_callhook usessubprocess.runwith its ownZMEM_MCP_TIMEOUTclamp (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 --jsonprints{"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;manualnever raises on capture grounds — namespace validation can refuse in any mode).ingest-jsonlsummary carriesquarantined=andquarantine_failed=aftercapture_refused=;quarantine_failed>0exits 1.ingest-jsonl --stricttreats a quarantined row as a rejection (all-or-nothing stays fail-closed).import-store.pystages a sanitized copy (source read-onlymode=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 stalememory_vecembeddings dropped), verifiesPRAGMA integrity_checkandstaged_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 carriesadded,redacted,quarantined,quarantine_failed.scripts/ingest_harvest.pysubprocessesstore.py add --capture-mode auto --jsonper row (a record may carry its ownsource_ref; the CLI--source-refis the fallback), classifies each child result asstored/deduped/quarantined/failed, and reports all four counts; anyfailedexits 1.- MCP
addand Hermeszmem_addforward 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):
- Hyphenated one-dot basenames (
my-script.py,build-helper.sh,claude-code.md,app-config.json) stayhost— the one-dot host rule runs before file, and persisted kinds are sticky under first-seen-kind-wins. - The slash/doc-suffix asymmetry stands:
docs/readme.md→otherwhileREADME.md→file(the doc-suffix rule is gated on/not in the value). - Bare runner-head words (
make,go,node,python) staycommand(first whitespace token in RUNNER_HEADS). - Service nouns matching filenames stay
service:server.logandserver.pyareservice(whole-word rule);access.logremainsfile. - CamelCase service identifiers (
UserService,DatabaseServer) stayother— 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:
-
Snapshot copy first. The live store runs in WAL journal mode, so a bare
cpofstore.sqlitecan miss commits still sitting in the-walfile (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 -
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 acontent_norm), and a sortedsignal=noneupgrade action plan. -
Verified backup (
store.py backup) before any remediation, then a dry-run rekey (rekey-namespace --dry-run) if namespaces must move. -
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 livesupports/updates/extends/deriveslink, 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.pyis 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_MAPto 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=nonemeans no external grounding — the lesson is the agent's self-opinion. Never setsignal=testunless a test actually ran. - Wrap, do not replace: this skill never writes to
tasks/<slug>/*.mdorissue-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 afile: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 (envZMEM_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 reasonbelow-relevance("nothing relevant") instead ofbelow-bar("nothing trusted").recall --explainshows the numbers:found/below_limitverdicts carrydetail.lanes, the envelope reports the resolvedlane_floors, and a row that missed every pool but would ride the 1-hop link walk reports alink_expansionverdict (parent, relation, score) instead ofnot_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_scorekey).
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 orMEMORY.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) — notAGENTS.md. The SessionStart hook does not injectAGENTS.mdon 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=nativeas 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
/hooksreview (or re-record thehooks.statetrusted hashes); for vetted automation only,codex exec --dangerously-bypass-hook-trustruns 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
memory
暂无简介
memory
暂无简介
memory
暂无简介
memory
暂无简介
swarm-pr-review
Run a graph-guided, tool-augmented PR review using context packing, parallel exploration, mandatory repository-agnostic risk-family coverage with dispatch scaled to diff size and risk, independent reviewer validation, critic challenge, and metrics writeback. Use for deep pull request review with low false-positive tolerance and high recall in any repository, on any agent harness (structured lane controller, native parallel subagents, or single-context sequential passes).
swarm-pr-feedback
Ingest and resolve known pull request feedback with skeptical source verification. Use when addressing pasted PR feedback, GitHub review comments or threads, requested changes, CI/check failures, merge conflicts, stale PR branches, or PR follow-up work that must close all known issues without dropping findings. Supports multi-round bot reviews when the repo uses an auto-review bot that posts a new review after every push, via the iterative pattern documented in the body. Stage A (structural pre-checks) and Stage B (reviewer + test_engineer) gates and the reviewer + critic closeout gate are MANDATORY for any change made as part of this process.