Skip to content
dsh.fish
Bundle

dsh-engramory-recall

Local BM25 auto-recall, fail-closed write guard, static doctor and a memory panel for the Engramory markdown store — no network, no model, no daemon.

Source
yffo
License
MIT
Updated
Updated 10 days ago

Readme

# dsh-engramory-recall

给 [Engramory](https://github.com/tinqiao-oss/engramory) 格式的 markdown 记忆库加四件事:

1. **本地自动召回**:模型不用自觉去读索引,相关的既有笔记会自己浮现到这一轮里;
2. **写入治理**:fail-closed 校验闸门、落盘审计、静态体检;
3. **自动归档**:把追加式的「第 N 批」章节整段搬进 `<笔记>-history.md`,主文件只留结论与现状;
4. **可观测性**:GUI 里一个「记忆」标签页,看得到库里有什么、这轮召回了什么、写得干不干净。

全部用 `node:` 内置模块实现:零第三方依赖、零网络、**默认零模型调用**、零常驻进程
(只有你显式开启可选的语义增强时,才会走宿主的模型服务)。

5. **别的宿主也能用**:`mcp.js` 是一个零依赖的 MCP(stdio)服务器,同一份记忆库可以挂给
   Claude Code / Codex / Cursor 这类支持 MCP 的宿主 —— **只读**(检索与读笔记),写治理留在下面。

## 底线

| 底线 | 本插件如何守 |
|---|---|
| 数据不出本机 | 纯 `node:` 内置模块;`capabilities` 只需要 `fs-read` / `fs-write`,不需要 network |
| 不每轮塞正文 | 只在**命中**时注入,且只注入「文件名 + 一行片段」;没命中一个 token 都不注入 |
| 纯文本、可跨 Agent | 只读记忆库的 markdown,不建库、不产生索引文件 |
| 有上限 | 注入量硬上限 `maxChars`(默认 480 字符)+ `maxPointers`(默认 4 条)+ 预算降级阶梯 |
| 不静默改数据 | 召回只读、体检只报告、归档每次都要显式确认且先备份、写入闸门只拒绝不代写 |

## 一分钟选型:先看会不会被卡住

选型的顺序是先排除,再比功能。下面四条否决线是两次真实卸载换来的(云端 Memmy 装上又卸了两遍)。

| 否决线 | 判据 | 倒在这条线上的 |
|---|---|---|
| **数据不出本机** | 正文、embedding、摘要,一个字节都不该离开这台机器 | Memmy(`api.memmy.cn`)、Hindsight(默认上云)、DSH-SEP(抽取走 `api.deepseek.com`) |
| **用不上就不计费** | 没命中就该是零 token、零检索,而不是"少花一点" | Memmy(每轮无条件 550–1550 tokens)、DSH-SEP(每步 ~1250 字符 guidance + ≤4200 字符快照)、OpenViking(强制服务端 + 每轮注入) |
| **不做不可逆的事** | 可以不写,不能替人删 | 所有"自动遗忘 / 自动合并"型方案:删掉的记忆找不回来,而记忆系统的价值恰恰在长尾 |
| **三年后还能跑** | 不需要编译原生模块、不需要常驻服务 | 带 `better-sqlite3` 编译、rust 工具链、独立 daemon 的方案,会在某次系统升级后让你回来看这篇 README |

四问都过了,才轮到比功能。功能这一层,本插件的取舍是**把不确定性变成可测量的东西**:
新机制必须在库上跑出数字,跑不出来就不上(文末九条被证伪的路就是这么留下的)。

## 跟同类插件对照

| 维度 | **本插件** | Memmy | DSH-SEP | dsh-mneme | 灵枢 dsh-memory | hindsight |
|---|---|---|---|---|---|---|
| **存储真相源** | markdown 文件(人可读、可 git、可手改) | 云端 | 云端抽取 | SQLite(markdown 是可重建的投影) | rust 索引 | 服务端 |
| **出网** | 无 | `api.memmy.cn` | `api.deepseek.com` | 无 | 无 | 默认上云 |
| **每轮常驻开销** | 0;命中才 ≤480 字符 | 550–1550 tokens | ~1250 + ≤4200 字符 | 未核实 | 未核实 | 未核实 |
| **召回** | 门控 → 头尾取样 → BM25 + IDF 选词 + 同义桥 + 别名 | embedding 向量 | 抽取式 | — | BM25 + 时间衰减 | BM25 + 时间乘性加权 |
| **写入治理** | fail-closed 闸门 + 写入审计 + 沉淀提醒(三层) | 无 | 无 | 无 | 只认 `committed` 字段 | 无 |
| **自动归档** | 不截断、整段搬家、可再分层、可回滚 | — | — | 截断 + 存 `_full_content` | — | — |
| **可观测** | 六页签只读(召回日志 / 笔记 / 体检 / 索引原文 / 归档 / 写入审计) | 未核实 | 未核实 | 未核实 | 未核实 | 未核实 |
| **依赖** | 零(全 `node:` 内置) | 云端 SDK | 云端 SDK | SQLite | rust 工具链 | 服务端 |

表里的「未核实」是故意留的:没读源码也没实测过的格子就空着。一份对比表最坏的失败方式不是漏项,
而是把猜测写成事实。

### 有几件事不是本插件独有的

写清楚这些,省得读者以为独家:**零模型调用**至少有四家同类做了(`thoth-mem`、
`@max-null/dsh-memory`、`@tr1v3r/dsh-ltm`、`@jipika/dsh-memory`);
**中文词法检索**也不是独家(`@max-null/dsh-memory` 做 CJK 单字加 2-gram,
`@tr1v3r/dsh-ltm` 做 CJK 二元加 FTS5 混合重排);**markdown 当真相源**有 basic-memory(AGPL-3.0);
**写入安全**有 `@jipika/dsh-memory`(写前 guard hook)和 `@tr1v3r/dsh-ltm`(串行写入 + 备份 + 回滚)。

### 确实少见的三件

1. **索引上限做成「拒绝写入」而不是「注入预算」**。同类的超限处理都是软的:
   `@jipika/dsh-memory` 的 `indexBudget` 默认 12000 字符,超了自动回退成索引模式;
   `@max-null/dsh-memory` 逐条截断注入;`thoth-mem` 用 strict character budget。
   本插件的 `index-guard.js` 是在**写入那一刻**拒绝让 `MEMORY.md` 超过 200 行 / 25KB,
   理由是索引每次会话都要整篇读,超限的部分会被静默地不再召回,软处理救不了这件事。
2. **可逆归档**。追加式章节整段搬家、不截断、搬前备份、两段式确认;同类里没有等价物。
3. **MIT + 零依赖 + 零进程**。最强的同生态对手 basic-memory 是 AGPL-3.0 + Python 进程 +
   FastEmbed 权重下载,装它要接受另一套运行时。

### 短板

- **公开基准只跑了一半**:LongMemEval-S 500 题跑出 RecallAny@5 98.2%(见下),但那只测英文词面
  检索;`thoth-mem` 公布的 470 题版本(RecallAny@5 432/470、Recall@5 84.8%、NDCG@10 0.8165)
  用的是别的 harness,两边数字不可相减。真正的「跨插件排名」仍然不存在。
- **自带路径上没有语义检索**:没有内置 embedding、没有向量库,开箱就是纯词面;同义改写要靠
  手工同义表覆盖,没覆盖到的就是漏检。想接语义得自己指一个提供方(`semanticProvider`,见下),
  插件本身不替你选模型、不替你出网。
- **只有 DSH 里是「全功能」**:0.8.0 起带一个零依赖的 MCP 服务器(见下),任何支持 MCP
  的宿主都能检索与读笔记;但写入闸门、归档、体检、沉淀提醒、面板这些**只在家用版(DSH 插件)
  里有** —— 别的宿主拿到的是只读视图。`claude-mem` 覆盖 7 个宿主、`thoth-mem` 覆盖 4 个,
  我们覆盖「1 个全功能 + N 个只读」。
- **没有多端或团队同步**:basic-memory 有云同步,本插件没有。
- **写入闸门可以被绕过**:`guard` 只拦 `write` / `edit` 工具调用,`bash` 重定向它看不见;
  `@jipika/dsh-memory` 的 guard 挂在更底层的 `tools/pre-execute`,覆盖更广。

## 召回质量:三把可复算的尺子

### 一、合成基准(`node bench/run.mjs`)

224 篇程序生成的笔记(中文书面 83 / 英文术语 101 / 口语体 40,其中 37 篇写了 `aliases`)、
704 条查询、固定随机种子。查询按三种风格分开记分:`verbatim` 抄笔记自己的写法(对照组)、
`colloquial` 只用口语说法(与正文没有字面重叠)、`keyword` 口语症状加域特征词
(同域候选很多,必须靠事件词分辨)。语料是程序生成的,不依赖任何私人库,任何人可复算:
`npm run bench` 整跑 4 秒,零依赖、零网络,消融表里对我们不利的那几行也照打,
CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。

| 引擎 | 总 hit@1 | 总 hit@4 | verbatim | colloquial | keyword |
|---|---|---|---|---|---|
| **本插件** | **50.4%** | 67.5% | 98.7% | 34.8% | 15.6% |
| 教科书 BM25(同分词口径) | 43.2% | 46.9% | 100% | 17.9% | 17.9% |
| 教科书 BM25(中文逐字切分) | 47.2% | 53.4% | 100% | 20.1% | 28.1% |
| 瞎猜(地板线) | 0.6% | 2.6% | 0.4% | 0.9% | 0.4% |

(分风格单元格是 hit@1。地板线是实测的随机命中率:`keyword` 的同域候选有 14 篇,瞎猜大约 7%。)

消融,同一份语料,每次只动一个机制:

| 改动 | 总 hit@1 | 相对本插件 |
|---|---|---|
| 本插件 | 50.4% | — |
| 去掉同义表 | 46.4% | -4.0pt |
| 去掉笔记别名(不再声明 `aliases`) | 56.1% | **+5.7pt** |
| 去掉字段加权(`TITLE_EXTRA` 24 → 0) | 49.1% | -1.3pt |
| 同义键碎片不降权(回到改动前) | 49.0% | -1.4pt |
| 桥权重与同义表一致(都算 0.5) | 51.4% | +1.0pt |

「去掉同义表」这一行要打个折读:合成语料的口语组 / 桥组查询本身就是照着「需要桥」造的,
同义表也为它们补过词,所以 **-4.0pt 是这张表在**这份**卷子上的上界,不是它的通用价值**。
同口径在真人查询上的数字小得多:探针那 36 条真实 query,拔掉整表 hit@1 27 → 24、hit@4 36 → 31
(`npm run bench:bridges --ablate`)。成因也量出来了:519 个同义值里 **39 个只在合成语料里有落点**,
在真库上一个词项都不存在(见「覆盖率尺子」一节)。

「去掉笔记别名」是 **+6.7pt**,在总分上反而更好看 —— 这个语料里别名是把双刃剑:
它帮自己(写了别名的 37 篇 hit@1 94.6%,没写的 187 篇 23.0%),
也把同域别的笔记的口语查询抢过来(语料里同域的别名共用同一个口语域词,而别名是程序按比例随机发的)。
真库里别名是人手写的、更克制,所以别拿这一行下结论 —— 判断别名只看探针的别名组(5/5)。

最后两行是一次明确的取舍。改动前,桥组(查询里的词在目标笔记里一个字都没有)
只有 27.3%:查询自己的字面碎片(`崩了` 的「了」在别的笔记里到处都是)按满权重计分,
把桥到的词压了下去。修法有两种,一种是把命中的同义键自身的字面碎片从词项里剔掉,
实测**打红了真实存在的测试**:`test/recall.test.mjs` 的注入预算阶梯用 `代理节点与部署` 当查询,
而 `代理`、`部署` 既是同义键、也是笔记正文里的原词,剔掉等于把真证据扔了。
所以改成**降权不剔除**(`BRIDGED_FRAGMENT_WEIGHT = 0.5`,桥到的技术词算 `BRIDGE_WEIGHT = 1`)。
降权只针对键的**碎片**(单字、相邻双字),键本身照旧算满权重 —— 这一条是量出来的:
一开始连键本身一起降权,`verbatim` 对照组从 100% 掉到 95.1%,放开键本身后回到 98.7%,
而桥组一点没退。道理很直白:查询里原样出现的那个词是实打实的证据,降权的理由只是
它拆出来的碎片在别的笔记里到处都是。

代价是与「桥权重打回 0.5」相比总分少 1.0 个点(51.4% → 50.4%)。接受它的理由:
合成语料里绝大多数查询本来就有字面落点,总分被它们主导;桥是唯一在字面完全无交集时
还能工作的机制 —— 同一批桥查询,桥权重打回 0.5 只有 25.0%,碎片不降权 43.8%。
(历史值:修「碎片压桥」之前,那一版语料上这组查询只有 27.3%。)

「去掉字段加权」那一行要单独说:它现在是 -1.3pt(拿掉略微更差),**在一次引擎改动之前它是 +4pt**
(拿掉明显更好),而**真实库的中立对照一直说拿掉更差**(见下)。当时的冲突定位到机制上:
合成语料的标题是从正文模板派生的、与正文近乎重复,抬高标题权重只是放大域词噪声;
真实库的标题是人手提炼的。现在前两把尺子都偏向「留着字段加权」,但理由仍是上面这条 ——
不是冲突消失了,是那把合成尺子的标题变真实了一点。两者不一致时以对照 C 为准。

**长度归一的强度 B 是量出来的,不是抄的。** 教科书默认 0.75 我们抄了很久,三把尺子都说它偏大:

| 尺子 | B=0.75 | B=0.4 |
|---|---|---|
| 这份合成卷子 | hit@1 49.9% · hit@4 66.6% | hit@1 50.4% · hit@4 67.5% |
| LongMemEval-S 500 题 | @1 87.8% · @5 97.4% · 覆盖 91.5% | @1 88.4% · @5 98.2% · 覆盖 92.5% |

(这张表单独量 B。查询侧后来又加了一类框架词过滤,@1 从 88.4% 再涨到 **89.2%** —— 见 LongMemEval 一节的第 1 条实话。)
| 真库对照 C(中立那把) | top1 141/181 · recall@4 168/181 | top1 150/181 · recall@4 177/181 |

机制:同一个词在长会话、长笔记里出现得更多,`len/avgLen` 这个惩罚就变成「谁长谁吃亏」,
与相关性无关。代价在对照 A 上看得见(top1 45→43),但 A 拿文件名当查询、结构性偏袒短名,
按一直以来的规矩以对照 C 为准。扫过的值是 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.75 / 0.9,
0.3~0.5 是一片平台,取中间偏下的 0.4。

### 桥组:32 条,65.6%

| 组 | 条数 | hit@1 | 说明 |
|---|---|---|---|
| 桥组(全部) | 32 | 65.6% | 查询与目标笔记的字面重叠为 0,命中只能靠同义桥 |
| 其中公开词族 | 20 | 65.0% | 调表时看得见的词族 |
| 其中留出词族 | 12 | 66.7% | 没参与调表的词族,看一点泛化 |

这组查询是从引擎自己读的那张同义表里长出来的,所以它证明的是**扩展管道通不通**,
不是「表能不能猜中没见过的说法」,后者只有留出那 12 条能说一点。
真正的瓶颈在供给面:这张表有两层来源,口径必须分开说 ——

- **前四波是从真实库里挖出来的**(专有名词、某个人的叫法),换一套词汇就大面积失效;
- **第五波是通用技术口语**(「变慢」「退不回去」「改表结构」这类任何写中文技术笔记的人都会用的说法),
  不依赖具体某个库,装到别人机器上也能命中。

新表扩容后,这套语料的 16 个域词、14 个事件词全部有桥;
但仍有一批口语键因为自带字面碎片而够不上「纯桥」资格被丢弃(本次丢弃 22 组)。
704 条查询里,口语风格那批字面重叠为 0 的只有 30 条 —— 绝大多数「口语查询」
仍能靠碎片碰到笔记,真正需要跨说法的只有那几条。

未命中的查询里,101 条的 top-1 是同一领域里搞错了事件,242 条是同一事件里搞错了领域,
「完全不沾边」9 次、「一条都没召回」0 次。失败都是「差一点」,没有灾难性的空召回。

### 二、真库探针(`npm run probe`)

跑在自己的记忆库上,分三组 query 各测一层机制。数字随库变化,每次跑都打印语料指纹,
指纹变了会直接警告「分数不可直接比较」。

最近一次:**66 篇 / 200 个 chunk**(指纹 `cbe8d93f9d67`)。这组数字的用处是「别掉分」,
不是跨库比较 —— 换一台机器、库里多写一篇笔记,绝对值都会动(下表的条数就是当时的语料规模)。

| 组 | 条数 | 测什么 | 最近一次 |
|---|---|---|---|
| 口径 | 21 | 真实场景的中文口语问法 | recall@4 21/21 |
| 桥探针 | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 10/10 |
| 别名探针 | 5 | 笔记自己的 `aliases:`(关键词在全局桥里一个都没有) | recall@4 5/5 |
| 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 117 个死值(均为通用词) |
| 对照 A | 200 | 文件名当查询的底线(对标题权重有偏袒) | top1 47/200 · recall@4 100/200 |
| 对照 B | 175 | 笔记中文标题当查询的底线(同样偏袒) | top1 170/175 · recall@4 174/175 |
| 对照 C | 187 | 正文独有词当查询(该词在任何标题里都没有) | top1 155/187 · recall@4 182/187 |

对照 A 和 B 都拿标题当查询,所以它们对「标题里的词多计几遍」这类参数是**结构性偏袒**的:
抬高标题权重几乎必然让这两组变好。它们既不该当目标函数,也不能用来证明「抬高标题权重是安全的」。
唯一中立的是对照 C:查询词取自某篇正文、且任何标题里都没有它,此时抬高标题权重
只会让标题里恰好撞上这个词的别的笔记平白加分、把正确答案挤下去。`TITLE_EXTRA` 的值就是按 C 定的。

探针存在的理由值得单说:`bm25.js` 里的同义表与 `EXTRA_WEIGHT` 是语料统计量撑起来的经验值,
在小 fixture 里测不出差异。写过一版单测,权重从 0.5 改到 0.8 两边都绿,
因为小库里 df 结构整个反过来。那种测试给的是假安心,已删掉,证据改由探针在真实库上产出。

它也确实拦下过一次误判:`EXTRA_WEIGHT` 一度被提到 0.8(当时量到口语 query 19/22 → 20/22),
但那是同义表还没补全时量的。等最后几条桥补完再重量,0.5 和 0.8 的三项数字一模一样
(口径 19/21、对照 A 22/82、对照 B 136/142),说明那 1 分来自补桥、不是来自权重。0.8 因此退回 0.5。
**A/B 要在最终状态上做,中途的差值可能只是「另一半还没做完」。**

还有一个坑:探针跑的是真实记忆库,库一变分数就会动。撞过一次,某篇笔记里逐字抄了探针的 query,
自己成了第一名,把真正的目标挤下去。现在写调优笔记一律不引用探针原文。

这个坑现在有机器化的答案:每次跑探针都打印一枚语料指纹(`store.js` 的 `corpusFingerprint`,
把每个 chunk 的 `id / title / description / aliases / body` 哈希成 12 位十六进制),并和基线比对:

- **指纹相同、数字有变**:这是一次可信的 A/B(改了代码,卷子没换)。
- **指纹不同**:直接警告分数不可直接比较。

指纹的第一版只哈希了 `id + body`,于是只改 `aliases` 时它纹丝不动,而 `aliases` 是被计两遍、
实打实改变排序的。结果探针一边说「语料没变、这是可信 A/B」,一边把一次语料改动伪装成了代码改动。
**一个漏字段的指纹比没有指纹更坏**,因为它是权威口吻的错误。现在指纹覆盖所有喂进索引的字段,
并有专门的回归测试守着。

### 三、公开数据集:LongMemEval-S

前两把尺子都是自己出的卷子,这一把不是:LongMemEval 是 ICLR 2025 的记忆评测集
(`xiaowu0162/longmemeval-cleaned`)。我们跑的是 S 版,500 题,每题一个约 48 个 session 的
haystack,题目分六类。

做法:一个 session = 一篇笔记(角色前缀保留),query = 问题原文(**不读** `question_date`,
也**不看** `has_answer` 标注),命中 = gold session 进 top-k。500 题跑 18 秒,零模型调用。

| 指标 | 全部 500 题 | 教科书 BM25(同分词口径) |
| --- | --- | --- |
| RecallAny@1 | **89.2%** | 86.2% |
| RecallAny@5 | **98.2%** | 97.0% |
| Recall@5(gold 覆盖率) | **92.5%** | 90.7% |
| MRR@5 | **0.9251** | 0.9056 |
| RecallAny@10 | **99.0%** | 98.2% |

| 题型 | 题数 | RecallAny@1 | RecallAny@5 | Recall@5 | MRR@5 |
| --- | --- | --- | --- | --- | --- |
| knowledge-update | 78 | 98.7% | 100.0% | 99.4% | 0.994 |
| multi-session | 133 | 88.0% | 98.5% | 86.0% | 0.920 |
| single-session-assistant | 56 | 100.0% | 100.0% | 100.0% | 1.000 |
| single-session-preference | 30 | 60.0% | 90.0% | 90.0% | 0.708 |
| single-session-user | 70 | 92.9% | 100.0% | 100.0% | 0.950 |
| temporal-reasoning | 133 | 85.0% | 97.0% | 88.5% | 0.894 |

三句必须说在前面的实话:

1. **这 3.0 个点的来源要说清楚。** 本插件比教科书 BM25 高 3.0pt @1,差的那部分**全部**来自三处通用改动:
   查询侧丢掉英文虚词(`what / is / the / of / did / how…`);查询侧再丢掉一类「求助/建议框架词」
   (`tips / suggest / recommend / advice / think / good / weekend…` 共 14 个,这一处单独值
   **+0.8pt @1**、MRR +0.005,最弱的偏好分片 @1 从 46.7% 抬到 60.0%);以及把长度归一常数从教科书的
   0.75 调到 0.4。三处都与中文无关 —— 是我们自己的机制(同义表、别名、字段加权、中文分词)在这份数据上
   没被激活:LongMemEval 是英文的,session 没有标题、没有别名、没有中文口语,它们在这里没事可做。
   B 的取值不是在这份数据上单独挑的(挑法见上:三把尺子一致,真库中立对照 +9/+9)。所以这张表
   证明的是「英文词面检索不输、且基础卫生做到位」,**不是**「本插件的独门设计更好」。机制值多少,
   回上面两把尺子看。
2. **别拿它跟别家数字直接相减。** thoth-mem 公布过 LongMemEval-S 470 题、RecallAny@5 91.9%。
   我们用的是 cleaned 版 500 题 + 自己的 harness,题集版本、命中口径、读不读日期都未必一样,
   相减没有意义。能担保的是:harness 与逐题结果都在仓库里(`bench/longmemeval.mjs`、
   `bench/longmemeval-results.json`),可以逐题核。
3. **最差的仍是偏好题**(60.0%@1,改动前 46.7%):「我喜欢什么」这类问题本身没什么词面抓手,
   而且它的 16 个 miss 里有 11 个掉在第 2~4 名 —— 全是「另一场给建议的对话」,是同流派干扰而不是
   够不着。丢掉求助框架词能把 @1 抬 13.3pt,剩下的差距才是零模型路线的边界:偏好类想做得更好,
   要么开可选的 `llm.js`,要么就老老实实把偏好写进标题。

复算:

```bash
mkdir -p bench/.tmp && curl -L -o bench/.tmp/longmemeval_s_cleaned.json \
  https://hf-mirror.com/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json                     # 本插件
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json --engine unigram      # 教科书 BM25
```

数据 264MB,不入库也不进 CI;CI 里跑的是同格式的小夹具
(`bench/fixtures/longmemeval-mini.json`,四道题分别覆盖「英文命中 / 中文命中 /
多 gold 只找到一半 / 零字面重叠必失手」),判据被改动就会红。

## 工作机制

### 召回:发生在每个用户轮的第一个 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 是从另一个只做这两件事的插件整个融进来的(逻辑与措辞逐字保留),
融进来之后它就可以从 profile 里摘掉。理由不只是省一个依赖:两套闸门各管一半
(这边管笔记文件、那边管索引),判据一旦漂移就会互相打架,而这个项目已经因为
「同一个概念有两处定义」栽过三次(见文末)。一个概念一处定义。

### 写入闸门(`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`)

纯静态、只报告不动手。12 项检查:库不可读、索引指向不存在的文件、孤儿笔记、索引重复、
文件名不合规、`name` / `type` 与文件名不一致、缺 `description`、超 100KB、待办 30 天没更新、
失联路径(同一目录名出现在多个位置、其中有的已不存在)、归档建议(超 64KB 或追加章节超过 20 个)、
疑似重复主题。

三条防误报纪律:路径比较前先规范化(`~/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,与「对话 / 轨迹」并排。

六个页签:召回日志(每轮判定:注入 / 无命中 / 跳过 / 拦截写入 / 沉淀提醒 / 归档,带展开明细)、
笔记(可过滤的清单)、体检(doctor 的发现,按严重度排序)、索引原文、
归档(待归档计划 + 一键执行)、写入审计(每条放行与拒绝都留痕)。

数据来自插件注册的只读路由 `GET /api/engramory-recall/state`(面板每 3 秒轮询;
体检与归档计划按 30 秒 TTL 缓存,不重算全库)。执行口是另一条必须 `POST` 的路由
(`{ file, confirm: true }`,GET 一律 405)。用 `ctx.inject(['webServer'], …)` 注册:
Electron 载体走 `file://` + IPC 时可能没有 webServer,那时召回照常工作,只是没有面板数据。

### 按需检索工具

`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`)。**「这条桥有用吗」的答案取决于问的是哪份库**:
自用可以按自己的库裁剪,发布就得留通用词。两者混在一起谈,就会一边留着死重、
一边把别人的桥删掉。

## 它不做什么,以及还做不到什么

### 一、自愿的约束(能做到,但不做)

- **不写记忆库**:写入只由人和模型判断,走 Engramory 的纪律。一个能自己往库里塞东西的
  记忆插件,等于把用户的笔记变成模型的草稿纸。
- **不删任何东西**:没有自动遗忘,没有自动合并。「合并」意味着原文消失。
- **不建第二份存储**:不建 SQLite、不建向量库、不生成索引文件。markdown 是唯一真相源,
  可读、可 diff、可手改、可跨 Agent 共用。

### 二、真的短板(还做不到)

- **不替你上语义**:没有内置 embedding、没有向量库。中文用「单字 + 相邻双字」切分,
  同义改写、隐含意图会漏检,目前唯一的桥接是一张 177 条的手工同义表。
  0.9.0 起留了接缝(`semanticProvider`,见下):你可以挂本地模块或宿主模型来重排 / 改写,
  但**默认那一条路径仍然是纯词面的**,我们不会偷偷替你发一个请求出去。
- **没有跨插件排名**:LongMemEval-S 跑得了(见上),但那是一个英文词面基准,且各家 harness 不同;
  合成基准与真库探针都是自建口径,只能自己跟自己比。

### 三、曾经没做、现在补上了

- **不自动归档**:已实现。它原来确实写在这一节里,理由也写了「不可逆」。
  但确定性分层不需要 LLM,只是当时没做,所以补上了。
- **同义鸿沟**:同义表已扩到 177 条并逐条经探针验证;笔记别名也真的被用起来了。
- **不自动合并**:降级成只出报告(体检器报疑似重复主题与失联路径),不改文件。

### 路线图

| 缺口 | 计划 | 状态 |
|---|---|---|
| 追加式日志撑爆单篇 | 物理分层归档,零 LLM | 已实现 |
| 同义 / 别名鸿沟 | 同义表扩容 + 别名自动利用,零 LLM | 已实现 |
| 自动合并 | 只出报告,不自动改文件 | 已实现(只报告) |
| 语义冲突检测 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticConflicts`) |
| 自动摘要 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticSummary`) |
| 跨宿主分发 | 零依赖 stdio MCP 服务器,只读 | 已实现(`mcp.js`) |
| 跨会话覆盖 | 「结果要多样」的选桶护栏(MMR / 覆盖优先) | 量过了,**被证伪**(见「被实测证伪的路」),收益改在长度归一 `B` 上拿到 |
| 可选语义检索 | 可插拔 seam(向量 / 本地模型 / 远端 API / 宿主模型都行),默认关闭 | 已实现(`semantic.js`) |

