# 工作机制、配置与成本

> 本文是**说明书**，[README](../README.md) 是**门面**。README 用一屏讲清「装不装、怎么装、装完什么样」；
> 这一份讲它内部到底怎么跑、有哪些旋钮、代价是多少 —— 给人肉复核用，不要求一次读完。

覆盖：召回在什么时候发生（含可检索性门控）、十个机制、写入闸门、体检器、沉淀提醒、归档、
「记忆」标签页与打开率账本、按需检索工具、桥与别名、完整配置表、成本对照。

> 交叉引用：正文里的「见上 / 见下」指本文内的其它小节。语义增强与语义检索接缝的完整讨论在
> [limits.md](./limits.md)，实测数字在 [measuring.md](./measuring.md)。

---

## 工作机制

### 召回：发生在每个用户轮的第一个 step

1. 取这批消息里最后一条**真人**消息，做可检索性门控：空、寒暄（继续 / 嗯 / 好的 / ok…）、
   短于 4 字的消息直接放行，零开销；
2. 超长消息头尾各取一半（中间通常是粘贴的日志或正文，噪音最多），头部再收敛到前 3 句；
3. BM25 检索 + 同义扩展 + IDF 选词，取前 `maxPointers` 条；
4. 命中就追加一条 `role: user` 的插件消息（`source.kind = plugin:dsh-engramory-recall`、
   `form: notice`），内容是「可能相关的既有条目」：文件名 + 命中行片段 + 一句「需要细节自己 read」；
5. 没命中就原样放行。

### 十个机制

| # | 机制 | 治什么 |
|---|---|---|
| 1 | 同义扩展（177 条口语→技术词）+ 连字符再切分 + 命中键只降「碎片」权重 | 中文口语与笔记用词对不上的同义鸿沟 |
| 2 | 超长正文按 `##` 切桶 + **整篇**长度归一 | 单篇巨文霸榜（一篇几百 KB 的笔记曾占掉四分之一召回位） |
| 3 | 寒暄 / 极短消息门控 | 「继续 / 嗯」这类消息 10/10 注入噪音 |
| 4 | 写入闸门（fail-closed） | 不合规文件名、缺失 frontmatter、疑似真实凭据 |
| 5 | 注入去重 | 同一批指针上一轮已在上下文里，不重复计费 |
| 6 | 预算降级阶梯 | 装不下的条目塌缩成只剩文件名，而不是静默丢掉 |
| 7 | 跨轮轮换降权 | 最近注入过的条目在后续轮次乘 0.5，把机会让给别的笔记 |
| 8 | 沉淀提醒 | 长会话一次都没写过记忆库时，提醒一次，补「该记的没记」 |
| 9 | 索引上限闸门（`index-guard.js`） | `MEMORY.md` 长过 200 行 / 25KB 就拒绝写入，索引是每次都读的，超限部分会被静默地不再召回 |
| 10 | `engramory` 技能（`skill.js`） | 「什么时候该记、什么时候不该记」这套纪律本身，注册成技能才不依赖外部插件 |

9 与 10 的**判定口径**来自 Engramory 协议在 DSH 的适配器（上游 `tinqiao-oss/engramory`，
npm 包 `dsh-engramory` v0.2.5，MIT —— 版权与移植范围见仓库根目录 `THIRD-PARTY-NOTICES.md`）；
**实现与措辞是本仓库自己的**，写进 `index-guard.js` 与 `skill.js`。

自己实现而不是继续装那个插件，理由不只是省一个依赖：两套闸门各管一半
（这边管笔记文件、那边管索引），判据一旦漂移就会互相打架，而这个项目已经因为
「同一个概念有两处定义」栽过三次（见文末）。一个概念一处定义。

### 写入闸门（`guard.js`）

注册为宿主的 `ctx.tools.guard()` 单调守卫：模型用 `write` / `edit` 往记忆库里写时先静态复核，
不合规就拒绝这次工具调用（拒绝理由直接回给模型），且后续监听器无法把拒绝翻回允许。

| 判定 | 规则 |
|---|---|
| 文件名 | 必须匹配 `<user\|feedback\|project\|reference>-<小写slug>.md` |
| frontmatter | 必须有头部块，且含 `name` / `description`，`type` 与文件名前缀一致 |
| 凭据 | 6 类真实密钥（私钥块 / `sk-` / `ghp_` / `AKIA` / `xox` / JWT）+ 键值对形式；占位写法放行（`process.env.X`、`<YOUR_TOKEN>`、`CHANGEME`、`***`…） |
| 体积 | 单篇 ≤ 100KB |

