# dsh-engram

> **[English](README.md) · [中文](README.zh.md)**

[![CI](https://github.com/skepsun/dsh-engram/actions/workflows/ci.yml/badge.svg)](https://github.com/skepsun/dsh-engram/actions/workflows/ci.yml)

Minimalist long-term memory for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), distilled from the
[symbolic-index](https://github.com/skepsun/symbolic-index) and [pi-esr](https://github.com/skepsun/pi-esr) ideas —
with one goal: **save tokens**.

- **Zero-LLM intake** — auto-captures meaningful events from tool results by pure
  pattern matching (git milestones with a written `-m` commit message, edits to
  key files, repeated errors), plus an explicit `engram_store`. Nothing on the hot
  path calls a model, and pure plumbing — `git push` / `git stash` / silent
  commits — is deliberately never recorded (see *Auto-capture policy* below).
  Every write is run through a deterministic **secret redactor** first — API
  keys, JWTs, `Bearer` tokens, private keys, AWS/Stripe/Slack/GitHub tokens and
  `key=value` secret shapes are replaced with `<REDACTED:…>` markers before the
  dedup hash, the char cap, and storage, so sensitive text can never land on
  disk. Failing **test runs** are recognized by pure patterns (`npm test`,
  `node --test`, vitest/pytest/jest, failure lines) and captured as
  `tags:["error","test"]` entries with a raised signal. DSH **goal-domain
  integration**: a completed or blocked goal (`goal/change` session event)
  auto-sediments as a `handoff`/`error` memory (`tag:goal`), so the outcome of
  a goal survives its session (see the `GET /api/dsh-engram/goals` read surface).
- **User-correction / preference learning (`autoCapturePreferences`, default on,
  zero LLM)** — the lesson that "when the AI makes a mistake and gets corrected,
  that lesson should persist" ([correx](https://github.com/ozoz5/correx),
  [PAHF](https://arxiv.org/abs/2602.16173), Mem0 preference extraction): a
  `session/event` listener watches `user/message` and turns deterministic
  correction/preference cues into a dedicated `preference` memory kind —
  Chinese (`不要用X`, `改用X`, `以后统一用X`…) and English (`don't use X`,
  `switch to X`, `prefer X`, `never use X`…) patterns, three directions
  (`avoid` / `switch-to` / `prefer`), with interrogative/one-shot-instruction/
  memory-tool-meta-talk filters to keep false positives out. `preference`
  memories outrank every other kind in the `[ENGRAM]` block (`[R]` prefix) so a
  standing user rule is the first line the model sees each session; per-session
  cap + recent-window dedup mirror the auto-capture discipline. Zero LLM, zero
  embeddings, deterministic and pure — extracted clauses are bounded, and every
  write still goes through the secret redactor.
- **Symbolic index + progressive disclosure** — a compact `[ENGRAM]` block (default
  budget 700 chars ≈ 175 tokens; one line per memory) is injected at prompt
  assembly and **frozen per session**, keeping the request prefix byte-stable for
  KV-cache reuse. The agent drills down with `engram_recall` / `engram_detail` instead
  of dumping raw hits into context; recall ranks the in-domain pool with an
  in-process BM25 pass (TF·IDF with label/phrase boosts plus a gentle time-decay
  factor — deterministic, zero deps), appends a compact entity-neighborhood
  table (reusing esr_link: `node --rel--> node · conf%`) for entity-anchored
  hits, re-warms the old `error` entry when a new failure is near-identical
  (repeat-failure revival: recency + hit climb toward promoteHits so the
  failure resurfaces instead of piling up), and on zero local hits falls back
  to DSH's own cross-session full-text index (`ctx.sessionQuery`, filtered by
  cwd) instead of building a parallel SQLite index; when the FTS fallback also
  comes back empty and the query contains **CJK** characters (Chinese FTS5
  tokenization), recall runs a bounded substring scan over the newest session
  logs (zstd-decompressed, LRU-cached) and appends matched past sessions —
  deterministic `# past sessions` lines, capped files and bytes.
- **Session-start auto-recall (`autoRecallOnStart`, default on)** — the pull-only
  design had a gap proven in real use: recall tools relied on model initiative,
  and in practice the model almost never calls `engram_recall` on its own (19
  real sessions, thousands of tool calls, ~5 recall calls total). So at the FIRST
  assembly of a new session the host runs one deterministic BM25 recall keyed on
  the session's first user message and injects the top ≤ `autoRecallLimit` hits
  as a `[RECALL]` block (default 700-char budget, "few and precise" — agentmemory
  candidate ①). Relevant memory is in context even without a single recall call.
  Pure rule, zero LLM, pure read (never bumps `hits`); superseded stale truth does
  not occupy an injection slot (still fetchable via `engram_detail`); frozen per
  session together with `[ENGRAM]` for prefix stability.
- **Read-path activation (D)** — a hit whose one-line preview is truncated
  (>140 chars) carries a `…` marker and the block's trailing guidance names that
  concrete id: `#<id> truncated — engram_detail <id> (full) · engram_recall <query>`.
  So the model knows the full record is one deterministic `engram_detail` call
  away instead of assuming the preview is the whole memory (proven gap in real
  use: 66 sessions, zero `engram_detail` calls — the detail path was invisible).
- **Memory-to-memory semantics** — `engram_store` accepts optional
  `supersedes` / `contradicts` memory ids (validated against the same
  workspace). A superseded ("stale truth") memory is demoted to the tail of
  recall and **excluded from the `[ENGRAM]` block**, while the superseding
  statement keeps the line; a contradicted memory stays ranked but is flagged
  `· contradicted by <id>`. Old rows are never deleted — re-fetchable, just
  honestly ranked. Opt-in `autoSupersede` config (default **off**) auto-marks
  entity-anchored replacement updates ("改用 / no longer / switched…") as
  superseding their matching older row; explicit `supersedes` always wins.
- **Failure→fix closure (zero LLM)** — error memories carry a `cmd:` signature
  tag; when that same command later succeeds, engram auto-sediments a
  `procedure` memory (`fixed: <cmd> — N earlier failing runs now succeed`),
  tags the earlier error rows `resolved` (never re-ranking them), and recall
  then surfaces the fix instead of the stale failure.
- **ESR-lite closure protocol** — `esr_task` / `esr_close` / `esr_link` give tasks
  a `draft → active → stable` lifecycle where `stable` requires real evidence
  (`artifact` / `evaluation` / `memory_ref`), surfacing closure gaps instead of
  letting the agent declare victory without proof. With `verifyArtifact` on
  (default), a non-URL artifact is resolved against the workspace (= the session
  cwd) and must exist on disk — otherwise the task stays ACTIVE with the reason;
  `force:true` (or disabling the toggle) skips the disk check, the three gates
  are still mandatory. The tool and the web forms share one gate
  (`store.evidenceGate`), so the two surfaces can never drift.
  Tasks are never isolated graph nodes: `esr_task(entity=…)` one-call wiring
  auto-creates the `ent_<slug>` node and hangs the task on it
  (`task --relates_to--> entity`, idempotent), so a task created with a domain
  anchor immediately shows a relation; and `esr_dep` writes the dependency edge
  into both the task's `deps` (blocker logic) and the shared links table, making
  every task dependency a first-class visible edge in the graph.
- **ESR trigger mechanism (pi-esr-aligned, hybrid: static protocol + frozen
  snapshot + monotonically accumulated actionables + pull)** — prefix-cache
  stability is the point. Teaching the model WHEN to use ESR is a **static
  methodology** section (byte-identical every turn); the `[ESR]` block is a
  deterministic **per-session frozen snapshot** (set at session start,
  deterministically sorted, timestamps excluded, explicitly "WILL NOT
  auto-refresh") **plus the session's monotonically accumulated actionables**:
  the `promote:` / `root-cause:` / `close:` / `stale:` / `escalate:` lines are
  APPENDED exactly once, the moment each matures, and stay frozen for the rest
  of the session (never removed) — so the prefix changes only when genuinely
  new decision-point info appears, never per-turn, while the timely nudges
  (narrow funnel / recurring failure / work done / stale / balance) stay in
  context. Live state is still **pulled** via `esr_status` (full deterministic
  view + `since_revision` short "unchanged" response + derived actionables).
  The `tools/result` recorder feeds the actionables' data sources; P4
  conversion metering stays (`#suggest-*` tags + 10-minute attribution +
  `GET /api/dsh-engram/triggerstats`). Pure rules, zero LLM.
- **Session-end todo auto-sink (draft safety net, `autoSinkTodosOnEnd` default
  on)** — during a session the native todo stays cheap, per-session working
  memory that dies with the session. But when a session closes at its teardown
  edge (`session/disposed`) while its plan still has pending todos, they are
  **auto-landed as ESR `draft` tasks** (name = the todo text, description
  marked 「源自会话计划」, deduped against existing task names, capped by
  `maxTasksPerWorkspace`), so the plan never silently evaporates. Sinking to
  **draft** — not active — keeps them out of the `[ESR]` active rows and creates
  no evidence obligation; an explicit `esr_claim` / `esr_task` step is still
  what promotes a draft to active. The "nothing auto-written during the
  session" trade-off is unchanged; only the end-of-session data-loss gap is
  closed. Disable via profile patch or the settings card.
- **Memory GC (pi-esr constraints)** — a scheduled, mechanical, archive-only
  sweep: TTL-expired memories are archived, over-cap workspaces evict the
  lowest-value entries, stable tasks past their retention window leave the
  `[ESR]` surface, and dangling link edges are dropped. The working set (active
  task refs, task memories, indexed hits) is never touched, and nothing is
  hard-deleted — every archived entry keeps its id and stays re-fetchable.
- **Context GC (auto-GC = replaces DSH's auto-compact)** — NOT the memory-panel
  GC. It takes over the host's `compaction` service and swaps DSH's lossy
  LLM-summary compression for **mechanical eviction + re-fetch pointers**:
  evicted turns are scanned for engram/ESR anchors (`#memory-id`, `tsk_*`,
  `ent_*`, `file_path`) and replaced with one line "this detail lives at
  `engram_detail` / `engram_recall` / the [ESR] block"; only un-provenanced
  turns get a scoped LLM narrative (`gcNarrative`, default on, off = fully
  mechanical). All six GC constraints hold (working-set-protected,
  pointer-salience, no-provenance-no-evict…); any error falls back to the
  default compressor — compaction is never broken.
- **Web viewer** — a memory browser with benchmark-ish stats and a config card,
  built entirely on DSH's native settings slots (no third-party UI package).

```
MIT   ·   node >= 22.19   ·   host-half + browser-half in one package
```

## Why another memory plugin?

Surveys of the existing DSH plugin ecosystem show the recall-bridge, approval-gate,
LLM-distillation and vector/graph niches are already crowded. dsh-engram fills the
three gaps that matter for token discipline:

1. **No model in the write path** — capture is deterministic pattern matching.
2. **No raw text in the prompt** — a bounded symbolic index is injected, plus the
   top ≤ N memories auto-recalled for the session's first message (within budget);
   deeper retrieval stays on demand ("retrieved ≠ full-text injected").
3. **Honest task closure** — STABLE cannot be declared without evidence.

DSH already provides cross-session FTS (`ctx.sessionQuery`), storage
(`ctx.storageDomain`), prompt-injection hooks and settings slots; dsh-engram is a
thin composition layer over them, not a re-implementation.

## Security model

The DSH plugin ecosystem is young and unvetted — there is no official directory,
no signature check, and the harness's read/write permission tiers do **not**
constrain what a plugin's own code can do. So the trust bar for a memory plugin
is higher than for a tool: a memory plugin sits on your prompt prefix and writes
to disk on your behalf. dsh-engram's model, stated plainly:

- **No network, ever.** The plugin has no outbound socket, no telemetry, no
  update pinger. Nothing here can leak your sessions off the machine.
- **No shell, no filesystem access beyond one storage file.** It never executes
  commands and never touches arbitrary paths. All data lives in the single
  storage-domain unit `~/.dsh/storages/dsh_engram.json` (plus the session-log
  reads it makes through DSH's own `ctx.sessionQuery`).
- **Secrets are redacted before they can land on disk.** Every write path —
  `engram_store`, auto-capture, and the import/restore route — runs text through
  the deterministic `redactText` rule engine (API keys, JWTs, `Bearer` tokens,
  private keys, `key=value` secret shapes) *before* the dedup hash, the char
  cap, and storage. Nothing sensitive is ever persisted, so nothing sensitive
  can be recalled later.
- **Doing harm requires your harness's own permissions.** The plugin only *asks*
  the host to do things via the same seams the tools you already trust use. It
  does not widen sandbox policy or escalate to `danger-full-access` — if you
  keep your profile's defaults, engram inherits exactly those bounds.
- **Read-mostly web API, loopback-fenced.** The GUI routes under
  `/api/dsh-engram` reject any non-loopback caller unless you explicitly
  whitelist a hostname via `trustedHosts`; even then the same-origin fence holds.
- **Verifiable.** `npm run dsh-engram -- doctor` (`/scripts/dsh-engram.mjs
  doctor`) reports what the plugin is configured to touch; the whole store is
  inspectable, nothing is opaque. If you ever doubt a claim in this section, the
  code to check is `lib/redact.js`, `lib/store.js` and this plugin's
  `cordis.patch.yml`.

What this deliberately does **not** claim: it is not an air-gap. If your profile
grants the agent `danger-full-access` or a shell, the agent can use those —
engram is a memory layer, not a sandbox. The point above is that *dsh-engram
itself* adds no surface of its own.

## Data contract: export / import

Memory should not be a hostage. The four tables (memories / tasks / links /
entities) can be dumped and restored through a stable, versioned, machine-readable
shape — so the corpus survives a reinstall, a machine move, or (when a thin
adapter is written) a move to another harness, MCP end-point or Claude Code
skill. The contract is plain JSON with a self-describing header:

```http
GET  /api/dsh-engram/export?workspace=/path/to/project   # one workspace
GET  /api/dsh-engram/export                              # every workspace
```

```jsonc
{
  "meta": { "format": "dsh-engram/export", "version": 1, "exportedAt": 1724…, "workspace": "/path/to/project" },
  "memories": [ /* all rows, archived included — a backup never loses provenance */ ],
  "tasks":    [ /* draft/active/stable + archived */ ],
  "links":    [ /* typed edges */ ],
  "entities": [ /* graph nodes */ ]
}
```

Restore is idempotent by default and gated when destructive:

```http
POST /api/dsh-engram/import          # body: { payload, mode, dryRun?, workspace?, confirm? }
```

- **`mode: "merge"`** (default) — writes only rows whose `id` is absent, so a
  backup can be re-applied any number of times without duplicating anything. Each
  row keeps its own `workspace`.
- **`mode: "replace"`** — wipes the target `workspace` from all four tables, then
  restores only its rows. Destructive, so it additionally requires
  `confirm: "restore"` in the body.
- **`dryRun: true`** — computes the identical plan (what would be written /
  skipped) without writing a single row.
- **Import honors the same invariants as `storeMemory`**: memory text passes the
  secret redactor before touching disk, and rows that would break the contract
  (missing id/workspace, empty text, over `maxMemoryChars`, id already present
  in merge, over the workspace memory cap) are skipped and listed in the
  response report instead of aborting the batch.

A restore round-trip for one workspace is then:

```sh
curl -s "http://127.0.0.1:3080/api/dsh-engram/export?workspace=$PWD" -o engram-backup.json
curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
  -H "content-type: application/json" \
  -d "{\"payload\": $(cat engram-backup.json), \"mode\": \"merge\"}"
```

## Compatibility

<!-- dsh-market compatibility block — keep labels bilingual and in sync with
     docs/RELEASE-NOTES.TEMPLATE.md (market README convention) -->

- **DSH 版本 / DSH versions**: `>=0.1.2-alpha.2 <0.3.0-0` (本版 `0.9.0`; 已发布的 `0.3.7`、`0.4.0` 仍声明旧的 `0.1.x` 范围 `>=0.1.2-alpha.2 <0.2.0-0` — 见下方矩阵)
- **最后验证日期 / Last verified**: 2026-10-27 — DSH `0.2.1-rc.2` (`dsh --version`), web profile live composition; npm test 483/483
- **安装方式已验证 / Verified install**: `dsh plugin --profile web add dsh-engram`

> **Short version.** Every **published** release (`0.2.0` … `0.3.6`) supports
> the **old** DSH API graph (`0.1.0-rc.7` / `0.1.1`, peers `^0.1.0-rc.7`)
> **only**. DSH `0.1.2-alpha.2` and newer are **not** compatible with those
> releases — the settings/client API family broke there. New-DSH support
> **starts with `0.3.7`**, the first release of the new API generation
> (described below).

| dsh-engram release | Status | DSH support | Declared via |
| --- | --- | --- | --- |
| `0.2.0` … `0.3.6` (npm, GitHub v0.3.3–v0.3.6) | published | **old DSH only** — `0.1.0-rc.7` / `0.1.0-rc.8` / `0.1.1-rc.1` / `0.1.1-rc.2` | peers `@deepseek-ai/dsh-*@^0.1.0-rc.7` |
| `0.3.7` / `0.4.0` (npm) | published | **new DSH, settings `installSection`** — `>=0.1.2-alpha.2 <0.2.0-0` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.2.0-0` |
| `0.5.0` (npm) | published | **DSH `0.1.2-alpha.2` … `<0.3.0-0`** — 设置卡迁移到 entry-id volatile Config（`0.1.7+`/`0.2.x`），`≤0.1.5` 自动回退 `installSection` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.3.0-0` |
| `0.5.1` | published | 同 `0.5.0` — 会话扫描修复（`session.v<N>.jsonl.zstd`，issue #8）、数据导出/导入 GUI、替换/冲突可视化（issue #3） | 同 `0.5.0` |
| `0.6.0` | published | 同 `0.5.0` — Context Ledger、BoundaryPrune/ResultPack 默认开 + 账本否决、JIT 推送、Context GC 改 opt-in | 同 `0.5.0` |
| `0.7.0` | published | 同 `0.5.0` — session episode、resume brief、entity pages/cards、revalidatable anchors + layered TTL | 同 `0.5.0` |
| `0.7.1` (npm) | published | 同 `0.5.0` — web-plane Context GC on DSH `0.2.0` declarative presets (14-space rows), bundle patch re-enables the host `tool-result-pruner`, compaction savings feed the Context Ledger | 同 `0.5.0` |
| `0.7.2` (npm) | published | 同 `0.5.0` — L1 boundary prune arms through `ctx.inject`, fixing the boot-ordering race where a pruner activating one tick after the plugin stayed dormant for the whole process | 同 `0.5.0` |
| `0.7.3` | 未发布 | 设置卡迁到「Engram 记忆 → 设置」tab（DSH `0.2.1` 删了旧 section）——随 `0.7.4` 一起发布 | 同 `0.5.0` |
| `0.7.4` (npm) | published | 同 `0.5.0` — 修「推送腿」在 DSH `0.2.x` 上整条静默失效（`[RECALL]` 启动召回、JIT 推送） | 同 `0.5.0` |
| `0.7.5`–`0.7.9` | 本地 | 同 `0.5.0` — 账本按会话落行 + `gate-audit`；L1 三个真机 bug：还款闸门工作区键（`Users-x-proj` vs `/Users/x/proj`）、`session.append` 重入、归因不对称；L1 从「自己剪」转成「记账 + 归因」 | 同 `0.5.0` |
| `0.7.10`–`0.7.12` | 本地 | 同 `0.5.0` — 归因对称（宿主剪枝不进我们的 rewrite 账）、`boundaryPrune` 默认关闭改 opt-in、`status` 打印「账本两条腿」让开关判断可复核 | 同 `0.5.0` |
| `0.7.13` | 本地 | 同 `0.5.0` — 压缩入账读真实事件形状（`event.data`，此前 82 个 GC checkpoint 全部未入账 + 29.6M 字符）、会话计数重启不再清零（delta 累加）、CLI 尊重 `DSH_HOME` | 同 `0.5.0` |
| `0.8.0`–`0.8.2` | 本地 | 同 `0.5.0` — 证据化 procedure（v0.8-A）、导出为 DSH Skill（v0.8-B）、subagent 交接胶囊（v0.8-C） | 同 `0.5.0` |
| `0.8.3`–`0.8.4` | 本地 | 同 `0.5.0` — 审批门（v0.8-D）+ 其 GUI；G6 门槛改单边（Δ ≥ −2pp）+ 隐藏规格语料 | 同 `0.5.0` |
| `0.8.5` | 本地 | 同 `0.5.0` — 记忆行文件信号（`⚠file-changed` / `⚠file-missing`），G6 第三/四轮（撤回 +30pp，Δ=+58.3pp） | 同 `0.5.0` |
| `0.8.6` (npm) | published | 同 `0.5.0` — 修 issue #9：宿主没有 `sessionQuery`/`snapshotEvents` 时退回 `seq`+`eventAt` 逐条读，四项注入特性不再静默休眠；warn 报出宿主 surface；G6 第五轮 Δ=+90.0pp | 同 `0.5.0` |
| `0.8.7` (npm) | published | 同 `0.5.0` — 推送腿落盘（`$DSH_HOME/engram/push-legs.jsonl`）+ `status` 打印事件日志读法；`minimal` 预设不再承诺永远不会发生的自动接管 | 同 `0.5.0` |
| `0.8.8` | 本地 | 同 `0.5.0` — 修推送腿遥测写错目录（`engram/engram/`）导致状态显示「还没记录」；`pushLegsPath` 契约写死在一处 | 同 `0.5.0` |
| `0.9.0` (本期 / this release) | 本地（tag，未发 npm） | 同 `0.5.0` — 产品代码仅一条 CLI 修复（`--presets-dir` 优先）；主体是评测：G6 网络围栏（per-uid 出网白名单 + 两条前置探针）、密钥语料第 4 条自证、污染三分类与重算、G7 实测 0.2956%。**不是 1.0**：宿主仍 rc、G1 未测、减法债未还 | 同 `0.5.0` |

### Where the API family broke — `0.1.2-alpha.2`

`0.1.2-alpha.2` is the first version of the new API family and the first that
**breaks** the released plugin versions:

- `@deepseek-ai/dsh-settings` **removed** `settingsNamespace()` and
  `installSettingsSection()` — the exact imports every release ≤ `0.3.6` uses —
  and replaced them with `settings.installSection`. The old host half fails to
  load on `0.1.2-alpha.2+`.
- The web client half swapped its inject set: `@deepseek-ai/dsh-client-runtime`
  no longer exists in the new family (replaced by `dsh-api-remotes` plus
  `dsh-client-locale` / `-conversation` / `-renderer` / `-ui-settings-plugins`),
  and the settings card used to talk through `ctx.remote.settings`.
- Supporting bumps at the same boundary: `@deepseek-ai/cordis` `^4.0.1 → ^4.0.2`,
  `@deepseek-ai/schemastery` `^3.18.1 → ^3.18.2`, and `@deepseek-ai/dsh` stopped
  shipping `config/agent-presets` in the package (moved to `dsh.configTrees`).

The web config card no longer depends on any of this: since `0.3.7` it reads
and writes the `dsh-engram` settings namespace through the plugin's **own**
HTTP route (`/api/dsh-engram/settings`, same-origin fetch like the memory
viewer), so the card is generation-agnostic on both sides of the
`0.1.2-alpha.2` boundary.

**One more settings break, at `0.1.7` / `0.2.0`** — and the reason `0.4.0` was
not actually loadable on the current DSH: `@deepseek-ai/dsh-settings` **removed
`settings.installSection` again** in `0.1.7-alpha.2` (and keeps it removed in
`0.2.x`). The new settings model is **entry-keyed**: editable fields are
`.volatile()` fields of the plugin's own `Config` (validated + hot-committed by
the loader), and the settings form is keyed by the profile entry id (`engram`),
so nothing needs "mounting". `0.5.0` feature-detects the two generations in
`installEngramSettings`:

- DSH `≥0.1.7` / `0.2.x` — the 34 GUI knobs are the volatile `Config` fields
  (`lib/settings.js` `makeEngineConfigSchema`, exported as `Config` from
  `lib/index.js`); the card reads/writes entry `engram` through the same
  `/api/dsh-engram/settings` HTTP route, and the `service.settings` seam
  re-syncs `resolved` on `loader/volatile-update` so card edits land live on
  new sessions.
- DSH `≤0.1.5` — the legacy `settings.installSection` namespace (`dsh-engram`)
  is registered as before; nothing changes.

The declared range `>=0.1.2-alpha.2 <0.3.0-0` covers both, and the HTTP wire
namespace stays `dsh-engram` on both generations (the seam's active namespace
drives mutations).

So: keep DSH on `0.1.1-rc.2` or older while running a release ≤ `0.3.6`, and
upgrade the plugin to `>=0.3.7` before moving DSH to `0.1.2-alpha.2`.

### Current release — `0.7.2`

**L1 boundary prune was silently dormant on some profiles** — a boot-ordering
race, not a missing patch.

`BoundaryPrune` resolved DSH's `toolResultPruner` with a strict
`ctx.get("toolResultPruner")` at install time. On DSH `0.2.0` web profiles the
pruner's fiber activates **~100 ms after** the plugin (measured on the live
composition `dsh-context` + `dshmarket` + `dsh-engram`: `t0 strict=undefined /
loose=object`, strict resolves 100 ms later), so the strict lookup was empty,
`installBoundaryPrune` gave up, and L1 never armed for the life of the process —
taking the ledger's only prune-credit leg with it. The status snapshot also
reported that race as "row missing", pointing at the wrong fix.

- **Arm through `ctx.inject(["toolResultPruner"], …)`** — cordis runs the
  callback the moment the service becomes active (and again after a reload), so
  arming no longer depends on composition order; the durable status snapshot is
  rewritten as soon as L1 is armed.
- **"Absent" and "late" now read differently** — `boundaryPruneAvailability`
  falls back to a non-strict lookup: a disabled/missing row still says
  `no toolResultPruner service … (the bundle patch must re-enable …)`, a
  registered-but-inactive one says `registered but its fiber is not active yet`.
- **Verified** — the live bundle composition (with `dsh-context`) reproduces
  `armed:false` in an isolated `DSH_HOME`; after the fix the same composition
  reports `armed:true · threshold 8192`. `npm test` 406/406.

### Previous release — `0.7.1`

Web-plane Context GC on DSH `0.2.0`'s **declarative presets** — the preset
format changed shape (one `@deepseek-ai/dsh-agent-preset` declaration inside
`dsh-web-app/presets/*.patch.yml`), and the auto-wiring was anchored to the old
`agent.cordis.yml` layout, so `autoWebCompaction` silently skipped every preset
(and could half-rewrite a stock row: the pattern did not include its trailing
newline).

- **Structural row location** — the `compaction` group is found by YAML
  structure (a list item carrying `group: true`), not by column position; the
  stock/engram rows are validated by BOTH their `- id:` and `name:` values, and
  the replacement inherits the row's own indent. Legacy `agent.cordis.yml`
  (4 spaces) and declarative patch files (14/16/18 spaces) both round-trip
  byte-identically.
- **Declarative discovery** — the preset roster no longer carries `path` on
  DSH `0.2.0`; the plugin maps each roster id to the shipped
  `<presetsDir>/<id>.patch.yml` (resolved through the `dsh` install, with
  `DSH_WEB_PRESETS_DIR` override) and skips user-defined declarative presets
  (profile-patch overrides) with an explicit log line instead of failing.
- **Status/doctor** — `npx dsh-engram status`/`doctor` discover both preset
  generations, show the detected layout, and flag `autoWebCompaction=false`
  (the opt-in toggle that makes the web plane a no-op), node_modules preset
  wiring (survives `npm update dsh` only while auto-wiring stays on), and
  profile-patch `preset-*` overrides that outrank the shipped files.
- **Migration** — restart `dsh web` with `autoWebCompaction:true`: the next boot
  re-wires every stock declarative preset (idempotent refresh; existing
  hand-wired rows are rewritten to the requested engine config). `npm test`
  396/396.

### Previous release — `0.7.0`

From event records to **maintainable current truth**: sessions sediment an
episode at teardown, new sessions get a resume brief, `esr_node` becomes a
readable page, and file anchors make stale truth detectable.

- **Session episode** — zero-LLM digest at `session/disposed` (files / commands /
  failures→fixed / decisions / open loops), never indexed, newest 20 per
  workspace kept.
- **Resume brief** — the newest episode from the last 7 days is injected as a
  `[RESUME]` line (≤240 chars + drill pointer) at session start.
- **Entity pages + cards** — `truth / timeline / open loops / conflicts /
  relations`; up to 2 one-line cards in the index, full page via
  `engram_detail(entity=…)`.
- **Revalidatable anchors** — file-backed memories record `mtime/size/sha256`;
  a bounded revalidation at session start tags changed/missing sources `stale`
  (dropped from index/truth, still re-fetchable).
- **Layered TTL** — preference/procedure permanent, episode 14d, error 60d,
  others 180d.
- **Migration** — only new writes are anchored (no historical backfill);
  restart `dsh web` after upgrading; an episode appears after one session ends,
  the resume brief on the next new session. The `0.6.0` ledger / defaults / JIT
  behaviour is unchanged.

The published `0.4.0` declared `>=0.1.2-alpha.2 <0.2.0-0` — which the semver
gate **rejects on every DSH `0.2.0-0` prerelease** (`<0.2.0-0` excludes
`0.2.0-rc.x`), and its host settings half still called the `installSection`
API that `dsh-settings 0.1.7+` removed. `0.5.0` fixes **both**: the range
opens to `<0.3.0-0` (verified against DSH's own `includePrerelease` check, so
`0.2.0-rc.2` and the whole `0.2.x` line pass), and the settings card is
migrated onto the entry-id **volatile Config** model described above (with a
feature-detected legacy fallback).

| Component | Supported range |
| --- | --- |
| DSH runtime / host / client package family | `>=0.1.2-alpha.2 <0.3.0-0` |
| `@deepseek-ai/cordis` | `^4.0.2` |
| `@deepseek-ai/schemastery` | `^3.18.2` |
| Node.js | `>=22.19.0` |

`0.1.2-alpha.2` is the minimum version for this API family: host settings are
**feature-detected** — on DSH `≤0.1.5` they register the `dsh-engram`
namespace via `settings.installSection`; on `≥0.1.7` / `0.2.x` the form is the
plugin's `Config` with the 34 GUI knobs marked `.volatile()` (entry id
`engram`). The browser settings card reads and writes through the plugin's own
HTTP route (`/api/dsh-engram/settings`, see Route B above) on both. The full
regression suite (npm test 396/396, incl. the new settings-v2 coverage for the
volatile-Config model) passes against the DSH `0.2.0-rc.2` dependency set
(cordis 4.0.4 + schemastery 3.18.4 + cosmokit, matching the global DSH CLI).
The `.volatile()`/loader hot-commit path and the describe-side projection were
verified against the real `dsh-settings 0.2.0-rc.2` utilities, and a full
real-machine boot smoke on an isolated `dsh 0.2.0-rc.2` profile **passed**:
the plugin boots with no version-range denial, the settings card form is
served for entry `engram` (all 27 GUI knobs, incl. `trustedHosts`), and a
GET → PUT (`mutate` ops, revision 0→1) → unset round trip on
`/api/dsh-engram/settings` lands in the profile row and reads back. The older
`0.1.0-rc.7` and `0.1.1` API graphs are outside this release's compatibility
range.

To install the DSH version used for validation:

```sh
npm install --global @deepseek-ai/dsh@0.2.0-rc.2
dsh --version
```

## Install

```sh
# from GitHub (this repo)
dsh plugin --profile web add github:skepsun/dsh-engram

# once published to npm
dsh plugin --profile web add dsh-engram

# local development (symlink — edits apply immediately)
dsh plugin --profile web add link:/path/to/dsh-engram
```

Then **restart `dsh web`**. Data persists in `~/.dsh/storages/dsh_engram.json`.

> The npm and GitHub installs below need **no manual dependency step**: pnpm
> installs `zod` and vendors the optional `@deepseek-ai/*` peers into the
> plugin's own `node_modules`, and the CLI auto-registers the plugin into the
> profile's `dsh.profile.bundles`. The `setup-links` step below is only for
> the `link:` development workflow, where pnpm deliberately does not install a
> symlinked directory's dependencies.

> A fresh session is required to see the injected `[ENGRAM]`/`[ESR]` blocks and the
> tools; both prompts and the tools registry are assembled per session.

### Dependencies for `link:` installs

A *symlinked* plugin resolves its imports from its own `node_modules`, so the
host-side dependencies must be present **next to the checkout** — they are not
tracked by git:

```sh
cd /path/to/dsh-engram
node scripts/setup-links.mjs     # one command: links the @deepseek-ai
                                 # workspace packages into node_modules AND
                                 # installs zod (reused from the harness
                                 # pnpm store, or via `npm install`)
```

The script auto-locates the harness checkout at `../deepseek-harness` (also
works when it sits next to the repo's *parent*, e.g. `E:\deepseek-harness` +
`E:\kototoro_demo\dsh-engram`); override with `DSH_HARNESS_DIR`. Without this
step, `dsh web` boot fails with `ERR_MODULE_NOT_FOUND: Cannot find package
'zod'` (and would fail on the `@deepseek-ai/*` peers next). `node
scripts/setup-links.mjs --check` prints the state without writing anything.

## What you get in the GUI

After restart, inside the **native** DSH settings surface:

- **Sidebar "ESR 看板" entry + full-screen kanban** — one more row under
  New Session with a **live active-task badge** (polled from /overview every
  30s across all workspaces). Clicking it opens a full-screen board in the
  center column: **草稿 / 进行中(gaps) / 就绪(evidence ready) / 已闭环** columns,
  workspace filter + search, an inline create form, and per-card
  "补齐证据 → 关闭" closure forms sharing the esr_close gates (artifact +
  evaluation + memory_refs). A **看板 / 图谱 (board/graph)** toggle in the
  header reuses the full relation graph (esr_node/esr_link force-directed SVG:
  entity circles + task check badges, drag/zoom/select for relation details),
  following the workspace filter and refreshed by the same 20s polling.
  Following the task-board precedent, the entry and
  the board are DOM-mounted and self-heal (MutationObserver re-inserts on shell
  re-renders), with cross-panel exclusivity against task-board/ssh (opening one
  evicts the others; clicking a sidebar session/workspace row hands the center
  column back to the conversation). The conversation subtree stays mounted
  underneath and is hidden by `html[data-dsh-engram-board-active]`, so toggling loses
  no state.


- **Unified task strip above the composer** — the conversation dock that DSH
  ships for its built-in todo tool is taken over (same `conversation.input.dock`
  cell / `id: todo` at a lower priority) and **merged** into one modern control:
  the session's current plan (`todo_write`'s `todos` projection) plus the
  workspace's persistent **ESR tasks** (with evidence-gap badges and an inline
  "补齐证据 → close" form) plus the **relation graph** rendered as
  node → relation → node chips with entity/task names resolved. It only shows
  while there is something to show, stays live with 15s polling, and the built-in
  plan still renders (without the ESR parts) if the loopback-fenced API is
  unreachable. A **workspace-switcher chip** leads the strip: it defaults to
  following the current session (the tooltip says so), and its dropdown pins the
  ESR task/relation source to any workspace (✓ marks the active pin; the × or
  "follow session" entry reverts). Switching refetches immediately and is a pure
  UI focus change — the model's session context and the per-session frozen
  injection blocks are untouched.