后两条默认关闭,是为了让「零模型调用」这条基线保持可复现。开启是用户的显式选择,
而不是装完就生效:一个会自己调模型改写你笔记的插件不该默认打开。走 `ctx.llm`
意味着用宿主已经配好的模型,插件自己不配 key、不直接出网。

### 可选:语义增强(`llm.js`)

词面检索对「换一种说法」只能靠同义表,而「两篇笔记互相矛盾」这种判断词面根本测不出来
(实测过:对全库笔记两两算词面相似度,相似度最高的那些对全是不同主题)。这两件事只能过模型,
所以做成了两个**显式开关**,默认关闭:

```yaml
        semanticConflicts: true   # 注册 engramory_conflicts:让宿主模型找出互相矛盾的笔记对
        semanticSummary: true     # 注册 engramory_summarize:把一篇长笔记压成一段话
        llmProvider: ''           # 留空就用宿主的默认模型
        llmModel: ''
        llmTimeoutMs: 30000
```

关着的时候**连工具都不注册**,模型根本看不到这两个入口;开着时也只在工具被调用那一刻
才 `await import('./llm.js')` —— 静态依赖图里没有它,所以自动召回、面板、体检、写入闸门
在任何情况下都不会顺带发一次模型请求。这条承诺是结构上成立的,不是靠自觉。
(`test/llm-wiring.test.mjs` 就是钉这条的:默认只注册一个工具,且 `index.js` 里
不许出现静态 import。)