`edit` 只能看到片段、看不见结构，所以它只查文件名与凭据（返回 `partial: true`）。
放行与拒绝都追加到 `<storePath>/.engramory-audit.jsonl`（JSONL，含 `epoch` 规则版本，
超 512KB 自动轮转）。

**边界**：`guard` 只拦 `write` / `edit` 工具调用，`bash` 重定向它看不见。它是正路守卫，
不是沙箱；兜底靠体检器定时扫（真实抓到过两篇绕过闸门的裸 markdown）。

#### 不用信这段话，两分钟自己验一遍

闸门跑在宿主进程里，验法就是在你的 agent 会话里让它做下面四件事 —— 全部走普通的 `write` / `edit` 工具，不需要装任何东西：

| 让它做 | 你会看到 |
|---|---|
| 往 `MEMORY.md` 追加 130 行（凑到 203 行） | 拒绝：**「这次写入会把索引 MEMORY.md 顶过上限（203 行 > 200）」** |
| 在记忆库外（比如项目目录里）写一份同名、同样 203 行的 `MEMORY.md` | 放行（闸门只认配置的那一个库） |
| 往库里写 `bad-name.md` | 拒绝：**「文件名不合规。必须是 \<user\|feedback\|project\|reference\>-\<小写slug\>.md」** |
| 写一篇带 `api_key: sk-…` 的笔记 | 拒绝：**「疑似含真实凭据（OpenAI 风格密钥）」** |

作者实测（本地库几十篇笔记、索引七十余行）：四条全中，两次被拒的写入**磁盘上零字节变化** —— 被拒后索引与操作前逐字一致。

这就是它跟别家最本质的一处不同：别人的记忆预算卡在**注入**上（超了就少喂几段给模型），这里卡在**写入**上 —— 索引文件本身长不到超限，所以「超限的部分静默不再被召回」在这个库里不会发生。

### 体检器（`doctor.js`）

纯静态、只报告不动手。14 项检查：库不可读、索引指向不存在的文件、孤儿笔记、索引重复、
文件名不合规、`name` / `type` 与文件名不一致、缺 `description`、超 100KB、待办 30 天没更新、
失联路径（同一目录名出现在多个位置、其中有的已不存在）、归档建议（超 64KB 或追加章节超过 20 个）、
疑似重复主题、**被带进上下文很多次却一次没被用上**（读账本，样本不够就不说话）、
**`project` 类笔记太久没更新**（只提示，不拦）。

三条防误报纪律：路径比较前先规范化（`~/x` 与 `/Users/you/x` 同处、尾斜杠不算另一个位置、
含 `...` 的是缩写、一层深的隐藏目录如 `~/.dsh` 是各工具自己的配置位不算冲突）；
缺 `description` 时不参与相似度比较（否则 `undefined` 会变成共同 token 算出假相似）；
判断路径是否还在要用 `statSync` 不抛错，不能用 `isFile()`（软链指向目录时它返回 false）。

它不做纯词面的语义冲突检测，这条路已实测证伪：对全库笔记两两算词面相似度，
最高的一组（0.445）全是毫不相干的项目，它们像只是因为共用同一套文档模板措辞。
纯词面测的是文体不是主题。要测语义冲突只能过模型，见下面的可选项。

### 沉淀提醒（机制 8）

补的是唯一一个不受任何约束限制的缺口：闸门保证写进来的东西干净，
但没有任何机制保证该写的被写了。判据刻意保守，三条同时满足才提醒：

- 本会话轮次 ≥ `sedimentMinTurns`（默认 12）；
- 本会话对记忆库的成功写入次数为 0；
- 距上次提醒 ≥ 24 轮。

短会话不触发，已经在写的会话永远不触发。它独立于召回命中（命中为 0 时才更需要它），
也不参与注入去重（去重讲的是上一轮那份指针还在，它讲的是本会话一直没写过）。

### 归档（`archive.js`）：把历史搬走，不把历史删掉

追加式写法（每批上线往同一篇笔记后面接一段）会让一篇笔记长到几百 KB。检索时它按整篇长度归一，
于是长期霸占召回位。归档做的是物理分层：