- **Settings → Engram Memory** — a standalone first-class settings section
  (right after the Plugins section, not a child tab of it). Default
  "All workspaces" view shows every workspace's memories/tasks/links
  grouped by workspace (dropdown + prev/next workspace pager; the memory
  table additionally pages 10 rows per page with a jump dropdown, fixed
  column widths, 3-line clamped content ellipsis and full text on hover).
  Overview
  stat cards (counts by workspace/kind, auto-capture totals, per-workspace
  `[ENGRAM]` index token estimate, cumulative GC totals), a searchable /
  filterable memory table with archive + delete actions, an ESR task board
  with an inline "new task" form and a per-task "fill evidence to close"
  (artifact / evaluation / memory_ref → STABLE, same gates as esr_close),
  a node + relation list (nodes are domain objects the model registers via
  esr_node — package/service/repo/concept; relations via esr_link), a separate
  **relation-graph** tab (hand-rolled force-directed SVG, no chart library so
  the bundle stays pure: entities as circles, tasks as check badges, relations
  colored per type with direction arrows; drag nodes, pan, wheel-zoom, re-layout,
  hover highlights the neighborhood, clicking a node pops a floating panel with
  its incident relations and linked objects; dangling links are counted and
  warned about), an **injection-preview** tab that renders the exact
  `[ENGRAM]` index block (order 40) and `[ESR]` task/closure block (order 41)
  the model sees each session — same pure functions as the system-prompt
  sections — as two terminal-style panes with per-line coloring (block
  headers, task lines, drill hint, and the data-driven `escalate:` reminder
  highlighted), line/char/~token cost chips plus memory/task/link/node count
  chips, 20s auto-refresh and one-click copy of the raw block text (backed by
  the new `GET /api/dsh-engram/preview?workspace=…` route). Every ESR task
  card (board and ESR tab) carries an **evidence-progress ring** — a small
  three-arc SVG donut mapping artifact · evaluation · memory_ref, all green
  when closure-ready, amber while gapped, gray with no evidence yet; the
  board header adds an **aggregate ring** showing overall evidence
  completeness (%) plus how many in-progress tasks are closure-ready. Pure
  SVG, no chart library, bundle stays clean. A **telemetry dashboard** tab
  turns the /stats usage rollup (workspace × day) into a pure-SVG dashboard:
  three gauges for **ESR proactivity** (benchmarked against the 0.34 escalate
  threshold, amber + hint when low), **recall hit rate** and **detail
  follow-through**, five stat cards (total / esr / memory calls, avg hits per
  query, failures), a 14-day mem-vs-esr stacked bar chart and a Top-8 tool
  breakdown (mem blue / esr purple), 20s auto-refresh with an automatic
  small-sample (<10 calls) warning. A **details sidebar** (master–detail)
  opens on the right when you click any task / memory / node / relation
  row: task cards show state badge, evidence ring, full id + timeline,
  gap list, clickable memory references and an inline "fill evidence to
  close" form that refreshes the lists on success; memory cards show full
  text, tags, signal/hits/TTL and provenance metadata; node cards list all
  incident relations (typed, colored, with direction + confidence); clicking
  a task's memory reference jumps straight to that memory, with a hint when
  it is not in the loaded set. And a
  memory-GC panel (dry-run toggle + run button + pointer report). The GUI
  create/close use the host's new `POST /api/dsh-engram/tasks` and
  `POST /api/dsh-engram/tasks/close` routes. Model-side proactivity is driven
  by the [ENGRAM]/[ESR] injected blocks: multi-step work gets a task now,
  recurring domain objects get a node, related tasks/nodes get a link.

  **Real behaviour telemetry (agent observability)** — the ESR page opens
  with an "agent behaviour" panel fed by a new `usage` table (per workspace
  × day rollup) + `GET /api/dsh-engram/stats`: every `engram_*`/`esr_*` tool call
  is recorded (counts, failures, recall mechanics). Reported ratios:
  **ESR proactivity** = esr calls / (memory + esr calls); **recall hit rate**
  = recalls returning ≥1 hit / total recalls; **mean hits per query**;
  **detail conversion** = a engram_detail following a hit recall within 8
  session events. Per-tool counts + a 14-day daily rollup are shown too.
  Numbers are real, from real sessions — lift ESR proactivity by watching
  this panel and tuning the injected prompt.