开启后会多花模型 tokens,用量上限是明说的:一轮冲突检查最多 4 次调用 × 每次 20 篇,
单篇正文超过 1200 字符会截断,**没查到的部分会如实报出来**(`semantic-truncated`),
不假装全看过。模型编造的文件名一律丢弃(只认本批真实文件名),
丢弃的条数也会报出来 —— 宁可显示「这次有 2 处输出不可信」,也不拿它冒充发现。

### 可选:语义检索接缝(`semantic.js`,默认关闭)

同义表和别名覆盖不到的说法,只有语义能救。但**默认路径不许因此变慢**,所以这里做的是一个
接缝(seam)而不是内置向量库:用户自己指一个提供方,指了才生效,指错了也不会把检索弄坏。

```yaml
        semanticProvider: ''      # 留空 = 纯词面(默认)。可填本地模块路径,或字面量 host-llm / host-embed
        semanticTimeoutMs: 2000   # 单次预算;超时/抛错/返回垃圾一律降级回词面结果
        semanticPool: 50          # 提供方看多宽的候选池(默认 50,上限 200)
        semanticFusion: trust     # trust = 提供方说了算(默认);rrf = 词面有一票否决权
        semanticHostRoute: ''     # 只有 host-embed 用:复用哪条 DSH 路由(留空 = 当前默认模型的路由)
```