- **只搬追加式章节**：认 `## 第 N 批` / `**第 N 批**` / `### 第 N 批`（中文数字也认）。
  正文里的结论、现状、非批次章节一律不动。
- **搬进同目录的 `<笔记>-history.md`**：仍在库里、仍被 `readDocs` 索引、仍能被召回，
  所以没有一步是不可逆的。超过 90KB 自动续写到 `-history-2.md`，因为写入闸门对单篇
  有 100KB 上限（第一版没做分组，一份 151KB 的历史被自己的闸门拒了）。
- **先备份再动手**：原件写入 `.archive-backup/<时间戳>/`（只读快照，`readDocs` 不递归，不进检索）。
- **两段式**：`planArchive` 只读预览，面板点「执行」才动库，且每次执行前重新出具计划。
- **可以再来一层**：搬出来的 history 笔记本身也可能长回去。归档器认得出自己的产物，
  `baseSlug()` 把 `x.md`、`x-history.md`、`x-history-2.md` 归成同一篇，于是
  `history-2.md` 能再拆出 `history-5/6.md`，新文件名从不撞已有的。
  它也不会去归档自己的归档：文件名过滤 + 计划时按已有的批次标题去重，两层都在。
- **不碰 `MEMORY.md`**：索引由另一套守卫管，归档是机械派生、不产生新事实。

示例效果（示例库，数字只说明数量级）：单篇 260KB 压到 8KB，46 个批次搬进 4 份 history 笔记
（37 / 90 / 89 / 46KB，都在闸门上限以内）。召回反而变好：原来这篇自己霸榜，
现在命中的是它的 history。

**一次自我纠错**：第一版只认 `## 第 N 批`，而真实那篇里同时存在 `## 第二十七批：…`、
`**第二批：…**`（加粗整行）、`### 第三十八批（…）` 三种写法。判据比数据窄，于是 46 个批次
只看见 32 个，搬完主文件还有 116KB，被闸门**正确地**拒了，而界面上看起来像功能坏了。
现在三种写法都认，且体检器不再自己抄一份正则，而是直接调 `planArchive`。同一个毛病
在这个项目里犯过三次（另外两次是体检器的绝对路径只认 `/Users/`、探针指纹漏算 `aliases` 字段），
所以它现在是一条明确的搜索模式：**判据比数据窄，且失效时不报错**。

### 「记忆」标签页

客户端 bundle 是手写的（`client/client.js`，不需要任何构建工具，只 `require` 平台的
react + primitives），注册到宿主的 `conversation.view` slot，与「对话 / 轨迹」并排。

**首屏先给三个结论**（不是先摆一屏仪表）：

1. **这一轮提醒了什么？** —— 最近一轮带进上下文的笔记与相关度，以及每条**后来有没有被翻开**：
   状态点读的是账本（`.engramory-recall.jsonl`）里的累计打开记录，与「用上了多少」页签同一份口径。
2. **用上了多少？** —— 打开率 + 「给出去的第几条更容易被用上」的柱状图（虚线是中位），
   并明写它是代理指标：证明笔记被翻开过，证明不了被写进回答。
3. **有没有人被拦住？** —— 写入保护拦了几次、体检有几项要处理或留意、索引已用多少 / 上限多少行。

三个结论下面才是明细，**八个页签**：提醒记录（每轮判定：注入 / 无命中 / 跳过 / 拦截写入 / 沉淀提醒 / 归档，
带展开明细，可按类型筛选、复制一轮或导出最近 20 轮）、用上了多少（打开率、位置级曲线、预算与分数、
没注入的原因分布、minScore 代价曲线、门控降权与抑制的账，与尺子同一套算法 + 四句判据）、笔记（可过滤的清单）、
体检（doctor 的发现，按严重度排序）、索引全文、归档（待归档计划 + 一键执行）、
写入记录（每条放行与拒绝都留痕）、说明（术语表 + 「我要的功能在哪个开关上」+ 每个数字的来源）。

无障碍上做了三件小事：页签是 `role="tablist"`，柱状图与状态点是 `role="img"` + `aria-label`
（读屏念得出「第 3 条：给过 68 条，被用上 0 条」，而不是「一张图」），页签按钮带焦点环；
切页签时页签条会自己滚进视野 —— 首屏三问占了约 700px，不然点完按钮像是没反应。