- **Settings → Engram Memory → 设置 tab** — the config card, expanded by default
  (DSH 0.2.1 dropped the `settings.plugin.item` slot, so the old
  Settings → Plugins → Plugin configuration seat no longer renders). Same card,
  same transport: title + one-line description + chevron, its ~12 options under
  four groups — Capture & Search / Index / Lifecycle & GC / Security. Changes
  apply to new sessions (frozen blocks stay stable); Discard / Save with an
  "unsaved" badge on the header. It drives the namespace through the
  plugin's own `/api/dsh-engram/settings` route (not the isLoopback-gated
  scope), so it stays editable even through an operator-authorized tunnel.
- **Settings → Plugins → Plugin configuration → dsh-engram** (DSH ≤ 0.2.0 only,
  legacy `settings.plugin.item` seat) — a collapsible
  config card in the same style as the built-in "Shell / Agent loop / Web
  search" cards: title + one-line description + chevron, collapsed by default,
  click to expand/collapse. Open, its ~12 options render under four groups —
  Capture & Search / Index / Lifecycle & GC / Security. Changes apply to new
  sessions (frozen blocks stay stable); Discard / Save with an "unsaved" badge
  on the header. The card drives the namespace through the connection's own
  settings RPCs (not the isLoopback-gated scope), so it stays editable even
  when the GUI is reached through an operator-authorized tunnel.