提供方就是一个本地 ESM 模块,导出两个函数里的任意一个:

```js
// 1) 重排:给候选,还顺序(可以用 embedding、可以用本地小模型、可以调远端 API,随你)
export async function rerank({ query, docs, limit, signal }) {
  // docs: [{ id, snippet, matched, lexicalRank, lexicalScore }]
  return [{ id: 'reference-publish-plugin-to-npm.md', score: 0.91 }];
}

// 2) 扩展:把问题改写成几句别的说法,只用来**补空位**(词面排出来的顺序一个字不动)
export async function expand({ query, signal }) {
  return ['npm 发布 token 403', '怎么把包发到 npm'];
}
```

`host-llm` 是内置的第二种:它用宿主已经配好的模型做**查询改写**(`expand`),走 `llm.js`
的动态 import —— 与上面两个开关同一条纪律,静态依赖图里没有它。

四件事是刻意设计的,不是随口承诺:

| 行为 | 为什么 |
|---|---|
| `rerank` 默认 **trust 融合**(提供方的顺序即结果,词面只用来补满窗口),`expand` **只补空位** | 真提供方返回的是一份**全量排名**,等权 RRF 会让它自己第 2 名以后的名次反过来稀释第 1 名 —— 合成 704 条上这是 86.6% 对 52.7%(下表)。`semanticFusion: rrf` 保留给「不确定提供方靠不靠谱」的场景(那时它是安全档:26.0% 对 1.0%)。`expand` 若也拿去融合,实测 hit@1 掉 7~19pt(原因见下面「让宿主模型当改写提供方」):词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**,`added` 报补了几条。`expand` 若也拿去融合,实测 hit@1 掉 7~19pt(原因见下面「让宿主模型当改写提供方」):词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**,`added` 报补了几条。两种提供方返回候选之外的文件名一律丢弃并报数 |
| 超时**硬兜一层** `Promise.race` | 提供方可以完全忽略 `AbortSignal`(第三方代码什么都干得出来),只靠 abort 会让检索永远等下去 |
| 失败**不抛错**,降级回词面并如实报 `code` | `off` / `not-found` / `unsupported` / `provider-error` / `timeout` / `provider-empty`,检索工具返回值里的 `semantic` 字段直接给出这次到底用没用上 |
| **提供方看的是宽池**(`semanticPool`,默认 50) | 池子等于输出窗口时,提供方只能在词面已经挑中的那几条里排序 —— 语义再准也捞不动排在第 12 名的正确笔记。池宽从 5 抬到 50,池内召回从 70.3% 涨到 97.3%(`npm run bench:seam`) |
| **只管按需检索工具**,不碰每轮自动注入 | 自动召回每个用户轮都跑;挂一个要 200ms 的外部件等于给每一轮加税。热路径永远纯词面 |


#### 实测:接缝的代价与天花板(`npm run bench:seam`)

`bench/semantic-seam.mjs` 用合成基准的 704 条查询跑同一批检索,只换提供方,全程离线、零模型调用
(这条命令你自己就能复算):

| 场景 | hit@1 | hit@4 | 平均耗时 | 返回码 |
| --- | --- | --- | --- | --- |
| 关闭(默认) | 50.4% | 67.5% | 1.3 ms | `off`×704 |
| 提供方抛错 | 50.4% | 67.5% | 1.4 ms | `provider-error`×704 |
| 提供方卡住不返回 | 50.4% | 67.5% | **53.0 ms**(预算 50ms) | `timeout`×704 |
| 提供方返回编造的 id | 50.4% | 67.5% | 1.4 ms | `provider-empty`×704 |
| 离线字符三元组重排(真打分) | 47.4% | 65.5% | 1.4 ms | `reranked`×704 |
| 按 gold 重排(**作弊上界**,只验管线) | 86.6% | 86.6% | 1.4 ms | `reranked`×610 |

(候选池 = 20,评测看前 4 名。`node bench/semantic-seam.mjs --pool 4` 是另一种用法:池子等于输出量时
RRF 只能在池内重排,所以 hit@4 一动都不动(67.5%),坏的提供方只赔 hit@1。两种都量过。)

三个数字值得单独说:

- **默认零成本**:关着的时候与词面路径是同一条同步路径,1.3 ms,没有任何 import。
- **失败不改变结果**:三种坏提供方下命中与词面**逐字相同** —— 降级是「结果不变 + 如实报码」,
  不是「返回半个答案」;卡住的那种被硬兜在 53 ms(提供方完全可以无视 `AbortSignal`,所以兜的是 `Promise.race`)。