面板改版前先请一个**不看代码的非技术用户**拿着它做了四件事（它帮上忙了吗 / 怎么关掉它 / 为什么这轮没提醒 /
索引 80/200 要不要管），按他的记录逐条改：状态点除颜色外还有**形状**（实心＝被翻开过、空心＝没被翻开过、
灰点＝账本里没这条），首屏多一个「**怎么关掉它**」按钮直达说明页并写清改哪个文件，
说明页的术语表扩到 **20 条**并附「我要的功能在哪个开关上」（`enabled` / `maxPointers` / `maxChars` /
`minScore` / `nudge` / `guardWrites` / `gate` / `autoArchive`）与「每个数字从哪来」。
对比度不是估的：浅色与深色两套渲染结果各量一遍，**942 个文本节点全部 ≥ 4.5:1**（状态色按 70% 与
`currentColor` 混色，跟着主题自己变深或变亮）。

数据来自插件注册的只读路由 `GET /api/engramory-recall/state`（面板每 3 秒轮询；
体检与归档计划按 30 秒 TTL 缓存，不重算全库）。这条路由的 JSON 里还多一节 `usage`：
**打开率、位置级曲线、预算与分数、原因分布、minScore 代价曲线 + 四句判据** —— 与 `npm run recall:stats`
同一套算法（见下），所以面板与命令行不会出现两套口径。执行口是另一条必须 `POST` 的路由
（`{ file, confirm: true }`，GET 一律 405）。用 `ctx.inject(['webServer'], …)` 注册：
Electron 载体走 `file://` + IPC 时可能没有 webServer，那时召回照常工作，只是没有面板数据。

### 召回日志与打开率（`npm run recall:stats`）

内存里的召回日志（面板「提醒记录」页签）只留最近 80 条、重启即清——它回答「这轮为什么注入」，
回答不了「注入的指针后来有没有被用上」。0.13.1 起每次注入、每次打开都多写一行到
`<storePath>/.engramory-recall.jsonl`（512KB 封顶、超了半量截断、**永不抛异常**——
记账失败不该影响工具调用）：

| 行 | 什么时候写 | 关键字段 |
| --- | --- | --- |
| `hit` / `sediment` | 每轮注入（命中 / 只给沉淀提醒） | `ids`（**按显示顺序**）、`scores`（与 `ids` 一一对应）、`injected`、`collapsed`、`collapsedIds`（**被缩成裸文件名的是哪几条**）、`dropped`、`budget`/`spent`（该轮预算上限与实际用掉的字符数） |
| （轮级字段） | 上面三类「轮」的行都带 | `scope` = `main` / `subagent`（提问者；0.13.7 之前的行没有这一项，汇总里归 `unknown`） |
| `skip` | 没去检索（寒暄 / 太短 / 与上轮重复 / 没配库路径） | `reason` = `chitchat` / `too-short` / `duplicate` / `store-not-configured` |
| `empty` | 检索了但一条都没注入 | `reason` = `index-empty` / `no-hits` / `below-min-score`，另带 `minScore` 与 `topScore` |
| `deny` | 写入被闸门拦下 | `reason`（拒绝原文） |
| `used` | 注入过的笔记后来被 `read` / `edit` 打开 | `id`、`via`、`lagMs`（距注入多久） |
| `opened` | 没注入过、也被主动打开的库内笔记（对照组） | `id`、`via` |

**打开率 = 去重后的打开条数 ÷ 注入指针条数**：同一条指针在**同一轮**注入里被 read/edit 多次只算一次
（`used` 行仍是每次一行，尺子把原始次数单列成「打开次数（未去重）」）。分子分母必须是同一个量纲 ——
否则 20 条指针被读 30 次就会印出「打开率 150% / 偏高，建议放宽窗口」，结论正好说反。

```bash
npm run recall:stats                          # 打开率、位置级曲线、预算与分数、原因分布、minScore 代价曲线、四句判据
npm run recall:stats -- --max-pointers 5      # 多算一句「窗口用满了几轮」
npm run recall:stats -- --json                # 机器可读（带 exists：区分「没落盘」与「路径写错」）
npm run recall:stats -- --help                # 用法；--store / --positions / --limit 都在这里
```