The browser half is served by DSH's client-module loader directly from this
package (`dsh.client` + `exports["./client"]`, no web-application rebuild); the
data comes from the loopback-fenced `/api/dsh-engram/*` route family. The fence
stays closed by default; to reach the memory viewer from an authorized tunnel
hostname, list it in the plugin's `trustedHosts` config (e.g. via the registry
or a profile patch):

```jsonc
// patch/engram.json
{ "engram": { "trustedHosts": ["cream-club-fragrances-caught.trycloudflare.com"] } }
```

If you change `client/src`, rebuild the bundle with:

```sh
npm run build:client
```

## Tools

| Tool | Purpose | Kind |
|---|---|---|
| `engram_store` | Explicitly store one memory (kind, tags, optional entity anchor, optional supersedes/contradicts ids, optional `source`/`conditions`) | write |
| `engram_recall` | Deterministic keyword recall over workspace memories; optional `search_sessions` FTS over past sessions; opt-in `scope=global`/`recallScope` cross-workspace recall with `[W:<ws>]` origin markers | read |
| `engram_detail` | Full record of one memory id (provenance, tags, hits) | read |
| `esr_task` | Create a task entity (draft → active) | write |
| `esr_close` | Close a task via the evidence protocol (artifact + evaluation + memory_ref) | write |
| `esr_link` | Add a typed relation between two entities (mini graph) | write |
| `esr_dep` | Add a dependency edge (blocks / relates-to / parent-of) between tasks | write |
| `esr_claim` | Atomically claim a task (assignee + claimedAt, draft → active) | write |
| `esr_unclaim` | Release a claimed task's assignee | write |
| `esr_ready` | List claimable tasks (no open blocker, unclaimed) | read |
| `esr_status` | Pull live ESR state + derived hints (`since_revision` short "unchanged" response) | read |
| `esr_node` | Create/update an entity node (stable symbol) | write |
| `esr_gc` | Run the memory GC for the workspace (`dry_run:true` previews) | write |
| `esr_model` | Precomputed mental model of the workspace (`brief`/`full`, `max_chars`) | read |