- **接缝本身不产生收益,收益上限 100% 取决于提供方**:随手写的词法变体反而赔 1.5~3.0pp
  (安全上限是作弊上界,不是期望值);而上界本身由候选池决定 —— 20 个候选里有 gold 的查询只占
  **86.6%**(610/704),所以「完美提供方」也只能把 hit@1 从 50.4% 抬到 86.6%,那 36.2pp 的缺口
  要靠**换真正不同的信号**(比如真 embedding、跨编码器)去补,插件不替你选。

`test/semantic.test.mjs`(假提供方模块:抛错、卡住、返回垃圾、编造文件名)与
`test/semantic-wiring.test.mjs`(关着时连 import 都不发生、开着时真的融合、自动注入不碰接缝)
就是钉这四条的。

#### 实测:候选池与融合模式(同一个 `npm run bench:seam`)

合成语料 704 条、输出窗口 top-4。池宽决定「提供方最多能捞多远」——池内召回就是它的天花板:

| 候选池 `semanticPool` | 5 | 20 | **50(默认)** | 100 |
|---|---|---|---|---|
| 池内召回(合成 704 条) | 70.3% | 86.6% | **97.3%** | 97.9% |
| 池内召回(真库探针 36 条) | 36/36 | 36/36 | **36/36** | 36/36 |

融合模式决定「提供方的话算不算数」(同一张卷子,只换提供方与融合):

| 提供方 | `trust`(默认) | `rrf` |
|---|---|---|
| 完美:全量排名(真 embedding 的形态) | **86.6% / 86.6%** | 52.7% / 73.6% |
| 完美:只投 gold 一票 | 86.6% / 86.6% | 86.6% / 86.6% |
| 词面倒排(最坏的可信提供方) | 1.0% / 3.8% | 26.0% / 59.7% |
| 与语义无关的稳定顺序 | 3.1% / 16.8% | 18.8% / 43.9% |
| 纯词面(不看提供方) | 50.4% / 67.5% | 50.4% / 67.5% |

(每格 = hit@1 / hit@4。)三点结论:

1. **默认 `trust`**:真提供方一定会排完整个池,等权 RRF 下它自己的低名次条目会把第 1 名挤下去 —— 完美提供方因此只拿到 52.7%(比纯词面 50.4% 几乎没有赚),`trust` 是 86.6%。
2. **`rrf` 是安全档**:提供方不靠谱时,`trust` 会一路砸到底(倒排 1.0%),`rrf` 至少保住词面的一部分(26.0%)。拿不准就用它。
3. **「只投一票」两种融合同分**:稀释来自提供方**自己的低名次**,不是来自「它参与了融合」。所以问题不在「要不要融合」,而在「按谁的顺序」。

#### 池宽在公开数据集上量不出差别(`npm run bench:ceiling`)

LongMemEval-S 500 题,每题自带的 haystack 只有 30~50 个 session(数据 `sha256_12 = d6f21ea9d60a`):

| 候选池 | 5 | 20 | 50 | 100 |
|---|---|---|---|---|
| 池内召回 | 98.2% | 99.6% | 99.8% | 99.8% |

按题型看,唯一在 top-5 有明显缺口的是偏好题(90.0%),它在 top-20 就补满。所以这份公开卷子上
**top-5 已经够了,池宽放宽买不到分数**——它的意义在库比窗口大得多的时候(合成 704 条 70.3% → 97.3%;
真库 66 篇 / 200 chunk 从池 5 起就是 36/36)。一句话:**池宽是给大库留的余量,不是公共基准上的分数。**

代价(离线估算,按池 100 算,即最坏情况):

| 项 | 中位 | p90 | 最大 |
|---|---|---|---|
| 候选条数 | 27 | 41 | 48 |
| 片段字符数 | 1916 | 2921 | 3425 |

按英文 ≈4 字符/token 粗估:一条查询约 479 token(中位)、730 token(p90)。提供方按文本缓存,
同一篇笔记反复出现只付一次;**真延迟要在有端点时量**(`npm run embed:check` 会报一次往返耗时)。

#### 内置参考提供方:OpenAI 兼容的 embeddings(`providers/openai-embeddings.mjs`)

填两个环境变量就能试;密钥只从环境变量或一个 0600 的小文件进,**不进仓库、不进日志**:

```bash
node providers/openai-embeddings.mjs --check     # 先自检:报维度与耗时(不打印密钥)
```

```yaml
        semanticProvider: 'dsh-engramory-recall/providers/openai-embeddings'
        semanticTimeoutMs: 15000   # 远端接口要给足预算(默认 2000ms 是给本机小模型的)
        semanticPool: 50
        semanticFusion: trust
```

```bash
export ENGRAMORY_EMBED_API_KEY=sk-…                                  # 或写 ~/.config/engramory/embed.json
export ENGRAMORY_EMBED_BASE_URL=https://api.openai.com/v1            # 换一家就换这两行
export ENGRAMORY_EMBED_MODEL=text-embedding-3-small
```

**embeddings 只有 OpenAI 这一种接口形状**(`POST {base}/embeddings`):Anthropic 没有 embedding 接口,
所谓「Claude 协议」在这里用不上。国产替代填 `BASE_URL` + `MODEL` 即可,例如阿里百炼
`https://dashscope.aliyuncs.com/compatible-mode/v1` + `text-embedding-v4`、智谱
`https://open.bigmodel.cn/api/paas/v4` + `embedding-3`、硅基流动 `https://api.siliconflow.cn/v1` + `BAAI/bge-m3`。

#### 哪些服务能用(以及 DeepSeek 为什么不行)

要求只有一条:提供 `POST {base}/embeddings`。常见可用名单(`BASE_URL` + `MODEL` 照填):

| 供应商 | BASE_URL | 模型示例 |
|---|---|---|
| OpenAI | `https://api.openai.com/v1` | `text-embedding-3-small` / `-large` |
| 智谱 | `https://open.bigmodel.cn/api/paas/v4` | `embedding-3` |
| 阿里百炼 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `text-embedding-v4` |
| 硅基流动 | `https://api.siliconflow.cn/v1` | `BAAI/bge-m3` |
| Mistral | `https://api.mistral.ai/v1` | `mistral-embed` |
| Together | `https://api.together.xyz/v1` | `BAAI/bge-large-en-v1.5` |
| 本机 Ollama | `http://localhost:11434/v1` | `nomic-embed-text` / `bge-m3` |
| 本机 LM Studio | `http://localhost:1234/v1` | 你加载的那个 embedding 模型 |

**不能用的两家**,原因不同:

- **Anthropic(Claude)没有 embeddings 接口** —— 「Claude 协议」跟向量无关,填了也只会 404。
  官方推荐的做法是另找一家 embeddings 服务。