**参数不静默改口径**（0.13.14）：`--limit 5` 会收到下限 10、`--positions 0` 会回落默认 5 ——
但**都会在 stderr 说一句按什么算的**，stdout 仍是干净的报告（`--json` 时仍是纯 JSON）。
`--store ''` 直接退出 2，而不是悄悄去读默认库（过去会静默回落到 `~/memory/engramory`：
想读一个临时目录却读到线上账本，是最坏的那类「悄悄」）。不认识的选项也会点一句 ——
`--strore` 打错时，最怕的就是它一声不吭地给你看别人的账本。

**预算那一节容易被忽略，但它才是「窗口调宽了、字跟上了吗」的答案**：注入行里带着
`collapsed`（预算不够被缩成裸文件名）与 `dropped`（整个没列出）——窗口条数够、内容却被砍，
这两列会直接露出来。第二句判据据此给动作：截断超过两成 ⇒ **先抬 `maxChars`**；
窗口顶满且没截断 ⇒ 再放宽只需动 `maxPointers`；两者都没有 ⇒ 窗口没顶到上限，是命中不够。

**「缩掉的是哪几条」也落盘了**（0.13.15）：光有 `collapsed` 的条数只说明预算顶到上限，
说明不了砍掉的东西有没有用 —— `collapsedIds` 让尺子能把「被缩的那几条」与「完整列出的那几条」
分开算打开率，第三句判据直接答「抬 `maxChars` 值不值」：被缩的反而更容易被打开 ⇒ 预算确实在
砍有用的东西；两边差不多 ⇒ 抬了也白抬。两边被打开的样本都不足 5 条时它只会说「先看形状」
（4% 的打开率下，十几条指针里可能一条都没被打开过）。老行没有这个字段，尺子如实印「—」。

**「这轮为什么没注入」也落盘了**（0.13.3）：`skip` 与 `empty` 带上了原因码，尺子把它们
分类计数 —— 关键是分清**设计性的安静**与**真漏**：寒暄门控占大头是好事（说明门控在干活），
`below-min-score` / `no-hits` 占大头才是要动参数的信号（前者看 `scoreStats` 定 `minScore`，
后者说明库里真没有）。原因那句判据会说这句，早期日志没有原因码时它会明说「再聊几轮」。

同一节还会打出**注入分**：`hit` 行的 `scores[0]` 中位 / p10 / p90，以及「第 1 条与最后一条的
落差中位」—— `minScore` 该定多高，先看这排数字。

**但光看分布定不了门槛，因为分布里没有代价**（0.13.6）：分低的那批里可能正躺着几条真被打开过的。
所以同一节再算一条 **`minScore` 代价曲线**——把线上日志里每条注入指针的（分数, 后来有没有被打开）
配成对，再扫一遍「门槛抬到这里会怎样」：

（下表只是**版式示意**；真表由你自己日志里的配对现算，数字会随账本长起来。）

| 门槛 | 留下注入 | 砍掉 | 其中被打开过的 |
| --- | --- | --- | --- |
| ≥ 3.152 | 19 | 1（5.0%） | 0 |
| ≥ 3.745 | 17 | 3（15.0%） | 0 |
| ≥ 5.441 | 15 | 5（25.0%） | 1 |
| ≥ 7.52 | 10 | 10（50.0%） | 2 |

最后一行永远是「最高分 + 0.001」，保证「全砍掉」那一行总在。第四句判据只报一个数：
**零代价上限**——砍得掉、又没丢过任何被打开过的指针的那道最高门槛（如「minScore ≥ 3.745 能砍掉
3 条注入（15.0%），且没丢过任何被打开过的指针」）。被打开过的样本不足 5 条时它只说「曲线只能
看形状」，不会催着你调参——**分母太小的账不能拿来定门槛**，这是这个插件里反复出现的那条教训。

**窗口该多宽，两半合起来看**：上半是 hit@k 曲线（离线卷子给的「上界」），下半是打开率
（线上实测的「到底用得着吗」）。

| 窗口 | 词面（每轮注入走的就是这条） | rrf（开了语义时） |
| --- | --- | --- |
| 1 条 | 67.2% | 74.7% |
| 3 条 | 89.1% | 90.8% |
| 4 条 | 91.4% | 94.3% |
| 5 条 | 93.1% | 95.4% |
| 6 条 | 93.1%（**白给**） | 97.1% |
| 8 条 | 95.4% | 97.1% |
| 10 条 | 97.1% | 97.1% |

（174 条真库卷子、池 10、智谱 embedding-3；由 `bench/.tmp/guard-data.json` 离线复算。
读法：4→5 买 1.7 个点、**4→6 一个点都不买**、8 条再买 2.3 个点。）