The `[ESR]` block is a frozen per-session snapshot and never auto-refreshes —
call `esr_status` for the live truth.

## GC: two planes — memory-panel reclamation + Context GC (replacing auto-compact)

### Memory-panel reclamation (store maintenance)

A scheduled sweep (`gcIntervalHours`, default 24h) plus a manual `esr_gc` /
GUI button keeps the store bounded the pi-esr way — **mechanical, working-set
protected, archive-only**:

- TTL-expired memories are archived (soft; the id stays, retrievable via the
  GUI's archived filter);
- over-cap workspaces evict the lowest-value *non-protected* memories;
- stable tasks past `gcStableRetentionDays` become archived and leave `[ESR]`;
- links whose **both** endpoints are gone are dropped (dangling edges).

GC never touches the working set: memories referenced by an active task
(`memory_refs`), task-kind memories, and already-indexed hits
(`hits >= promoteHits`). Run `esr_gc` with `dry_run: true` to preview. Nothing
is hard-deleted — the report ends with re-fetch pointers for everything it
archived, so archives are recoverable, not lost.

### Context GC (replaces DSH's auto-compact)

DSH's default context compression is a **lossy LLM full summary**
(`compaction-basic`): evicted history is crushed into prose — un-queryable, and
the summary itself burns tokens. dsh-engram's auto-GC replaces it:
`ContextGcEngine extends BasicCompactionEngine` overrides the single
`summarize()` hook and swaps the summary body for:

1. **Scan** — the evicted messages are scanned for provenance anchors: memory
   ids echoed by `engram_store`/`engram_recall`/`engram_detail`, `tsk_*`/`ent_*`
   touched by `esr_*`, and `file_path` anchors (`esr_gc` and other management
   calls are NOT anchors);
2. **Pointer summary** — every evicted category carries an explicit re-fetch
   call (`engram_detail(id: "…")` / `engram_recall(query)` / the [ESR] block /
   `esr_ready`); the active working set is restated, never evicted;
3. **Narrative safety net** — only un-provenanced turns (pure chat / reasoning)
   get a scoped LLM summary (`gcNarrative`, default on; off = the whole path is
   zero-LLM and un-provenanced turns are kept as truncated verbatim).

Triggers follow DSH's own compaction timing (step pressure / context-overflow /
`/compact`); the lock, replay validation, tool-call/result pairing and token
pricing are all reused from the basic engine. Any error falls back to the
default compressor — compaction is never broken; with `gcReplacesCompaction:
false` (the default since v0.6, or a host without `dsh-compaction-basic`) a bare
`BasicCompactionEngine` is mounted instead, so the `compaction` service never
disappears while dsh-engram is installed. Mounting registers the `compaction`
service on the plugin's fiber, so unloading/reloading engram restores the
default engine. **Taking over compaction is opt-in**: turn on
`gcReplacesCompaction` (and `autoWebCompaction` for web presets).

> **Shrink gate**: the harness refuses any checkpoint that is not strictly
> smaller than the evicted span (`summary is not smaller than the shadowed
> content`) and rolls the compaction back — otherwise the context never shrinks
> and the session eventually hits the model-side overflow. Context GC
> self-predicts that gate: it mirrors the harness framing with the host
> `tokenMeter` (verified token-for-token identical) and truncates the
> pointer/narrative body to the evicted span's token budget (the pointer head
> and working set survive; the trimmed tail stays recoverable in the session
> log), so compactions actually commit. Full from-scratch usage:
> [`docs/CONTEXT-GC-GUIDE.zh.md`](docs/CONTEXT-GC-GUIDE.zh.md).

#### Where it is enabled (the entry point)

Assembly happens on **two planes**, but both switches are opt-in (no takeover by
default):