- **DeepSeek 官方 API 只有对话模型**([官方模型表](https://api-docs.deepseek.com/quick_start/pricing)
  里只有 `deepseek-flash` / `deepseek-v4-pro`,没有 `/embeddings`)⇒ **你现在这把 DeepSeek key 用不上这条路**。
  想零成本试就把 Ollama / LM Studio 在本机起一个(完全不出网),想真量就挑上面任一家。

一探便知:`node providers/openai-embeddings.mjs --check` —— **404 = 这家没这个接口;401 = 密钥不对**;
成功了会报维度与一次往返耗时。

**限流与临时故障会自动扛**:`429 / 500 / 502 / 503 / 504`(以及 408/409/425)重试 2 次,尊重服务端
`Retry-After`(上限 5 秒,而且等待可被超时预算打断);`400 / 401 / 403` 这类不重试 —— 重试改不了
鉴权与请求体的问题。重试到上限的错误会写成 `embeddings HTTP 503(已重试 2 次):<服务端原话>`。

**一句实话**:这条路径的代码、测试、失败降级、重试都有,但**还没有在真 key 上量过召回**(要花 token,
得你先点头)。接缝本身的上限是量过的(上表 86.6%),提供方的质量只能真调一次才知道。

#### 真端点怎么量:`npm run bench:embed`

一条命令,两种模式:`--fake`(默认)起一个**本机假 embeddings 服务**,只读「管线与代价」;
`--real` 用 `providers/openai-embeddings.mjs` 的配置读法找真端点,那时质量列才是真的。
真库那半边要显式加 `--store ~/memory/engramory` —— 加了就意味着**你的笔记片段会发给那个端点**,
所以默认不开。

`--timeout <ms>`(默认 20000)是给这条端点的预算:**超时即降级为词面结果**,不是报错。
拿本机慢服务自证过:服务端故意每次慢 1.5 秒、预算给 800 ms ⇒ 每次检索 p50 卡在 805 ms
(预算被当真了),结果全列 `超时/失败 = 20(timeout×20)`,质量列退回词面基线 —— 端点在抖也不会拖死检索。

假向量(哈希向量 = 最坏提供方)跑出来的两组数字,正好把上面那张融合表变成实测:

| 卷子 | 词面 hit@1 / hit@4 | 假向量 + `trust` | 假向量 + `rrf` |
|---|---|---|---|
| 合成基准 704 条 | 50.4% / 67.5% | 18.3% / 25.4% | 26.8% / 43.6% |
| 真库探针 36 条 | 72.2% / 97.2% | 5.6% / 16.7% | 19.4% / 52.8% |

`trust` 对垃圾提供方是**灾难**(真库上 72.2% → 5.6%),`rrf` 也一样亏但守住一半。
所以:**不确定提供方靠不靠谱就别开 `trust`**(这也是第一次接第三方接口时的默认建议)。

代价侧(同一次运行数的):池子实际大小中位 31~39 条(不是 50,因为词面只回有分的文档)、
每次检索 p50 19~20 ms(假服务在本机,真延迟看端点);提供方按文本缓存让
**26925 条候选只发了 1994 条(省 92.6%)** —— 同一篇笔记在不同查询里反复出现时只付一次钱。

#### 复用 DSH 里已经配好的那条路由(`provider: "host-embed"`)

不想再填一遍环境变量:DSH 设置页 → 模型里配好的路由(`baseURL` + 模型 + API 密钥)可以**直接借来用**:

```yaml
        semanticProvider: 'host-embed'
        semanticHostRoute: ''      # 留空 = 用当前默认模型的那条路由;也可指名(如 openai / zhipu)
        semanticTimeoutMs: 15000
```

它按四步读宿主,每步失败都给**可照做**的提示(而不是一句「配置错误」),失败一律降级回词面:

| 步骤 | 从哪读 | 读不到时 |
|---|---|---|
| 路由 | `semanticHostRoute` → 插件的 `llmProvider` → `agentDefaultModel.currentSelection()` | 提示写 `semanticHostRoute` |
| `baseURL` | 该路由配置里的 `baseURL`;目录自带路由(openai / mistral / together / 智谱)有内置兜底 | 提示去「自定义模型 API」加一条路由,或写 `semanticHostBaseUrl` |
| 模型 | 路由 `models[]` 里名字含 embed 的那个;单模型路由直接用它;内置兜底 | 提示写 `semanticHostModel` |
| 密钥 | 路由的 `apiKeyEnv`(没有就按 `<ROUTE>_API_KEY` 派生)→ `ctx.credentials.resolve()` | 提示去设置页填密钥,并说清 ref 名字 |

三条纪律:密钥**只在这四步里过一遍**,不进返回值、不进日志、不进错误信息;凭据**每次检索现解析**
(宿主规范:不许跨操作缓存);MCP 那种没有宿主的场景会明确报 `no-host` 并提示改用本地模块提供方。



`host-llm` 能跑通不等于划算,所以这条尺子用**真宿主模型**量了一遍(2026-10-15,逐条调用本机
harness,提示词直接 `import` 产品的 `hostLlmPrompt()` —— 尺子问的就是产品要问的那句话;
采集结果缓存在 `bench/.tmp/`,`--offline` 可以只重算不重采):

| 卷子 | 条数 | 延迟(中位 / p90 / 最大) | 超过默认 2000ms 预算 | 词面 → 接缝(hit@1 / hit@4) | 池内有 gold | 任意一条改写单独检索命中 gold@1 |
| --- | --- | --- | --- | --- | --- | --- |
| 合成基准子集 | 70 | 3796ms / 11379ms / 14043ms | **70/70** | 70.0% / 87.1% → 逐条相同 | 68/70 | 50.0% |
| 真库探针(36 条真实口语) | 36 | 4230ms / 9937ms / 10978ms | **36/36** | 72.2% / 100% → 逐条相同 | 36/36 | 80.6% |

代价和收益在同一张表里,结论就一句:**信号在,但用不上**。

- **延迟**:中位 3.8~4.2 秒,没有一次落在插件默认的 2000ms 预算里。这不是「把超时调大」能解决的 ——
  按需检索是人等着的那种调用,挂 4 秒等于把插件废掉(自动召回每轮都跑,更不能挂)。
- **收益**:改写**一条也没把 gold 从前 4 名里救出来**(两种卷子都与词面逐条相同)。
- **上界**:模型给的 3 条改写里「任意一条单独检索」能命中 gold@1 的比例是 50.0% / 80.6%,
  而词面是 70.0% / 72.2% —— 真库那卷高 8.4pt,说明**改写里确实有信号**;但要用上它,
  得先有一个能判断「哪条改写更好」的打分器,而宿主只给对话接口、没有 embedding。

「不知道好坏就投票」的后果也量了(同一批改写离线重放,`--json` 里是全部逐条数字):

| 融合策略 | 合成基准子集 hit@1 / hit@4 | 真库探针 hit@1 / hit@4 |
| --- | --- | --- |
| `append`(**当前实现**:词面顺序不动,只用改写补空位) | 70.0% / 87.1% | **72.2% / 100%** |
| `gate`(只有词面什么也没查到时才用改写) | 70.0% / 87.1% | 72.2% / 100% |
| RRF,k = 1 / 3 / 5 / 60 | 51.4% / 48.6% / 51.4% / 47.1% | 69.4% / 63.9% / 61.1% / 52.8% |
| 归一化分数:求和 / 取最大 | 57.1% / 28.6% | 63.9% / 27.8% |

模型写了 3 条「更好的查询」,融合却让 hit@1 掉 7~19 个百分点(真库 72.2% → 52.8%):投票数变成
4 倍,而词面那 1 票并不比模型那 3 票更该被信任。所以 0.11.1 起 `expand` 的语义就是**只补空位**,
`semantic` 字段里的 `added` 会告诉你补了几条。

**那 `host-llm` 还开不开?** 只有两种场合值得:你要一次拿满 limit 条(它补位不排序),
或者你在自己写的脚本里拿它当**候选生成器**。插件这条路上更实际的做法是
**你(agent)自己换个说法再检索一次** —— 你有能力判断哪条结果对,而且你的模型调用不是逐条同步的。

## 配置

改 profile 的 `cordis.patch.yml`(改完要重启 App 才生效,宿主进程会缓存 ESM 模块):

```yaml
- insert:
    - id: engramory-recall
      name: dsh-engramory-recall
      config:
        enabled: true            # 总开关
        storePath: ~/memory/engramory
        maxPointers: 4           # 最多注入几条指针
        maxChars: 480            # 注入文本硬上限(字符)
        minScore: 0              # 提高它可以过滤弱命中
        queryChars: 400          # 检索的查询字符预算(超长消息头尾各取一半)
        nudge: true              # 「长期记住」意图提示
        nudgeSediment: true      # 沉淀提醒
        sedimentMinTurns: 12     # 沉淀提醒的轮次门槛
        guardWrites: 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          # 提供方看多宽的候选池
        semanticFusion: trust     # trust = 提供方说了算;rrf = 词面有一票否决权
        semanticHostRoute: ''     # 只有 host-embed 用
```

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

## 发版(打 tag 就发,不用长期 token)

```bash
# 1. 改 package.json 的 version + 写 CHANGELOG
# 2. 本地三条门槛全过(CI 里跑的是同样三条)
npm test && npm run bench:check && npm pack --dry-run
# 3. 提交、打 tag、推上去 —— 剩下的交给 GitHub Actions
git commit -am "release: 0.9.1" && git tag -a v0.9.1 -m "0.9.1" && git push origin main --tags
```

`.github/workflows/release.yml` 会在 tag 推上去后跑:校验 tag 与 `package.json` 一致 → 测试与基准
→ `npm publish --provenance` → `npm run release:verify`(**直查版本端点**,把 registry 上的
`dist.shasum` 与本地 `npm pack` 的逐字比对 —— 退出码不算数,这才算数)。tag 早打过、当时发不出去
的情况用 `gh workflow run release.yml` 手动触发,版本以 `package.json` 为准。

它**不用任何长期 token**:走 npm 的 Trusted Publisher(GitHub OIDC)。npm 包的
Settings → Trusted Publisher → GitHub Actions 要填四个字段,一个都不能错:

| 字段 | 填什么 |
| --- | --- |
| Organization or user | `yffo` |
| Repository | `dsh-engramory-recall` |
| Workflow filename | `release.yml`(只写文件名,别写 `.github/workflows/`) |
| Environment | 留空(workflow 里没有 `environment`,填了就必然对不上) |

为什么非要走这条路:长期 token 正在被 npm 关掉(bypass 2FA 的 token 直接发布 2027-01 起失效),
而普通 token 每次发布都要 6 位 OTP —— 人肉发版迟早卡在口令上(2026-10-15 就卡过一次,`EOTP`)。

### 发不出去时,先看这两行日志

2026-10-15 的 0.9.0 首发布连挂两次,两次的报错都是**假线索**,真因只在日志里:

| 日志里出现 | 真因 | 修法 |
| --- | --- | --- |
| `404 Not Found - PUT https://registry.npmjs.org/<pkg>`,紧挨着一句 bypass-2FA 提示 | `actions/setup-node` 的 `registry-url` 会写一行 `_authToken=${NODE_AUTH_TOKEN}` 并塞占位值 `XXXXX-XXXXX-XXXXX-XXXXX`;npm 只要在配置里看到 token 就**不再走 OIDC**,于是拿假 token 去 PUT | 去掉 `registry-url`;workflow 里已加一步断言 `_authToken` 为空(只打有无、不打值) |
| `ENEEDAUTH need auth ... npm adduser` | npm 把 OIDC 交换的失败**全吞掉**(`lib/utils/oidc.js` 只写一行 verbose 就返回 undefined),所以「没绑定」和「字段不符」长得一模一样 | 看预检步的 `交换接口 HTTP …`(见下);或给 publish 加 `--loglevel verbose`,日志里会出现 `Failed token exchange request with body message: …` |
| `交换接口 HTTP 404` + `OIDC token exchange error - package not found` | 这个包在 npm 上**没有任何 Trusted Publisher 绑定**(注意它不说「没绑定」,说的是「包找不到」——registry 惯用的模糊话术) | 去 Settings → Trusted Publisher → GitHub Actions 绑上那四个字段 |
| `交换接口 HTTP 401` + `OIDC token exchange error - unauthorized` | id-token 本身没拿到或被拒(`permissions: id-token: write` 缺失、或请求里带的是假 token) | 检查 workflow 的 `permissions` |
| `交换接口 HTTP 201` + `{"token": …}` | **这就是成功** —— registry 用 `201 Created` 表示新建了一个临时 token(不是 200)。预检的判据是「回包里有没有 `token`」,不是 HTTP 码 | 什么都不用做,往后走 |

工作流里那步「**预检 OIDC 交换**」就是为这张表服务的:它自己发一次与 `npm publish` 内部完全相同的
交换请求(`POST /-/npm/v1/oidc/token/exchange/package/<name>`),把 registry 的原话打印出来,
并且排在测试之前 —— 缺绑定时 30 秒就失败,不必先烧两分钟测试。

还有一条同类的假线索:runner 上 Node 22 自带的 npm 是 10.x,而 Trusted Publisher 要
**npm ≥ 11.5.1**,所以 workflow 第一步就 `npm install -g 'npm@^11.5.1'` —— 少了这步也会报
ENEEDAUTH,看起来像「Trusted Publisher 没配好」,其实只是 npm 太老。

还没配 Trusted Publisher 时可以手动发(临时文件里放 token,用完删):

```bash
npm publish --access public --userconfig=/tmp/npmrc-<名字>
npm run release:verify                                       # 发完必须验证
```

## 安装 / 卸载

```bash
npm i dsh-engramory-recall                                          # 从 npm 装
dsh plugin --profile <profile> add /path/to/dsh-engramory-recall     # 或从源码目录装
dsh plugin --profile <profile> remove dsh-engramory-recall
```

安装后确认 profile 的 `node_modules/dsh-engramory-recall` 软链指向真实目录。
软链断链时宿主只会打印一行 `skipping profile bundle`,插件整轮不加载却不影响其它功能,
这个坑踩过一次,代价是三天无效测量。

**`dsh plugin` 是全有或全无的**:它每次都会重装 profile 的整个依赖集,所以只要
`package.json` 里有任何一个 spec 解析不了(版本被 registry 下架、被供应链策略拦住、
指向已删的路径),添或删任何插件都会一起失败,而且报错说的是那个无关的包。
排查时看 `.plugin-manager/logs/operation-*/pnpm.log`,别盯被操作的那个包。

## 别的宿主:MCP 服务器(只读)

`mcp.js` 是一个零依赖的 MCP(Model Context Protocol)stdio 服务器。它只做三件事,
全部只读、全部本机、零模型调用:

| 工具 | 作用 |
|---|---|
| `engramory_search` | 同一个 BM25 内核(中文分词、177 条同义桥、别名、字段加权) |
| `engramory_read` | 按文件名读一篇笔记的原文(只认库里根目录的 `.md`,路径一律拒) |
| `engramory_status` | 库在哪、多少篇、索引几行几字节、指纹 —— 先确认读的是不是你以为的那个库 |

另外把每篇笔记也注册成 MCP 资源(`engramory://note/<文件名>`),客户端可以直接列出来读。

```jsonc
// Claude Code / Claude Desktop / Cursor 之类的 mcpServers 配置
{
  "mcpServers": {
    "engramory": {
      "command": "node",
      "args": ["/path/to/node_modules/dsh-engramory-recall/mcp.js", "--store", "~/memory/engramory"]
    }
  }
}
```

```bash
# 装了这个包就有了一个可直接调用的命令
engramory-recall-mcp --store ~/memory/engramory      # 库路径也可以用环境变量 ENGRAMORY_STORE
npm run mcp:smoke                                    # 自证:拉起来走一遍完整对话,打印收发记录
npm run bench:mcp                                    # 这一层的数字:冷启动、每步往返、协议帧多大
```

这一层的代价(本机实测,64 篇的库、5 次冷启动取中位数):

| 指标 | 值 | 说明 |
|---|---|---|
| 冷启动 | 39 ms | 含 Node 启动、加载索引、`initialize` 往返 |
| `tools/list` | < 1 ms | 清单是静态的 |
| `engramory_search` | 74 ms | 首次检索要为 66 篇建一次索引,之后同进程走缓存 |
| `engramory_status` | 3 ms | 只读索引头 |
| 协议帧合计 | 3025 字节 | 一次完整对话(initialize + list + search + status)的 stdout 总量 |
| 依赖 | 0 | 不装任何东西,进程起来就能说 MCP |

延迟随机器变化,能跨机复算的是**帧的字节数与依赖性**,以及 `npm run bench:mcp` 里的那条硬断言:
stdout 上只准有 JSON-RPC 帧,混进任何一行非 JSON,这次运行就直接失败(顺带守住「日志只走 stderr」)。

**为什么它是只读的**:写入闸门、归档、体检这些能力依赖宿主工具层的钩子(`tools/pre-execute`),
别的宿主没有这套接口;硬做一个「谁都能写」的版本,等于把 fail-closed 的闸门换成一句口头约定。
所以跨宿主给的是**看**的能力,治理留在家用版里。它也不 import 任何宿主包 ——
在别的宿主里最坏是「没被用上」,不会「装上就崩」。

## 测试与自检

```bash
npm test        # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness / MCP 服务器 / 语义接缝(含 host-embed)
npm run check   # 接线自检:配置键认不认、库路径对不对、装的是哪一份、包清单漏没漏文件
npm run probe   # 召回质量探针:3 组 query + 三道对照,跑在真实记忆库上
npm run probe -- --save   # 把这次结果存成基线,之后每次跑自动比对
npm run bench:check       # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
npm run bench:bridges     # 同义表覆盖率尺子:死值、覆盖、使用面、逐键消融(--ablate / --json / --store)
npm run bench:seam        # 语义接缝的代价与天花板(四种坏提供方 + 作弊上界 + 融合模式对照)
npm run bench:ceiling     # 池宽天花板:任一提供方在公开数据集上的上界 + 每查询要发多少东西
npm run bench:embed       # embeddings 接缝:--fake 量管线与代价 / --real 用真端点量质量
npm run embed:check       # 参考提供方自检:探一次 embeddings 接口,报维度与耗时(不打印密钥)
npm run bench:rewrite     # 宿主模型当改写提供方:真调用量代价与收益(要本机有 dsh;--offline 只重算)
npm run bench:mcp         # MCP 这一层:冷启动、每步往返、协议帧字节数
npm run mcp:smoke         # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json   # 公开基准(数据要自己下,264MB)
```

CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄悄漂走」的:任何一项
跟期望值的偏差超过 2 个百分点就红;桥组容差 3pt、verbatim 组 1pt(它们掉下来多半是我们改坏了)。
分数低不红 —— 绝对值会随语料演化,卡死绝对值只会逼人去改尺子;漂了才红,因为那说明
要么是回归、要么是刻意改动而没人说清楚。

上面这些命令是克隆仓库开发时用的:`test/`、`scripts/`、`bench/` 不在 npm 包的 `files` 白名单里。
发布出去的是运行时真正需要的那十几个文件(`npm pack --dry-run` 可核),
其中 `package.test.mjs` 会从 `index.js` 递归跟所有 `./x.js` 的 import,逐个核对是否都在白名单里。
这个检查已经抓到过两次真事故(漏 `audit.js`、漏 `archive.js`),两次的后果都是
别人装上一装就 `ERR_MODULE_NOT_FOUND`、整个插件加载失败。

`npm run check` 覆盖的五种**静默失效**(不报错、只是从此不干活):配置键打错字、
库路径指空、profile 里跑的不是你改的那份、`files` 白名单漏文件、这一行被 `enabled: false` 关掉。
它顺带跑一遍 `node --check` 校验客户端 bundle 的语法(面板写坏了不会连累宿主加载)。
唯一做不了的是活体守卫测试:守卫只拦 `write`/`edit` 工具调用,脚本写盘会绕过去,
所以脚本最后打印的是要由 agent 亲手执行的两个方向探针(完整的四条见上面「写入闸门」一节的复现表)。

## 集百家之长

四家同类插件的源码逐个读完(hindsight / 灵枢 dsh-memory / dsh-mnemon / dsh-mneme),
产出要求是「机制 + `文件:行号` + 能否在纯 JS、零依赖、零出网下复刻」。能搬的搬,
搬不动或搬了没用的当场证伪。

| 机制 | 抄自 | 结果 |
|---|---|---|
| 注入去重 | dsh-mnemon `src/host/lifecycle.ts:339-345` | 落地,但必须换键:它比的是整段渲染文本,而本插件有跨轮轮换会改变渲染文本,照抄就是死代码(真库探针抓出来的,单测当时还是绿的) |
| 注入预算降级阶梯 | dsh-mneme `src/inject.js:232` | 落地。装不下就塌缩成只剩文件名,不再静默消失 |
| 跨轮轮换降权 | dsh-mneme `src/inject.js:101` | 落地,配套细节不可省:候选池要同步扩容 3 倍,否则降权只是让位子空着 |
| IDF 选词 cap | hindsight `engine/search/bm25_term_selection.py` | 落地,但只能对 `df > 0` 的词排名。按 df 升序取最低的 24 个,留下的全是库里没有的词,实测注入 0 条 |
| fail-closed 闸门 | 灵枢(它只看 `committed` 字段,`ok:true` 不算数) | 落地 |
| 长查询取样 | 灵枢 issue #108(长查询只有开头段进键) | 落地。本插件犯的是反方向:原来只取尾部 400 字符,把开头的主题句整个丢了 |
| 自动归档 | dsh-mneme `src/dream/sleep.js:411` `phaseDemotion` | 落地但换了实现:它截断 + 存 `_full_content`,本插件整段搬家、不截断 |
| 时间衰减 | 灵枢 `rust/src/freshness.rs` | 实测证伪。六档半衰期扫描,recall 恒定、top-1 只有一档 +1(噪声级)。它能用是因为它的 `protected_floor` 依赖人工 `importance` 字段,Engramory 笔记没这个字段 |
| 时间乘性加权 | hindsight `engine/search/reranking.py:35-37` | 实测证伪。五档扫描净收益为零,`w ≥ 0.1` 起还要拿对照组去换 |
| 同义表扩容 | 灵枢的语义归一化 | 落地。177 条桥 / 519 个值,每条都经探针验证,另加一道死桥自检 |
| 查询侧同义词要先分词再算 | 自己踩的坑 | 落地。中文同义词原样返回时永远匹配不上「英文整词 + 汉字双字」的词项形状,写了等于没写且不报错。英文同义词 tokenize 后等于自身,所以这毛病藏了很久 |
| 笔记级别名(`aliases:`) | dsh-mneme 的 `aliases` 钩子 | 落地。机制本来就在(计两遍 = 满权重),但真实库里 0 篇在用;给 8 篇补上后别名探针 1/5 → 5/5 |

**搬机制最容易犯的错**是把机制搬过来、却没搬它依赖的不变量。dsh-mnemon 没有跨轮轮换,
所以它的「渲染文本不变」是真的不变;加上轮换之后,同一个写法就成了永远不触发的死代码。
抄之前先问它凭什么成立、那个前提这里有没有。

## 成本对照

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

## 十一条被实测证伪的路

| 尝试 | 结果 |
|---|---|
| 拿「结果要多样」当选桶护栏:MMR / 覆盖优先 | 在 LongMemEval-S 500 题上,MMR(对 tf 集合算 Jaccard,λ 取 0.15 / 0.3 / 0.5)与基线**逐位相同**(87.8% @1):不同 session 之间几乎不重叠,惩罚项无处发力。覆盖优先 β=1.0 让 @1 从 87.8% 掉到 86.4%,换来的 @5 只有 +0.2pt。机制:我们要解决的是「这个词的重心在别处」,不是「两条结果太像」。跨会话类题目(multi-session 133 题)真正的瓶颈是 gold session 里根本没有问题里的名词 —— 948 个 gold 里 16.5% 与问题零词重叠,19.1% 排在第 6~10 名、39.1% 在第 11~25 名,属于「差一点」,需要的是语义不是重排 |
| df 下限(把出现在超过一半文档里的词直接扔掉) | LongMemEval-S 上扫描 keep6 / keep12 / 0.7 三档:@1 与基线持平或更差(80.5% vs 81.5%),@5 一点不涨。英文虚词已经由查询侧停用词处理掉,索引侧再删一遍只会顺手动掉 `len`。它的**查询侧版本**同样无效:把出现在 >35% / >20% 文档里的查询词丢掉,@1 从 88.4% 掉到 87.6% / 85.4% —— 高频词本身不脏,脏的是「求助/建议」那一类词(见「效果」一节的第 1 条实话) |
| 拿日期当检索信号(解析查询里的时间词做时间窗过滤) | `haystack_dates` 与 `question_date` 都在数据集里,喂进去也救不回来:500 题只有 **39 题**能抽出可用时间窗(其余要么没时间词,要么问的是「多少天前」这类间隔 —— 那 96 题现在 @1 已经 89.6%)。硬过滤的账是 **11 题救回 / 11 题误伤**,净收益 0。更根本的机制:**问题引用的是事件时间,不是 session 时间戳** —— `How many different museums did I visit in December?`(问于 2026-03-03)的 gold 全在 3 月;`Which pair of shoes did I clean last month?`(问于 05-24)的 gold 就在当天,而「上个月」那个窗里躺着 23 个干扰 session。日期侧的先验也不成立:948 个 gold 在各自 haystack 里的**新度百分位平均 0.459**(0.5 = 完全随机),纯按日期排的引擎 @1 只有 3.8%(我们 89.2%) |
| 伪相关反馈查询扩展(拿 top-1 的高频词再搜一遍,RM3 那一路) | 在 LongMemEval-S 500 题上前 120 题实测:RecallAny@1 从 89.2% 掉到 **48.3%**。机制:扩出来的词全是 top-1 自己的词汇,等于把「已经赢了的文档」又投了一遍票,稍有偏差的第一次命中会被自己的回声推到第一。Recall@5 只涨 0.4pt,@1 腰斩 —— 这种改动在只看 top-5 的评测里会被误认为「有效」 |
| 提高分数门槛 / IDF 门槛过滤误召回 | 正例最低分 4.59、反例最高分 5.54,区间重叠切不开;IDF 门槛会把 recall 从 28/28 打到 13/28 |
| freshness 时间衰减(学灵枢 / dsh-mneme) | 六档半衰期扫描:recall 恒为 28/28、top-1 只有一档 +1(噪声级)。库里「旧」的那批恰好是基础事实,衰减就是误伤 |
| 时间乘性加权(学 hindsight) | 五档扫描净收益为零,`w ≥ 0.1` 起反而伤对照组 |
| 给一个已有同义桥继续加词(`主题` 从 `['theme']` 扩到含 `light-dark / color-mix / data-theme / token`) | 看着全是相关的技术词,实测对照 B 掉 3 分(136→133)。机制:把词加进查询扩展后,同一篇笔记自己的其它 chunk 会把「标题里真的写着这个词」的那个 chunk 挤下去,长笔记里这叫自己跟自己抢。判据不是「这词相不相关」,而是「加了它,谁会往上浮」。这 4 个词已全部退回 |
| 拿宿主模型的改写去和词面结果融合(RRF / 归一化求和 / 取最大) | 用真宿主模型(逐条调用本机 harness,提示词取自产品代码)在合成子集 70 条 + 真库探针 36 条上量:改写里**确实有信号**(任意一条单独检索命中 gold@1 达 50.0% / 80.6%,词面是 70.0% / 72.2%),但融合发展一律变差 —— RRF 真库 72.2% → 52.8%、取最大 → 27.8%。机制:投票数变成 4 倍,而词面那 1 票并不比模型那 3 票更该被信任,宿主又没有打分器判断改写好坏。修法是让 `expand` **只补空位**(0.11.1),并且承认「模型能写出更好的查询」≠「检索变好」(`npm run bench:rewrite`) |
| 拿等权 RRF 当语义提供方的默认融合 | 合成 704 条、池 20、窗口 4:**完美的全量排名提供方**(真 embedding 的形态)在等权 RRF 下 hit@1 只有 52.7%,比纯词面(50.4%)几乎没赚;同一个提供方在 `trust` 下是 86.6%。机制:提供方返回的是全量排名,它自己第 2 名以后的名次也会拿到票,于是把第 1 名挤下去 —— 稀释来自它**自己的低名次**,不是来自融合本身(「只投一票」的完美提供方两种融合同为 86.6%)。默认已改成 `trust`,`rrf` 保留为「不确定提供方靠不靠谱」时的安全档(倒排提供方:trust 1.0% vs rrf 26.0%) |
| 拿偏袒的对照当护栏:用对照 A/B 证明「抬高标题权重是安全的」 | 它们都拿标题当查询,对标题权重结构性偏袒,抬高它必然让两组变好。这不是证据,是同义反复。`TITLE_EXTRA` 第一次定成 3 就是被这个漏洞困住的(「口径在 3 之后不再动,所以后面的增量全来自对照」,而对照根本测不出这件事)。补了中立的对照 C 之后,数据说 3 太保守:C 从 0 到 96 一路不降,在 24 到达平台。已按 C 的拐点改成 24 |

## 许可

MIT

Install

dsh plugin --profile web add github:yffo/dsh-engramory-recall

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source