判据写进尺子了，省得每次重新推：注入样本 < 20 条**不下结论**；打开率 < 30% 说明窗口在浪费
上下文（收窄，或把 `minScore` 抬一点）；> 60% 说明窗口偏窄（按上表放宽）。位置级曲线更直接：
第 4/5 条几乎没人打开的话，放宽就是白加字数。**诚实说一句**：`used` 只证明模型读了那篇
（打开率是代理指标），不证明它被用进了回答；真正的「用没用上」要靠人读几轮对话才判得准。

### 消费侧门控（`confidence.js`）：让记忆自己证明值得占上下文

检索只回答「像不像」，回答不了「值不值得占上下文」。一句话问得巧，能把库里十几条
陈年笔记全捞上来；`maxPointers` 与 `maxChars` 只是**上限**，它们不会说「这条每次都在，
可你从来没读过」。这一层补的就是这句话。

**证据从哪来**：账本（`.engramory-recall.jsonl`）里每篇笔记的履历 —— 被带进上下文几次、
被真正读过几次。两个细节决定了这份证据靠不靠得住：

- **自我触碰不算被读**：模型刚写完一篇笔记，10 分钟内又读回来「确认一下」，那不是「这条
  帮上了忙」，那是它自己在看自己写的东西。这种读会从正反馈里扣掉（`selfTouchMs`）。
  不扣的话，一个爱自检的会话能让空笔记看起来「被反复使用」，越来越像可信来源 ——
  这是把回音当证据（自我强化）。
- **分母是真正的机会数**：只算「真的被带进上下文」的轮次，不算它躺在库里的天数。

**四个档位**（配置 `gate`，默认 `assist`）：

| 档位 | 行为 | 什么时候用 |
|---|---|---|
| `off` | 完全不干预（旧行为） | 留作对照，或你不想让插件对排序有任何意见 |
| `shadow` | 一分不改，但把「本来会降权/拦掉谁」记进账本 | 想知道这套判据在你库上会做什么，先跑几天 |
| `assist` | 把「总也用不上」「过期」的笔记**降权**（往后排，仍看得见） | **默认**。只改排序，不删任何东西 |
| `enforce` | 把证据确凿的**移出这一轮注入** | 账本上的证据你已经看懂了，才值得开 |

**判据是明文的**（阈值都在 `confidence.js` 的 `GATE_DEFAULTS`，报告与面板读的是同一份）：

- **两条线按「本库自己的基础打开率」算，不是拍一个固定次数**：一条笔记被带进上下文
  8 次、一次没被读，听着像证据；可在 5% 的基础打开率下，一条**和平均一样有用**的笔记
  也有 `0.95^8 ≈ 66%` 的概率一次都没被读 —— 拿它判死刑就是拿噪声当证据。所以门槛写成
  「期望被读到几次 ÷ 基础率」：**降权线 = 期望被读 1 次**（2.9% 的库 → 携带 35 次），
  **抑制线 = 期望被读 3 次**（同一个库 → 105 次），再与「最少携带 8 次」取大。
  基础率从账本现算（携带总数不足 50 次时退回先验 10%，报告里会说明这次用的是哪个）。
- 降权（`assist`）：过了降权线、一次都没被读 → 分数 × 0.5（往后排，仍然看得见）。
- 抑制（只有 `enforce` 会执行）：过了抑制线，**且**从第一次被带进上下文起观察 ≥ 7 天。
  「观察够久」这一条是防「一次会话里连着十几轮」凑出来的假证据。
- **「看着像没用」与「证据够判它没用」是两件事**：没过线的笔记一律不动，但账本里会留一句
  `insufficient-evidence`，「它已经开始可疑了」在报告里是看得见的。
- 过期（`assist`/`enforce` 档）：`project-` 类笔记（正在做的事）超过 180 天没更新 →
  同样降权。**过期只降权、不拦** —— 旧不等于没用，一份三年前的接口笔记可能依然是对的，
  而一份「正在进行」的笔记过期往往只是那条线停了。
- 信任：被读过多次、且读到的比例够高（≥ 25%）→ 标为可信，档位再高也不动它。