- **Host plane (headless / TUI / base profiles)**:
  the main plugin (`lib/index.js`) mounts the engine inside `ctx.effect` via
  `mountCompactionEngine(ctx, resolved, { readWorkspace }) → new Engine()`,
  registering the `compaction` service on its own fiber; the plugin patch
  disables the base `compaction-basic` row, making engram the sole host-plane
  provider. `gcReplacesCompaction: false` (default) → a bare
  `BasicCompactionEngine` (DSH's own summarizer); `true` → `ContextGcEngine`
  (pointer summaries). Enable via profile patch
  (`config: { gcReplacesCompaction: true }`) or the settings card.
- **Preset plane (web profile) — covers every stock preset once enabled**: web keeps compaction inside each agent preset's isolated realm
  (the shipped presets mount their own `compaction-basic`), unreachable from a
  host-plane row — and unreachable from a profile patch too (`mountPreset`
  builds its `Include.Config` with no patches; verified against the harness
  source). So on boot, once the `agentPresets` service is ready, the plugin
  **auto-edits every preset that still has the stock layout** — the default
  preset plus the whole roster, across BOTH preset generations: legacy
  `<root>/<id>/agent.cordis.yml` compositions (DSH ≤ 0.1.x, row at 4 spaces)
  and the declarative `@deepseek-ai/dsh-web-app/presets/<id>.patch.yml` files
  (DSH ≥ 0.2.0, row at 14 spaces inside the `@deepseek-ai/dsh-agent-preset`
  declaration; a user-defined declarative preset with no shipped file is left
  alone). The row is located by YAML structure (a list item carrying
  `group: true`) and validated by both its `- id:` and `name:` values, so the
  swap is column-position independent: it swaps the `compaction-basic` row
  inside the `compaction` group for `dsh-engram/compaction`. That auto-wiring is:
  - **off by default (opt-in)** (`autoWebCompaction: false`, toggle in the
    settings card's memory-GC section); once enabled it is idempotent — an
    already-wired preset is left alone;
    **what "off" means**: the plugin stops auto-wiring on later boots (the
    headless/TUI/base host plane is unaffected) — it does NOT un-wire presets
    that were already wired; use `npm run web-compaction:revert` for that;
  - **per-preset**: only stock-layout presets are touched — a preset with a
    custom compaction group, or none at all (e.g. the shipped `minimal`
    preset), is never written, only logged;
  - **backed up + validated**: a create-only `.engram.bak`
    (`agent.cordis.yml.engram.bak` or `<id>.patch.yml.engram.bak`) holds each
    original next to its file, every result is re-read and verified
    after writing, and any doubt rolls that file back; a failure only warns and
    that session keeps DSH's default summarizer — the host plane is never
    affected.
  - **config actually propagates**: the engine config written into each row
    comes from the live settings (`gcReplacesCompaction` / `gcNarrative`) — with
    "already wired but with a different config" treated as a refresh, so
    changing the settings knobs re-provisions the web rows on the next boot
    instead of being silently ignored.
  - A fully equivalent manual CLI, shipped in the npm package
    (scans both preset generations — legacy roots, the shipped declarative
    presets, and the paths recorded at boot; `--presets-dir <dir>` overrides
    declarative discovery, `--file <path>` targets one):
    ```bash
    npx dsh-engram status     # stock = untouched · wired = active · custom = hands-off
    npx dsh-engram doctor     # status + ranked next steps for every gap found
    npx dsh-engram enable     # same as the boot-time auto-wiring (usually a no-op)
    npx dsh-engram revert     # restore the stock row everywhere — run BEFORE uninstalling
    ```
    (in the repo, `npm run web-compaction:*` is an alias of the same CLI.)
    Either way the `compaction` group becomes:
  ```yaml
  - id: compaction
    name: cordis:group
    group: true
    isolate:
      compaction: true
      toolResultPruner: true
    config:
      - id: engram-compaction       # replaces the original compaction-basic
        name: dsh-engram/compaction
        config:
          gcReplacesCompaction: true   # from settings; false = default LLM summary for this session
          gcNarrative: true            # from settings; false = fully mechanical
      - id: command-compact
        name: '@deepseek-ai/dsh-command-compact'
      - id: tool-result-pruner
        name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
        # keep the original config
  ```
  ⚠️ **Uninstall = revert first**: once a preset references
  `dsh-engram/compaction`, removing dsh-engram leaves every such row dangling
  and web sessions hang on load — so run `npx dsh-engram revert`
  (restores every preset) BEFORE uninstalling. A harness upgrade that ships a
  fresh standard preset overwrites the swap and self-heals the dangling
  reference.

#### First impressions after an npm install (perceive / guide / configure)

- **Semi-automatic (opt-in)**: DSH's own summarizer stays the default. Turn on
  `gcReplacesCompaction` (host plane) and `autoWebCompaction` (web presets) in
  the settings card, then boot once — `mountCompactionEngine` takes over and
  the stock presets are rewritten. **No file editing after that.**
- **Perceivable**: at boot the plugin writes an authoritative snapshot to
  `$DSH_HOME/engram/context-gc.status.json` (`host` plane mode + the per-preset
  `web` results + effective config). At any time run `npx dsh-engram status` or
  `npx dsh-engram doctor`; the web memory board (ESR kanban) also shows a
  header chip ("Context GC·主机 ·N 预设") fed by the overview API. Two startup
  log lines confirm it too: `engram context-gc: compaction = Context GC …` and
  `engram web-provision: wired Context GC into preset …`.
- **Configurable**: one settings card (memory-GC section) governs everything:
  `autoWebCompaction` (web auto-wiring, default off), `gcReplacesCompaction`
  (Context GC vs host summarizer, default off), `gcNarrative` (narrative
  safety net).
  Changes take effect after restarting `dsh web`, and the web rows are
  refreshed to match automatically.

**Verify it took**: after restarting `dsh web` (or the profile) —
- host plane (headless/TUI/base): the startup log shows
`engram context-gc: compaction = Context GC (mechanical eviction + re-fetch pointers)`;
- web plane: the log shows one line per wired preset —
`engram web-provision: wired Context GC into preset "standard" (…); restart dsh web so sessions pick it up`,
or `npx dsh-engram status` reports `wired` for each; sessions composed
from ANY preset then compact via mechanical eviction + re-fetch pointers.
A line like `… keeping the existing compaction service` means another provider
already owns `compaction` in that realm (the swap didn't take); `web-provision … skipped (custom)`
means that preset was customized (or has no compaction group) and was left untouched;
`dsh-compaction-basic unavailable` means the backend is missing and everything
falls back to the default compressor.

### BoundaryPrune (subtask-boundary pruning, opt-in since v0.7.11 + ledger veto)

Context GC owns *what a compaction summary says*; BoundaryPrune owns *when a
deterministic prune fires*: at todo-completion progress and goal terminal
events, it calls DSH's own `toolResultPruner` (deterministic surface-replace,
zero LLM) so long-stale over-threshold tool results stop replaying in every
later request. The two switches are independent.

- **Evidence chain** ([oracle analysis](docs/ORACLE-ANALYSIS.zh.md) +
  [proposal](docs/PROPOSAL-resultpack.zh.md)): 97 real sessions measured —
  2.77B–7.53B chars of tool-result replay exposure (~692M–1.88M tokens), 96%
  of result bytes from bash/read/run_code; the counterfactual simulation says
  todo boundaries cover 79% of that exposure and a repayment gate lifts
  cache-aware net savings 12–32% on every threshold config.
- **Repayment gate**: a prune rewrites the prefix (one KV-cache break), so it
  fires only while the session is still expected to run at least
  `boundaryPruneMinRemaining` (default 50) more requests — expectation is a
  workspace-history prior with ×2 growth projection past it, so long sessions
  are never starved.
- **Honest activation surface**: sessions with no goal and no todo progress
  simply never trigger (no boundary → no prune, zero side effects); the
  corpus-wide dry run activates on 38% of sessions. If the pruner service is
  absent the mechanism disables itself with one log line.
- **Live read (2026-10-26, two days of real use)**: the Context Ledger recorded
  the host pruning 64,200 chars on its own across two sessions (4 prunes in a
  220-request session that never wrote a completed todo), while engram's own
  leg produced 7,841 chars from a single boundary prune. The chain itself works
  (v0.7.7–v0.7.9 fixed gate/execution/accounting); the *coverage* does not.
- **Boundary sources**: todo-progress completion and goal terminal events
  (default), plus an optional **request-count cadence**
  (`boundaryPruneIdleCadence`, default 0/off) — the "step idle" source that
  covers the ~62% of sessions which never touch todo/goal (recon:
  `eval/recon-idle-boundary.mjs`; +85% saved chars at cadence 50 in the
  counterfactual `eval/simulate-idle-cadence.mjs`), still repayment-gated.
- **Threshold override**: `boundaryPruneThresholdChars` (default 0 → host's
  8192). Set e.g. 2048 with smaller retained edges
  (`boundaryPruneHeadChars`/`boundaryPruneTailChars`) to prune earlier —
  T2048+small-edges lands 2.4× the first-prune savings of the host default on
  the real corpus (10.14M vs 4.23M chars). Lowering the threshold is safe with
  `resultPack` on, which archives the removed middles.
- Default **off since v0.7.11**: `boundaryPrune: true` is the opt-in. Fifteen
  hours of live ledger proved the host prunes over-threshold results on its own
  (64,200 chars across two sessions, zero engram involvement) and that the
  boundary source never fires in an ordinary 220-request session — only planning
  sessions that use todos/goals get anything out of it. Adaptive gate
  `boundaryPruneAdaptive: true` lets the Context Ledger stop further fires once
  this workspace's own accounting is net negative (≥3 fires) — evidence can only
  turn pruning off. The log shows
  `engram boundary-prune (todo @N reqs): pruned …`.

### ResultPack L2 (archive + `result_recall`, default ON)

BoundaryPrune makes pruning lossless: the removed middle of every pruned
tool result is redacted (same rules as the memory write path) and archived to
`~/.dsh/engram/resultpack/<callId>.json`, and the pruned surface node gets a
tail handle — `[middle archived: result_recall(<callId>) 可取]` — so the model
knows the full result is one step away. `result_recall(callId, offset?)`
returns the archived middle in paged text blocks (progressive disclosure, the
same philosophy as `[ENGRAM]` → `engram_detail`, and the complete
ObservationPack pattern from SoL-Pi mechanism ③).

- **Lossless by construction**: L1 pruning shrinks the replay; L2 keeps the
  original recoverable on demand — the "model rereads a pruned result" failure
  mode goes to zero.
- **Scoped cost**: archives live outside the memory domain (big blobs never
  bloat the domain JSON or the [ENGRAM] index); retention is a mtime sweep
  (`resultPackRetentionDays`, default 7) riding the same GC cadence.
- **Self-disabling**: L2 without `boundaryPrune` has nothing to archive, so it
  registers no tool and emits one warning.
- Default **off**: `resultPack: false` (must be on together with
  `boundaryPrune: true`). When active the log shows
  `engram result-pack enabled: result_recall(callId, offset) registered`.

### Activation telemetry (SoL-Pi M15/C13)

Mechanisms must be provably activating, and dormant ones are off by default.
`/api/dsh-engram/stats` (and the GUI overview) now answer
`activation: { boundaryPrune, recall }`:

- `boundaryPrune` — cumulative, durable record of real BoundaryPrune fires
  (fires / prunedResults / charsRemoved / byKind todo|goal|step / lastFire),
  written to `~/.dsh/engram/boundary-prune.status.json` on every landed prune,
  independent of `resultPack`.
- `recall` — the injection leg of the recall funnel: how many `[RECALL]`
  blocks actually made it into sessions (`recall-injections.json`), closing
  the loop already measured from session logs (engram_recall queries → hits →
  engram_detail follows) so you can see injection→query→detail activation.

### Context Ledger (net-savings accounting)

Turns "saves tokens" into a checkable number. Every model request is one ledger
tick: injected `[ENGRAM]`/`[RECALL]` and `[ESR]` blocks are charged per request
they survive; results removed by BoundaryPrune are credited per later request
they no longer replay; every prune pays one prefix-rewrite penalty
`(1 − ledgerCacheDiscount)` (the KV-cache break). `GET /api/dsh-engram/stats`
and `/overview` return `ledger`:

- `totals` — `requests / injectedChars / avoidedChars / rewriteCount /
  rewriteChars / netChars / netTokens` with
  `net = avoided − injected − rewriteChars × (1 − discount)`;
- `byWorkspace` / `byDay` — the same arithmetic per workspace and calendar day
  (`byDay` newest first);
- `openSessions` — sessions still accumulating in memory (a restart loses at
  most `flushEvery` requests).

The Telemetry tab renders it directly (net-token headline, saved / injected /
rewrite-penalty cards, a 14-day red/green net chart), and the memory overview
adds a "net context tokens" stat card. Durable rollup at
`~/.dsh/engram/ledger.json` (atomic write, daily buckets, delta flush); the hot
path is an integer add, zero I/O and zero model calls.
Defaults: `ledgerEnabled: true`, `ledgerCacheDiscount: 0.1`; off = no listeners,
no writes.

**Two credit legs** (v0.7.1 — before this the ledger only charged costs, so the
net was structurally negative):

1. **L1 boundary prune** — chars removed by `toolResultPruner`, credited per
   later request. Requires a host-plane `toolResultPruner`; DSH ≥ 0.2.0 web
   profiles move that row into each agent preset's isolate realm, so the bundle
   patch re-enables the host row — check `boundaryPrune.armed` in the status
   snapshot.
2. **Compaction (Context GC)** — every checkpoint credits `span chars −
   checkpoint chars` from the engine that produced it; host plane and the web
   preset realm (bridged through the `engramLedger` service) write the same
   ledger.

**Honest scope**: tokens are `chars / 4` estimates, not provider billing, and no
quality delta is claimed. Until a workspace actually prunes or compacts its
"saved" leg is 0, so the net equals injected + rewrite penalty (negative is
expected) — the Telemetry tab says so inline.

### JIT push (v0.6-C)

Pull-only recall measured dormant (24 `engram_recall` / 8 `engram_detail` across
112 real sessions), so v0.6 pushes on three deterministic events instead. One
line per event, at most 3 lines per session (`jitMaxLines`, `jitMaxLineChars`=220),
and silence when there is nothing relevant to point at:

| Event | Trigger | What is pushed |
|---|---|---|
| Error recurrence | the same `cmd:` signature fails a second time and a prior error/procedure memory exists | that memory's id + a one-line excerpt + `engram_detail` |
| File focus | the same file is edited twice in a row and a filePath/dir-anchored memory exists | preference first, then fact/decision |
| Goal completion | goal complete/block while active ESR tasks remain | the first two open tasks + `esr_status` |

- **Append-only**: a pushed line is never edited or withdrawn, so the prefix
  changes once per push. Each push charges a rewrite
  (`estimatePrefixChars + line length`) in the Context Ledger
  (`rewriteCount` / `rewriteChars`), visible on the telemetry page.
- **Silent without a hit** — no memory/task to point at, no line.
- **Zero LLM, pure rules**; `jitEnabled:false` disables it, and the empty
  `[JIT]` section costs nothing.
- **Not yet**: the resume brief (next-day continuation) and an explicit
  remaining-requests ROI gate — today the 3-line budget plus the ledger are the
  guardrails.

### Session episode + resume brief (v0.7-A/D)

- **episode** — at `session/disposed`, a zero-LLM digest of the event log: files
  touched, commands run, failures that later recovered (`cmd×N→fixed`), written
  commit subjects, and still-open todos. Written only when there is substance (a
  bare `ls` session produces none); **never indexed**; the newest 20 per
  workspace are kept and older ones archived.
- **resume brief** — at the first assembly of a new session, the newest episode
  from the last 7 days is injected as a `[RESUME]` line (≤240 chars) plus an
  `engram_detail <id>` pointer, frozen with `[ENGRAM]`/`[RECALL]`. No recent
  episode → no line; returning to a project does not need a re-briefing.
- Settings: `episodeEnabled`, `resumeBriefEnabled` (both default on), plus
  `episodeMaxPerWorkspace` / `resumeBriefMaxAgeDays` / `resumeBriefMaxChars`.

> **Host requirement (silent-dormancy warning).** Episode, resume brief, `[RECALL]`
> and the session-end todo sink all read the session's event log. On ≥0.2.x hosts
> the log arrives through `ctx.sessionQuery.live()`, `session.snapshotEvents()` or,
> as a last resort, `session.seq` + `session.eventAt(seq)`. A host that exposes
> **none** of these keeps the three features dormant: memory storage, `engram_recall`
> and `engram_detail` still work, but nothing is injected at session start and no
> episode is written. The plugin logs one `engram: this host exposes no readable
> session event log … Session surface: …` warning per session naming what the
> session object does offer — that line is the thing to paste into a bug report.
> Known case: the **desktop runtime** (`@deepseek-ai/dsh-desktop-runtime`) on
> Windows, tracked as issue #9.
>
> `dsh-engram status` also prints a **push-legs** line — `推送腿：事件日志读法
> sessionQuery · 最近 …` — the read path the live process actually took, written
> to `$DSH_HOME/engram/push-legs.jsonl`. A dormant host shows
> `事件日志不可读（none）… 休眠中` there, so the four features can be checked
> without hunting a startup log.

### Entity pages + entity cards (v0.7-B)

`esr_node` grows from a dot on the graph into a readable **page**: current
truth, timeline, open loops, conflicts and relations for one thing
(package / repo / service / bug…).

- **Entity card (in the index, default on)** — `[ENGRAM]` renders ONE line for
  each active entity (anchored memories or open tasks), at most 2:
  `[ENT] dsh-engram · package · 3 mem · 1 open · truth: … #id · page: engram_detail(entity="dsh-engram")`
  Those entities' flat memory lines are not repeated; other entities stay in a
  single `nodes:` line.
- **Full page (on drill)** — `engram_detail(entity="<name|id>")` returns
  `truth / timeline / open loops / conflicts / relations`, with memory ids and
  evidence gaps. A name with anchored memories but no registered node still
  gets a virtual page.
- Pure, deterministic: preference leads `truth`, superseded rows never enter
  it, conflicts are de-duplicated by unordered pair (`#a vs #b`).
- Settings `entityCardsEnabled` (default on); `entityCardsMax` /
  `entityCardChars` tunable.

### Revalidatable anchors + layered TTL (v0.7-C)

"Treating the past as truth" is the strongest credibility critique against
memory plugins. v0.7 gives file-backed memories an **anchor**:

- on write, record `{path, mtimeMs, size, hash?}` (sha256 for files ≤256KB,
  stat only for larger ones) in the memory row; relative paths resolve against
  the workspace;
- at session start a bounded revalidation (≤40 rows) tags changed/missing
  sources `stale`; the `[ENGRAM]` index and entity `truth` stop showing them,
  but nothing is deleted — `engram_detail` still returns them and the entity
  timeline marks `· stale`;
- **layered TTL**: `preference` / `procedure` permanent; `episode` 14 days;
  `error` 60 days; everything else `expireDays` (180). Hard deletes still do not
  exist — GC only archives.
- Settings: `anchorRevalidate` (default on), `episodeTtlDays`, `errorTtlDays`.
- **Not done**: the offline consolidation job (episode abstraction/merge) —
  deterministic rules can only roll up, not abstract semantically; deferred
  until v0.8 alongside playbooks, rather than shipping a lossy pseudo-merge.

## Auto-capture policy

Capture is deterministic and offline — it only sees tool *results*, never the
conversation. Exactly what earns a memory record:

| Tool result | Action | Signal |
|---|---|---|
| `git commit … -m "subject"` | record — the written subject is the memory | 0.55 |
| `git merge` / `rebase` / `cherry-pick` / `tag` / `checkout -b` | record (milestone) | 0.5 |
| `git push` / `git stash` / commit without `-m` | **skip** — plumbing echo, not a decision | — |
| write/edit of a significant config & doc path | record | 0.3 |
| read of a config path | record | 0.3 |
| repeated tool error | record (deduped by message) | 0.25 |

Explicit `engram_store` writes are always recorded regardless of these rules
(rate-limited per session).

**Who earns a `[ENGRAM]` index line** (this is what actually touches the prompt):
`signal >= minIndexSignal` **or** `hits >= promoteHits` **or** `kind === "task"`,
then capped by `indexMaxLines` / `indexMaxChars`.

**Procedures are evidence-gated (v0.8-A).** An auto-captured `fixed: <cmd>` row is
a hypothesis, not a lesson: it starts with `evidenceCount = 1` and stays out of
the index until the same command is verified **3** times with the last
verification younger than **14 days** (`procedureMinEvidence` /
`procedureMaxAgeDays`). The moment that command fails again the row is
invalidated (`invalidatedBy` = the failing memory's id, signal → 0.2) and leaves
the index immediately — a just-broken procedure is worse than none, because the
next agent would copy it verbatim. Hand-written procedures with no evidence
fields keep the older `hits >= promoteHits` path.

**Promoted procedures can become real Skills (v0.8-B).** An index line is a
hint; a DSH Skill is an artifact the host discovers, advertises in the session
catalog, and any agent (or subagent) can load in full:

```bash
npx dsh-engram skill export            # → <workspace>/.dsh/skills/<name>/SKILL.md
npx dsh-engram skill export --dry-run  # show the plan, touch nothing
```

Only procedures that pass the evidence gate are exported (`≥3` verified
successes, verified within 14 days, not invalidated). Each file carries a
`generated-by` marker: the exporter rewrites and eventually **deletes** only its
own files, so a hand-written skill sharing the root is never touched. When a
procedure stops holding, its skill file disappears on the next export — the host
catalog must not keep advertising a fix that just broke. `npm run skill:check`
proves the seam end-to-end by loading DSH's own `dsh-skill-filesystem` provider
and listing the exported skill through it.

**The approval queue is in the GUI too (v0.8.4).** The memory tab shows
"Playbook 待批准" with evidence counts and approve/revoke buttons, backed by
`GET|POST /api/dsh-engram/approvals`. The route recomputes the list with the same
planner `skill export` uses, so a click can never approve something the CLI would
skip; revoking returns a row to pending rather than deleting its evidence.

**Writing a skill needs a human yes (v0.8-D).** Evidence (`≥3` verified
successes) gets a procedure into the private index line; it does not, on its own,
put a file where the host advertises it to every agent. `skill export` therefore
writes only **approved** procedures — `skill pending` lists what is waiting,
`skill approve <id>` / `skill revoke <id>` maintain the record in
`~/.dsh/engram/playbook-approvals.json` (kept out of the memory store: an approval
is a human act, and a pool must not approve itself), and `--force` is the audited
escape hatch. Three successful runs prove a command worked; they do not prove the
instruction is sound.

**Subagent reports become durable capsules (v0.8-C, advisory).** A child's final
report above `capsuleMinSourceChars` (1500) is distilled by structure — headings,
bullets, question marks, next-step cues — into a `handoff` memory (结论 / 证据 /
未决 / 下一步) whose footer records `source → capsule` chars. It does **not** shrink
what the parent reads: DSH emits `tools/result` to observers only ("without
exposing a mutation or error channel into the outcome"), so substitution needs a
host seam that does not exist yet. What you get today is durability — the
conclusion survives the child session and compaction — plus a real measurement of
the saving for the day that seam lands. One extra guard keeps the pipe
clean: auto-captured git command echoes — text that embeds a shell chain
(`git push: cd … && …`) — stay out of the index even when above the signal
threshold, until recall hits have promoted them. Everything else sits quietly in
storage, reachable on demand via `engram_recall` / `engram_detail` — "retrieved ≠
injected".

## Injected blocks

What the model actually sees (rendered once per session, then frozen):

```
ESR operating protocol (static, byte-identical every turn)
  1. Before starting: esr_ready to see claimable work; esr_status for live state.
  2. Multi-step work → esr_task (draft first); claim → esr_claim; finish → esr_close (all three gates).
  state is the single source of truth: unsure → call esr_status.

[ENGRAM] workspace: symbolic-index · 2 memories · 1 task(s) active · 0 links
[D] 06-18 Decided: use sqlite-vec for retrieval #a2331d87
[T] 06-18 Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref #tsk_8b26
drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)

[RECALL] recall · 1 hit(s) · first msg: retrieval
- [D] 06-18 Decided: use sqlite-vec for retrieval upgrade #a2331d87 ×2
context: use above · engram_detail <id> · engram_recall <query>

[ESR] tasks: 1 active / 1 stable
- tsk_0d: Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref
- closed: tsk_9a (RAG eval)  ·  +1
snapshot from session start — WILL NOT auto-refresh; call esr_status for live state
# this-session actionables (frozen)
promote: 2 pending todo(s) vs 1 ESR task(s) — esr_task(name="…") #suggest-promote
```

Prefixes: `[D]` decision · `[E]` error · `[P]` procedure · `[F]` fact ·
`[I]` insight · `[H]` handoff · `[T]` task. A procedure re-used enough times
(`hits >= promoteHits`) becomes a "proven experience" — its prefix upgrades to
`[P✓]` and it is stably ranked first in the block (newest-first within each
group, keeping the block deterministic): our zero-LLM counterpart to TencentDB
Agent Memory's "Skill = validated, executable experience". Membership follows
the *Auto-capture policy* (signal threshold / recall promotion / git-echo guard),
bounded by the configured line and character budgets. `#` ids address the full
records via `engram_detail`. When a workspace has no tasks, `[ESR]` still renders
one line naming `esr_task`/`esr_close` so the mechanism stays visible to the
model instead of vanishing.

`[RECALL]` (session-start auto recall, `autoRecallOnStart`) runs one deterministic
BM25 pass over the workspace keyed on the session's first user message and injects
the top `autoRecallLimit` hits, bounded by the `autoRecallMaxChars` character
budget; nothing renders on zero hits or an empty workspace. It is a *pointer* — the
model should treat it as "this is relevant; `engram_detail` for the full record" —
rather than full-text dumping. It is a pure read (never bumps `hits`), superseded
stale truth never occupies a slot, and it freezes per session with `[ENGRAM]`.

## Config

Defaults are token-conscious; override any key via the profile patch
(`~/.dsh/profiles/web/cordis.patch.yml`) or the web config card:

```yaml
- id: engram
  config:
    autoCapture: true        # zero-LLM tool-result capture
    autoCapturePreferences: true  # zero-LLM user-correction/preference capture (prefer/avoid/switch-to cues)
    preferenceCapturePerSession: 8  # per-session preference memory cap
    boundaryPrune: true        # ResultPack L1: prune stale over-threshold tool results at todo/goal boundaries (default ON)
    boundaryPruneAdaptive: true   # ledger veto: stop pruning this workspace once its net is negative (>=3 fires)
    boundaryPruneThresholdChars: 0   # threshold override (0 = host 8192; e.g. 2048 prunes earlier)
    boundaryPruneHeadChars: 0  # retained head under an override (0 = host 4096)
    boundaryPruneTailChars: 0  # retained tail under an override (0 = host 1024)
    boundaryPruneIdleCadence: 0   # request-count boundary for todo/goal-less sessions (0 = event-only)
    resultPack: true           # ResultPack L2: archive pruned middles + result_recall paged recall (default ON; needs L1 on)
    resultPackRetentionDays: 7 # archive retention days (mtime sweep on the GC cadence)
    sessionSearch: true      # engram_recall may also FTS past sessions
    recallScope: workspace   # recall scope: "workspace" (strict isolation, default) | "global" (opt-in cross-workspace recall with [W:<ws>] origin markers)
    autoRecallOnStart: true  # session-start auto recall (false = back to pure pull)
    autoRecallLimit: 3       # [RECALL] max injected hits (few and precise)
    autoRecallMaxChars: 700  # [RECALL] char budget
    autoCapturePerSession: 40
    indexMaxLines: 12        # [ENGRAM] line cap
    indexMaxChars: 700       # [ENGRAM] char cap (token budget)
    minIndexSignal: 0.4      # auto-captures below this stay out of the index
                             # (git command echoes are excluded regardless,
                             # until promoted by recall hits)
    promoteHits: 3           # ...until recalled this many times
    procedureMinEvidence: 3  # verified successes before a `fixed:` procedure
                             # earns an index line (one success proves nothing)
    procedureMaxAgeDays: 14  # ...and the last verification must be this fresh
    expireDays: 180          # memory TTL (0 = never)
    maxMemoriesPerWorkspace: 2000
    gcEnabled: true          # scheduled memory GC
    gcIntervalHours: 24      # sweep cadence
    gcStableRetentionDays: 120  # stable tasks leave [ESR] after this
    gcReplacesCompaction: false # Context GC: take over DSH auto-compact (default off = host summarizer; true = opt-in pointers)
    gcNarrative: true        # scoped LLM narrative for un-provenanced turns; false = fully mechanical (zero LLM)
    autoWebCompaction: false # rewrite stock web presets (default off; explicit opt-in, revert with the CLI)
    jitEnabled: true         # JIT push: error recurrence / file focus / goal completion → one memory line
    episodeEnabled: true     # session episode: zero-LLM digest at teardown (never indexed)
    resumeBriefEnabled: true # resume brief: newest episode from the last 7 days at session start
    entityCardsEnabled: true # entity card: one truth/tasks/drill line per active entity in the index
    anchorRevalidate: true   # re-stat filePath anchors at session start (changed/missing → stale)
    episodeTtlDays: 14       # episode TTL (preference/procedure permanent; error 60d; others expireDays)
    ledgerEnabled: true      # Context Ledger: injected cost / prune savings / rewrite penalty → net tokens
    ledgerCacheDiscount: 0.1 # cached-prefix price share; rewrites pay (1−discount)
    engramIndexOrder: 40    # systemPrompt section order (before tools band)
    esrOrder: 41
```

## Development

```sh
npm test            # 381 tests: core + web API + GC + usage-observability + Context GC + ESR triggers + BoundaryPrune + preference learning + Context Ledger + JIT push (node:test)
npm run eval        # offline recall + structure benchmark (deterministic)
npm run build:client
```

### Testing & evaluation (two layers of real testing)

**npm run eval** (eval/recall-bench.mjs) is the deterministic layer —
LongMemEval-style: a controlled corpus (ASCII + CJK, known tags/entities/
timestamps) measured through the real store/recall path (openEngramDomain +
domain.recall), reporting Precision@k / Recall@k / MRR / Hit@1 per probe
(exact tag, substring, multi-term, CJK, phrase-single, ordering, negative)
plus StructMemEval-flavoured structure metrics (exact-duplicate dedup rate,
entity anchoring coverage, dangling-link hygiene). Honest, reproducible
numbers — everyone gets the same output. Current run: AVG P@k 0.770 /
AVG R@k 1.000 / MRR 0.889 / hit@1 0.889 / 0 negative false-positives /
dedup 1.0.

**/api/dsh-engram/stats + the observability panel** is the real-session layer —
it answers how the model actually uses the memory in production (ESR
proactivity ratio, recall hit rate, detail conversion), while the eval
answers how good the retrieval layer itself is. The stats read the
concatenated multi-frame `session.jsonl.zstd` logs with Node's built-in
`node:zlib` (frame scan + one reused decoder handle) — **no system `zstd` CLI
is required**, so the telemetry works out-of-the-box on Windows and other
hosts without an external codec.

Repo layout: `lib/` (host half: store / capture / index-block / tools / api /
settings), `client/` (browser half, TSX + `build.mjs`), `test/` (node:test).

## Troubleshooting

**The web GUI opens and immediately shows “Failed to load plugins”**, with a
loader error like:

```
failed to apply loader entry … (@linxin666/dsh-client-ui-web-ui-settings):
keyed slot "settings.plugin.item" requires options.key
```

Cause: DSH hosts since `0.1.0-rc.7` declare the config-card slot
`settings.plugin.item` as **keyed by the settings namespace a card edits** —
which is exactly how dsh-engram's own config card registers (under
`key: "dsh-engram"`). The `@linxin666/dsh-web-ui-all` family **before 0.2.0**
(`dsh-client-ui-web-ui-settings`) registered its group card into that slot
**without a `key`**, and because a single failed loader entry aborts the whole
boot, the GUI stays stuck on the failure page.

Fixes:

- **Proper fix — upgrade the family**: `@linxin666/dsh-web-ui-all@^0.2.x`.
  The 0.2 line moved its settings surface out of the keyed slot into a
  first-level `settings.section` (the upstream fix for exactly this error).
- **Immediate unblock**: add `key: "web-ui-plugins"` to that one
  `settings.plugin.item` registration in the installed
  `node_modules/@linxin666/dsh-client-ui-web-ui-settings/lib/client.js`, then
  restart `dsh web`. (Under namespace-keyed dispatch the group card simply
  stays hidden; nothing else on the page is affected.)

## Related

- [symbolic-index](https://github.com/skepsun/symbolic-index) — the original cross-session
  memory plugin (5-signal RRF fusion, sqlite-vec, Dream Engine).
- [pi-esr](https://github.com/skepsun/pi-esr) — project-lifetime evidence-driven
  task states; the closure protocol here is its lite form.

## License

MIT