**每一轮都记账**：账本行里会写这一轮在哪个档（`gate`）、把谁降权了（`demoted`）、
哪些已经够到抑制线（`suppressReady`）、`enforce` 档真拦了谁（`suppressed`）。
`suppressReady` 是为 `shadow`/`assist` 档准备的：它让「要是我开 enforce，会拦掉谁、
拦对了吗」变成可以从账本里算出来的问题，而不是靠猜。

**推荐的推进顺序**：`shadow` 跑几天 → 看周报里「假杀率」（本来会被拦、后来真被读过的
比例）→ 假杀可接受再上 `assist` → 有把握了才考虑 `enforce`。全程零风险的部分是前两步：
它们不改任何注入，只让你看见。

### 按需检索工具

`engramory_search({ query, limit? })`：自动召回没命中时的兜底。模型可以主动搜记忆库
（BM25、只读、离线），拿到「文件名 + 命中片段 + 分数」，再自己 `read` 正文。

## 桥与别名：两个调参口

召回准不准，九成取决于「你会怎么问」到「笔记里怎么写」之间那座桥。桥有两层。

**全局同义表**（`bm25.js` 的 `SYNONYMS`，177 条 / 519 个值）管的是一类说法：
`梯子 / 翻墙 / 科学上网` → `proxy, http-connect, socks5`。同义项默认按 0.5 权重计入，
查询命中了某个键时，桥到的技术词按 1 计入、该键自身的字面碎片降到 0.5（上一节的取舍）。

**笔记自己声明的别名**（frontmatter 的 `aliases:`）管的是这一篇：

```yaml
---
name: reference-example-proxy-node
description: 自建代理节点的现状架构：双端口为主 + 兜底链路
type: reference
aliases: 梯子, 翻墙, 科学上网, 节点, 代理
---
```

别名按重复两遍计入，也就是满权重：它是作者点名说的「这篇就管这个词」，
语气比全局桥硬。所以要区分几篇长得很像的笔记用别名，要教会一类说法用同义表。

别名机制早就在仓库里，但在真实库里长期零使用（一份库里 0 篇写 `aliases:`）。
给 8 篇补上之后，别名探针那一组从 1/5 提到 5/5。

### 桥和别名是怎么挖出来的

凭直觉往表里塞词，看着很努力，其实大半是零收益，因为同义词只有在语料里真的存在时才加分。
流程是反过来的：

1. **先量语料**：拉出全库词项与 `df`（出现该词的 chunk 数）。`df` 低的才是专有名词、
   技术栈名，才有区分度；`df` 等于库容量的（如 `kb`）是噪声，拿它做桥等于没有。
2. **再倒推口语**：拿着这些锚点问自己「人会用哪句大白话问它」。
3. **只写语料里存在的锚点**：库里没有 `latency` 这个词，就不写 `节点慢: ['latency', ...]`，
   写了永远是零分，只会让表看起来很长。
4. **每条桥都要有 query 证明它在工作**：探针的桥探针十组就是干这个的。
   某条桥在探针里没有任何一条 query 用到，就删掉。
5. **死值自检**：同义桥的每个值都要在语料里真的存在才有分。指向一个库里根本没有的词，
   效果是写了等于没写：不报错、不降级，最安静的那种失效。探针每次跑都数一遍。

第 6 条是 0.11.0 补的：**覆盖率尺子**（`npm run bench:bridges`，`bench/bridge-coverage.mjs`）。
它把第 5 条从「数一个总数」变成「分格里看」，三张卷子各量各的：

| 量什么 | 数字 |
|---|---|
| 死值（真库口径） | **117 / 519（22.5%）**，整键全死 9 个键（`下载`、`轮转`、`并发`、`乱码`…） |
| 死值分格（真库 × 合成语料各一份 `df`） | 两边都活 **90** · 只对真库有用 **312** · **只对基准有用 39** · 两边都死 **78** |
| 覆盖：低 `df`（≤5）锚点词被桥接住 | **118 / 2732（4.3%）**；一个桥都接不住的 chunk 占 **50%** |
| 使用面 | 合成语料 704 条 → 走到 **69** 个键；探针 36 条真实 query → 走到 **44** 个键；两张卷子都没走到的键 **76（43%）** |
| 逐键消融 | 被走到的 101 个键里 **57 个**拔掉不影响任何名次；整表拔掉——合成 hit@1 355→327、hit@4 475→365，**真探针 27→24 / 36→31** |

**这轮量完的结论是「表不动」。** 78 个两边都死的值看着像该删的，但它们是 `permission /
idempotent / mojibake / xpath / selenium / watchdog / dsn / backoff` 这一类**通用工程词** ——
插件是发布出去的，要服务任意一份记忆库，别人库里当然可能有它们。尺子的用途不是自动删词，
而是把这笔账**量出来、摆明**：谁想按自己的库裁剪，`npm run bench:bridges --store <自己的库>`
会直接列出他那一份的死值清单。覆盖率的 4.3% 同理 —— 它量的是表的大小，不是表的好坏：
手写表永远盖不住几千个锚点，真问题是「没被接住的锚点里有多少条真人 query 会问」，
而那要靠探针的口径/桥组回答（现在 21/21 与 10/10）。

第 5 条当场抓出 32 个值指向不存在的词（`latency`、`slide`、`cert`、`gpt`…），
其中 7 个是同一轮刚加的，也就是说「按语料锚点写桥」这件事当时并没有做到。删掉后剩 25 个，
但那 25 个不该删：它们随插件一起发布，要服务的是任意一份记忆库（别人库里当然可能有
`permission`、`hang`、`gpt`）。**「这条桥有用吗」的答案取决于问的是哪份库**：
自用可以按自己的库裁剪，发布就得留通用词。两者混在一起谈，就会一边留着死重、
一边把别人的桥删掉。

## 配置

改 profile 的 `cordis.patch.yml`（改完要重启 App 才生效，宿主进程会缓存 ESM 模块）：

```yaml
- insert:
    - id: engramory-recall
      name: dsh-engramory-recall
      config:
        enabled: true            # 总开关
        storePath: ~/memory/engramory
        maxPointers: 4           # 最多注入几条指针（实测 4→5 买 1.7 个点，4→6 一个点都不买，见「召回日志与打开率」）
        maxChars: 480            # 注入文本硬上限（字符）
        minScore: 0              # 提高它可以过滤弱命中
        gate: assist             # 消费侧门控：off / shadow / assist / enforce（见「消费侧门控」）
        gateStaleDays: 180       # `project-` 类笔记多久没更新算过期（只降权，不拦）
        queryChars: 400          # 检索的查询字符预算（超长消息头尾各取一半）
        nudge: true              # 「长期记住」意图提示
        nudgeSediment: true      # 沉淀提醒
        sedimentMinTurns: 12     # 沉淀提醒的轮次门槛
        guardWrites: true        # 写入闸门
        usageLog: true           # 召回落盘日志（打开率尺子的数据源，见「召回日志与打开率」）
        dedupeInjection: true    # 注入去重
        rotationPenalty: 0.5     # 跨轮轮换降权（1 = 关闭）
        rotationHistory: 32      # 轮换记忆长度
        # 下面两个默认关闭：实测无净收益，留着等库里出现半年以上的跨度再复测
        decayHalfLifeDays: 0     # freshness 指数衰减
        recencyWeight: 0         # 时间乘性加权
        autoArchive: false       # 全自动归档（默认关：归档是改库，先手动点几轮再决定）
        indexMaxLines: 200       # 索引 MEMORY.md 的行数上限
        indexMaxBytes: 25600     # 索引的字节上限
        registerSkill: true      # 注册 engramory 技能（写入纪律的正文）
        # 语义检索接缝（默认关闭）：本地模块路径，或 host-llm / host-embed
        semanticProvider: ''
        semanticTimeoutMs: 2000
        semanticPool: 50          # 提供方看多宽的候选池（默认 50；量过的端点用 10 更准）
        semanticFusion: rrf       # 没量过就 rrf；量过用 guarded；trust 只在确认端点很准时才开
        semanticGuard: 0.5        # 只有 guarded 用：插队需要的分差门槛
        semanticHostRoute: ''     # 只有 host-embed 用
```

`autoArchive` 打开后归档会在两处自动跑：每轮 pre-step 兜底（10 分钟 TTL，不会每轮扫库），
以及写完记忆库的那一刻（触发源记为 `write`）。它仍然先备份、仍然过写入闸门，只是不再等你点按钮。

## 成本对照

| | 每轮常驻 | 命中时 | 出网 |
|---|---|---|---|
| Memmy（已卸载） | 550–1550 tokens 自动召回 | 注入记忆正文 | embedding / 摘要走 `api.memmy.cn` |
| DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
| **本插件** | **0** | **≤480 字符，且只有指针** | **无** |
