dsh-engramory-recall 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +100 -37
  3. package/bm25.js +16 -1
  4. package/mcp.js +384 -0
  5. package/package.json +9 -3
package/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
3
3
  本项目的所有重要变更都记在这里。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.8.0] — 2026-10-15
7
+
8
+ ### 新增
9
+
10
+ - **零依赖 MCP 服务器(`mcp.js`)**:走 stdio 的只读 MCP 服务器,把同一份记忆库挂给任何支持
11
+ MCP 的宿主(Claude Code / Codex / Cursor…)。三个工具:`engramory_search`(同一个 BM25 内核)、
12
+ `engramory_read`(按文件名读原文,只认库根目录的 `.md`)、`engramory_status`(库位置 / 篇数 /
13
+ 索引余量 / 指纹);每篇笔记另外注册成 MCP 资源(`engramory://note/<文件名>`)。
14
+ 只用 `node:` 内置模块、不 import 任何宿主包、**只读** —— 写入闸门与归档依赖宿主工具层的钩子,
15
+ 别的宿主没有这套接口,硬做只会把 fail-closed 换成口头约定。
16
+ 装了包就多一个命令:`engramory-recall-mcp`(库路径用 `--store` 或环境变量 `ENGRAMORY_STORE`)。
17
+ - `npm run mcp:smoke`:拿脚本当 MCP 客户端,把服务器真的拉起来走一遍对话
18
+ (initialize → tools/list → status → search → resources/read → 越界读被拒),打印收发记录。
19
+
20
+ ### 测试
21
+
22
+ - `test/mcp.test.mjs`:8 例,**全部起真进程**(协议版本协商、工具清单与 inputSchema、检索命中、
23
+ 非法参数只回 isError 不炸进程、`../` 越界读一律拒、resources 往返、空库给「库在哪」的提示、
24
+ stdout 上只允许协议帧且日志只走 stderr,外加 `--version` / `--print-config` / 坏参数的退出码)。
25
+ 合计 192 例全绿。
26
+
27
+ ## [0.7.0] — 2026-10-15
28
+
29
+ ### 变更
30
+
31
+ - **长度归一常数 B 从教科书的 0.75 调到 0.4**(`bm25.js`)。三把尺子一致说 0.75 偏大:
32
+ LongMemEval-S 500 题 @1 87.8% → **88.4%**、@5 97.4% → **98.2%**、覆盖 91.5% → **92.5%**、
33
+ MRR 0.9155 → **0.9202**;自建合成卷子 hit@1 49.9% → **50.4%**、hit@4 66.6% → **67.5%**;
34
+ 真库探针的中立对照 C top1 141/181 → **150/181**、recall@4 168/181 → **177/181**。
35
+ 代价是偏袒短名的对照 A 掉了 2 分(45→43)。机制:长会话/长笔记里同一个词出现得更多,
36
+ `len/avgLen` 的惩罚就变成「谁长谁吃亏」,与相关性无关。扫过 0.2~0.9,0.3~0.5 是平台,取 0.4。
37
+ - README 三张表与「长度归一」小节据此重算;`bench/expectations.json` 与
38
+ `bench/longmemeval-results.json` 一起重设(都写了原因)。
39
+
40
+ ### 新增(记在「被实测证伪的路」里)
41
+
42
+ - **拿「结果要多样」当选桶护栏(MMR / 覆盖优先)**:在 LongMemEval-S 500 题上,MMR 各档
43
+ (λ 0.15 / 0.3 / 0.5)与基线逐位相同;覆盖优先 β=1.0 让 @1 掉 1.4pt。
44
+ 顺带把「差一点」量化出来:948 个 gold 里 87.9% 已进 top-5,没进的里 40.0% 排在第 6~10 名、
45
+ 39.1% 在第 11~25 名、16.5% 与问题零词重叠 —— 瓶颈是语义,不是重排。
46
+ - **df 下限**(丢掉出现在超过一半文档里的词):LongMemEval-S 上三档全不涨,@1 持平或更差。
47
+
6
48
  ## [0.6.2] — 2026-10-15
7
49
 
8
50
  ### 变更
package/README.md CHANGED
@@ -10,6 +10,9 @@
10
10
  全部用 `node:` 内置模块实现:零第三方依赖、零网络、**默认零模型调用**、零常驻进程
11
11
  (只有你显式开启可选的语义增强时,才会走宿主的模型服务)。
12
12
 
13
+ 5. **别的宿主也能用**:`mcp.js` 是一个零依赖的 MCP(stdio)服务器,同一份记忆库可以挂给
14
+ Claude Code / Codex / Cursor 这类支持 MCP 的宿主 —— **只读**(检索与读笔记),写治理留在下面。
15
+
13
16
  ## 底线
14
17
 
15
18
  | 底线 | 本插件如何守 |
@@ -32,7 +35,7 @@
32
35
  | **三年后还能跑** | 不需要编译原生模块、不需要常驻服务 | 带 `better-sqlite3` 编译、rust 工具链、独立 daemon 的方案,会在某次系统升级后让你回来看这篇 README |
33
36
 
34
37
  四问都过了,才轮到比功能。功能这一层,本插件的取舍是**把不确定性变成可测量的东西**:
35
- 新机制必须在库上跑出数字,跑不出来就不上(文末六条被证伪的路就是这么留下的)。
38
+ 新机制必须在库上跑出数字,跑不出来就不上(文末八条被证伪的路就是这么留下的)。
36
39
 
37
40
  ## 跟同类插件对照
38
41
 
@@ -71,13 +74,16 @@
71
74
 
72
75
  ### 短板
73
76
 
74
- - **公开基准只跑了一半**:LongMemEval-S 500 题跑出 RecallAny@5 97.4%(见下),但那只测英文词面
77
+ - **公开基准只跑了一半**:LongMemEval-S 500 题跑出 RecallAny@5 98.2%(见下),但那只测英文词面
75
78
  检索;`thoth-mem` 公布的 470 题版本(RecallAny@5 432/470、Recall@5 84.8%、NDCG@10 0.8165)
76
79
  用的是别的 harness,两边数字不可相减。真正的「跨插件排名」仍然不存在。
77
80
  - **无语义检索**:没有 embedding、没有向量库。同义改写、隐含意图要靠手工同义表覆盖,
78
81
  没覆盖到的就是漏检。(可选的 `ctx.llm` 模块能做矛盾检测和摘要,但它**不参与检索排序**,
79
82
  所以排序本身仍然是纯词面的。)
80
- - **只支持 DSH 一个宿主**:`claude-mem` 覆盖 7 个宿主,`thoth-mem` 覆盖 4 个。
83
+ - **只有 DSH 里是「全功能」**:0.8.0 起带一个零依赖的 MCP 服务器(见下),任何支持 MCP
84
+ 的宿主都能检索与读笔记;但写入闸门、归档、体检、沉淀提醒、面板这些**只在家用版(DSH 插件)
85
+ 里有** —— 别的宿主拿到的是只读视图。`claude-mem` 覆盖 7 个宿主、`thoth-mem` 覆盖 4 个,
86
+ 我们覆盖「1 个全功能 + N 个只读」。
81
87
  - **没有多端或团队同步**:basic-memory 有云同步,本插件没有。
82
88
  - **写入闸门可以被绕过**:`guard` 只拦 `write` / `edit` 工具调用,`bash` 重定向它看不见;
83
89
  `@jipika/dsh-memory` 的 guard 挂在更底层的 `tools/pre-execute`,覆盖更广。
@@ -95,7 +101,7 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
95
101
 
96
102
  | 引擎 | 总 hit@1 | 总 hit@4 | verbatim | colloquial | keyword |
97
103
  |---|---|---|---|---|---|
98
- | **本插件** | **49.9%** | 66.6% | 98.2% | 33.9% | 15.2% |
104
+ | **本插件** | **50.4%** | 67.5% | 98.7% | 34.8% | 15.6% |
99
105
  | 教科书 BM25(同分词口径) | 43.2% | 46.9% | 100% | 17.9% | 17.9% |
100
106
  | 教科书 BM25(中文逐字切分) | 47.2% | 53.4% | 100% | 20.1% | 28.1% |
101
107
  | 瞎猜(地板线) | 0.6% | 2.6% | 0.4% | 0.9% | 0.4% |
@@ -106,15 +112,15 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
106
112
 
107
113
  | 改动 | 总 hit@1 | 相对本插件 |
108
114
  |---|---|---|
109
- | 本插件 | 49.9% | — |
110
- | 去掉同义表 | 46.7% | -3.1pt |
111
- | 去掉笔记别名(不再声明 `aliases`) | 56.5% | **+6.7pt** |
112
- | 去掉字段加权(`TITLE_EXTRA` 24 → 0) | 49.6% | -0.3pt |
113
- | 同义键碎片不降权(回到改动前) | 49.1% | -0.7pt |
114
- | 桥权重与同义表一致(都算 0.5) | 50.4% | +0.6pt |
115
+ | 本插件 | 50.4% | — |
116
+ | 去掉同义表 | 46.4% | -4.0pt |
117
+ | 去掉笔记别名(不再声明 `aliases`) | 56.1% | **+5.7pt** |
118
+ | 去掉字段加权(`TITLE_EXTRA` 24 → 0) | 49.1% | -1.3pt |
119
+ | 同义键碎片不降权(回到改动前) | 49.0% | -1.4pt |
120
+ | 桥权重与同义表一致(都算 0.5) | 51.4% | +1.0pt |
115
121
 
116
122
  「去掉笔记别名」是 **+6.7pt**,在总分上反而更好看 —— 这个语料里别名是把双刃剑:
117
- 它帮自己(写了别名的 37 篇 hit@1 89.2%,没写的 187 篇 23.0%),
123
+ 它帮自己(写了别名的 37 篇 hit@1 94.6%,没写的 187 篇 23.0%),
118
124
  也把同域别的笔记的口语查询抢过来(语料里同域的别名共用同一个口语域词,而别名是程序按比例随机发的)。
119
125
  真库里别名是人手写的、更克制,所以别拿这一行下结论 —— 判断别名只看探针的别名组(5/5)。
120
126
 
@@ -125,21 +131,34 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
125
131
  而 `代理`、`部署` 既是同义键、也是笔记正文里的原词,剔掉等于把真证据扔了。
126
132
  所以改成**降权不剔除**(`BRIDGED_FRAGMENT_WEIGHT = 0.5`,桥到的技术词算 `BRIDGE_WEIGHT = 1`)。
127
133
  降权只针对键的**碎片**(单字、相邻双字),键本身照旧算满权重 —— 这一条是量出来的:
128
- 一开始连键本身一起降权,`verbatim` 对照组从 100% 掉到 95.1%,放开键本身后回到 98.2%,
134
+ 一开始连键本身一起降权,`verbatim` 对照组从 100% 掉到 95.1%,放开键本身后回到 98.7%,
129
135
  而桥组一点没退。道理很直白:查询里原样出现的那个词是实打实的证据,降权的理由只是
130
136
  它拆出来的碎片在别的笔记里到处都是。
131
137
 
132
- 代价是与「桥权重打回 0.5」相比总分少 0.6 个点(50.4% → 49.9%)。接受它的理由:
138
+ 代价是与「桥权重打回 0.5」相比总分少 1.0 个点(51.4% → 50.4%)。接受它的理由:
133
139
  合成语料里绝大多数查询本来就有字面落点,总分被它们主导;桥是唯一在字面完全无交集时
134
140
  还能工作的机制 —— 同一批桥查询,桥权重打回 0.5 只有 25.0%,碎片不降权 43.8%。
135
141
  (历史值:修「碎片压桥」之前,那一版语料上这组查询只有 27.3%。)
136
142
 
137
- 「去掉字段加权」那一行要单独说:它现在是 -0.3pt(拿掉略微更差),**在一次引擎改动之前它是 +4pt**
143
+ 「去掉字段加权」那一行要单独说:它现在是 -1.3pt(拿掉略微更差),**在一次引擎改动之前它是 +4pt**
138
144
  (拿掉明显更好),而**真实库的中立对照一直说拿掉更差**(见下)。当时的冲突定位到机制上:
139
145
  合成语料的标题是从正文模板派生的、与正文近乎重复,抬高标题权重只是放大域词噪声;
140
146
  真实库的标题是人手提炼的。现在前两把尺子都偏向「留着字段加权」,但理由仍是上面这条 ——
141
147
  不是冲突消失了,是那把合成尺子的标题变真实了一点。两者不一致时以对照 C 为准。
142
148
 
149
+ **长度归一的强度 B 是量出来的,不是抄的。** 教科书默认 0.75 我们抄了很久,三把尺子都说它偏大:
150
+
151
+ | 尺子 | B=0.75 | B=0.4 |
152
+ |---|---|---|
153
+ | 这份合成卷子 | hit@1 49.9% · hit@4 66.6% | hit@1 50.4% · hit@4 67.5% |
154
+ | LongMemEval-S 500 题 | @1 87.8% · @5 97.4% · 覆盖 91.5% | @1 88.4% · @5 98.2% · 覆盖 92.5% |
155
+ | 真库对照 C(中立那把) | top1 141/181 · recall@4 168/181 | top1 150/181 · recall@4 177/181 |
156
+
157
+ 机制:同一个词在长会话、长笔记里出现得更多,`len/avgLen` 这个惩罚就变成「谁长谁吃亏」,
158
+ 与相关性无关。代价在对照 A 上看得见(top1 45→43),但 A 拿文件名当查询、结构性偏袒短名,
159
+ 按一直以来的规矩以对照 C 为准。扫过的值是 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.75 / 0.9,
160
+ 0.3~0.5 是一片平台,取中间偏下的 0.4。
161
+
143
162
  ### 桥组:32 条,65.6%
144
163
 
145
164
  | 组 | 条数 | hit@1 | 说明 |
@@ -172,12 +191,12 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
172
191
  | 组 | 条数 | 测什么 | 最近一次 |
173
192
  |---|---|---|---|
174
193
  | 口径 | 21 | 真实场景的中文口语问法 | recall@4 21/21 |
175
- | 桥探针 | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 9/10 |
194
+ | 桥探针 | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 10/10 |
176
195
  | 别名探针 | 5 | 笔记自己的 `aliases:`(关键词在全局桥里一个都没有) | recall@4 5/5 |
177
- | 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 121 个死值(均为通用词) |
178
- | 对照 A | 189 | 文件名当查询的底线(对标题权重有偏袒) | top1 36/189 · recall@4 96/189 |
179
- | 对照 B | 164 | 笔记中文标题当查询的底线(同样偏袒) | top1 159/164 · recall@4 163/164 |
180
- | 对照 C | 176 | 正文独有词当查询(该词在任何标题里都没有) | top1 140/176 · recall@4 164/176 |
196
+ | 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 118 个死值(均为通用词) |
197
+ | 对照 A | 194 | 文件名当查询的底线(对标题权重有偏袒) | top1 43/194 · recall@4 96/194 |
198
+ | 对照 B | 169 | 笔记中文标题当查询的底线(同样偏袒) | top1 164/169 · recall@4 168/169 |
199
+ | 对照 C | 181 | 正文独有词当查询(该词在任何标题里都没有) | top1 150/181 · recall@4 177/181 |
181
200
 
182
201
  对照 A 和 B 都拿标题当查询,所以它们对「标题里的词多计几遍」这类参数是**结构性偏袒**的:
183
202
  抬高标题权重几乎必然让这两组变好。它们既不该当目标函数,也不能用来证明「抬高标题权重是安全的」。
@@ -218,33 +237,35 @@ haystack,题目分六类。
218
237
 
219
238
  | 指标 | 全部 500 题 | 教科书 BM25(同分词口径) |
220
239
  | --- | --- | --- |
221
- | RecallAny@1 | **87.8%** | 86.2% |
222
- | RecallAny@5 | **97.4%** | 97.0% |
223
- | Recall@5(gold 覆盖率) | **91.5%** | 90.7% |
224
- | MRR@5 | **0.9155** | 0.9056 |
225
- | RecallAny@10 | **98.4%** | 98.2% |
240
+ | RecallAny@1 | **88.4%** | 86.2% |
241
+ | RecallAny@5 | **98.2%** | 97.0% |
242
+ | Recall@5(gold 覆盖率) | **92.5%** | 90.7% |
243
+ | MRR@5 | **0.9202** | 0.9056 |
244
+ | RecallAny@10 | **98.8%** | 98.2% |
226
245
 
227
246
  | 题型 | 题数 | RecallAny@1 | RecallAny@5 | Recall@5 | MRR@5 |
228
247
  | --- | --- | --- | --- | --- | --- |
229
- | knowledge-update | 78 | 97.4% | 100.0% | 98.7% | 0.987 |
230
- | multi-session | 133 | 88.0% | 97.0% | 85.3% | 0.919 |
248
+ | knowledge-update | 78 | 98.7% | 100.0% | 99.4% | 0.994 |
249
+ | multi-session | 133 | 88.0% | 98.5% | 86.0% | 0.920 |
231
250
  | single-session-assistant | 56 | 100.0% | 100.0% | 100.0% | 1.000 |
232
- | single-session-preference | 30 | 40.0% | 86.7% | 86.7% | 0.583 |
251
+ | single-session-preference | 30 | 46.7% | 90.0% | 90.0% | 0.627 |
233
252
  | single-session-user | 70 | 92.9% | 100.0% | 100.0% | 0.950 |
234
- | temporal-reasoning | 133 | 85.0% | 96.2% | 86.6% | 0.892 |
253
+ | temporal-reasoning | 133 | 85.0% | 97.0% | 88.5% | 0.894 |
235
254
 
236
255
  三句必须说在前面的实话:
237
256
 
238
- 1. **这 1.6 个点的来源要说清楚。** 本插件比教科书 BM25 高 1.6pt @1,差的那部分**全部**来自一处通用改动:
239
- 查询侧丢掉英文虚词(`what / is / the / of / did / how…`)。是我们自己的机制(同义表、别名、
240
- 字段加权、中文分词)在这份数据上没被激活 —— LongMemEval 是英文的,session 没有标题、
241
- 没有别名、没有中文口语,它们在这里没事可做。所以这张表证明的是「英文词面检索不输、且
242
- 基础卫生做到位」,**不是**「本插件的独门设计更好」。机制值多少,回上面两把尺子看。
257
+ 1. **这 2.2 个点的来源要说清楚。** 本插件比教科书 BM25 高 2.2pt @1,差的那部分**全部**来自两处通用改动:
258
+ 查询侧丢掉英文虚词(`what / is / the / of / did / how…`),以及把长度归一常数从教科书的 0.75
259
+ 调到 0.4。两处都与中文无关 —— 是我们自己的机制(同义表、别名、字段加权、中文分词)在这份数据上
260
+ 没被激活:LongMemEval 是英文的,session 没有标题、没有别名、没有中文口语,它们在这里没事可做。
261
+ B 的取值不是在这份数据上单独挑的(挑法见上:三把尺子一致,真库中立对照 +9/+9)。所以这张表
262
+ 证明的是「英文词面检索不输、且基础卫生做到位」,**不是**「本插件的独门设计更好」。机制值多少,
263
+ 回上面两把尺子看。
243
264
  2. **别拿它跟别家数字直接相减。** thoth-mem 公布过 LongMemEval-S 470 题、RecallAny@5 91.9%。
244
265
  我们用的是 cleaned 版 500 题 + 自己的 harness,题集版本、命中口径、读不读日期都未必一样,
245
266
  相减没有意义。能担保的是:harness 与逐题结果都在仓库里(`bench/longmemeval.mjs`、
246
267
  `bench/longmemeval-results.json`),可以逐题核。
247
- 3. **最差的是偏好题**(40.0%@1):「我喜欢什么」这类问题本身没什么词面抓手。这是零模型路线的
268
+ 3. **最差的是偏好题**(46.7%@1):「我喜欢什么」这类问题本身没什么词面抓手。这是零模型路线的
248
269
  边界 —— 偏好类想做得更好,要么开可选的 `llm.js`,要么就老老实实把偏好写进标题。
249
270
 
250
271
  复算:
@@ -484,6 +505,9 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
484
505
  | 自动合并 | 只出报告,不自动改文件 | 已实现(只报告) |
485
506
  | 语义冲突检测 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticConflicts`) |
486
507
  | 自动摘要 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticSummary`) |
508
+ | 跨宿主分发 | 零依赖 stdio MCP 服务器,只读 | 已实现(`mcp.js`) |
509
+ | 跨会话覆盖 | 「结果要多样」的选桶护栏(MMR / 覆盖优先) | 量过了,**被证伪**(见「被实测证伪的路」),收益改在长度归一 `B` 上拿到 |
510
+ | 可选语义检索 | 可插拔 seam(向量/API 都行),默认关闭 | 计划中 |
487
511
 
488
512
  后两条默认关闭,是为了让「零模型调用」这条基线保持可复现。开启是用户的显式选择,
489
513
  而不是装完就生效:一个会自己调模型改写你笔记的插件不该默认打开。走 `ctx.llm`
@@ -565,19 +589,56 @@ dsh plugin --profile <profile> remove dsh-engramory-recall
565
589
  指向已删的路径),添或删任何插件都会一起失败,而且报错说的是那个无关的包。
566
590
  排查时看 `.plugin-manager/logs/operation-*/pnpm.log`,别盯被操作的那个包。
567
591
 
592
+ ## 别的宿主:MCP 服务器(只读)
593
+
594
+ `mcp.js` 是一个零依赖的 MCP(Model Context Protocol)stdio 服务器。它只做三件事,
595
+ 全部只读、全部本机、零模型调用:
596
+
597
+ | 工具 | 作用 |
598
+ |---|---|
599
+ | `engramory_search` | 同一个 BM25 内核(中文分词、177 条同义桥、别名、字段加权) |
600
+ | `engramory_read` | 按文件名读一篇笔记的原文(只认库里根目录的 `.md`,路径一律拒) |
601
+ | `engramory_status` | 库在哪、多少篇、索引几行几字节、指纹 —— 先确认读的是不是你以为的那个库 |
602
+
603
+ 另外把每篇笔记也注册成 MCP 资源(`engramory://note/<文件名>`),客户端可以直接列出来读。
604
+
605
+ ```jsonc
606
+ // Claude Code / Claude Desktop / Cursor 之类的 mcpServers 配置
607
+ {
608
+ "mcpServers": {
609
+ "engramory": {
610
+ "command": "node",
611
+ "args": ["/path/to/node_modules/dsh-engramory-recall/mcp.js", "--store", "~/memory/engramory"]
612
+ }
613
+ }
614
+ }
615
+ ```
616
+
617
+ ```bash
618
+ # 装了这个包就有了一个可直接调用的命令
619
+ engramory-recall-mcp --store ~/memory/engramory # 库路径也可以用环境变量 ENGRAMORY_STORE
620
+ npm run mcp:smoke # 自证:拉起来走一遍完整对话,打印收发记录
621
+ ```
622
+
623
+ **为什么它是只读的**:写入闸门、归档、体检这些能力依赖宿主工具层的钩子(`tools/pre-execute`),
624
+ 别的宿主没有这套接口;硬做一个「谁都能写」的版本,等于把 fail-closed 的闸门换成一句口头约定。
625
+ 所以跨宿主给的是**看**的能力,治理留在家用版里。它也不 import 任何宿主包 ——
626
+ 在别的宿主里最坏是「没被用上」,不会「装上就崩」。
627
+
568
628
  ## 测试与自检
569
629
 
570
630
  ```bash
571
- npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness
631
+ npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness / MCP 服务器
572
632
  npm run check # 接线自检:配置键认不认、库路径对不对、装的是哪一份、包清单漏没漏文件
573
633
  npm run probe # 召回质量探针:3 组 query + 三道对照,跑在真实记忆库上
574
634
  npm run probe -- --save # 把这次结果存成基线,之后每次跑自动比对
575
635
  npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
636
+ npm run mcp:smoke # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
576
637
  node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json # 公开基准(数据要自己下,264MB)
577
638
  ```
578
639
 
579
640
  CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄悄漂走」的:任何一项
580
- 跟期望值的偏差超过 2 个百分点就红,桥组与 verbatim 组要求满分(它们掉下来就是我们改坏了)。
641
+ 跟期望值的偏差超过 2 个百分点就红;桥组容差 3pt、verbatim 组 1pt(它们掉下来多半是我们改坏了)。
581
642
  分数低不红 —— 绝对值会随语料演化,卡死绝对值只会逼人去改尺子;漂了才红,因为那说明
582
643
  要么是回归、要么是刻意改动而没人说清楚。
583
644
 
@@ -626,10 +687,12 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
626
687
  | DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
627
688
  | **本插件** | **0** | **≤480 字符,且只有指针** | **无** |
628
689
 
629
- ## 六条被实测证伪的路
690
+ ## 八条被实测证伪的路
630
691
 
631
692
  | 尝试 | 结果 |
632
693
  |---|---|
694
+ | 拿「结果要多样」当选桶护栏: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 名,属于「差一点」,需要的是语义不是重排 |
695
+ | df 下限(把出现在超过一半文档里的词直接扔掉) | LongMemEval-S 上扫描 keep6 / keep12 / 0.7 三档:@1 与基线持平或更差(80.5% vs 81.5%),@5 一点不涨。英文虚词已经由查询侧停用词处理掉,索引侧再删一遍只会顺手动掉 `len` |
633
696
  | 伪相关反馈查询扩展(拿 top-1 的高频词再搜一遍,RM3 那一路) | 在 LongMemEval-S 500 题上前 120 题实测:RecallAny@1 从 89.2% 掉到 **48.3%**。机制:扩出来的词全是 top-1 自己的词汇,等于把「已经赢了的文档」又投了一遍票,稍有偏差的第一次命中会被自己的回声推到第一。Recall@5 只涨 0.4pt,@1 腰斩 —— 这种改动在只看 top-5 的评测里会被误认为「有效」 |
634
697
  | 提高分数门槛 / IDF 门槛过滤误召回 | 正例最低分 4.59、反例最高分 5.54,区间重叠切不开;IDF 门槛会把 recall 从 28/28 打到 13/28 |
635
698
  | freshness 时间衰减(学灵枢 / dsh-mneme) | 六档半衰期扫描:recall 恒为 28/28、top-1 只有一档 +1(噪声级)。库里「旧」的那批恰好是基础事实,衰减就是误伤 |
package/bm25.js CHANGED
@@ -14,7 +14,22 @@
14
14
  */
15
15
 
16
16
  const K1 = 1.2;
17
- const B = 0.75;
17
+ /**
18
+ * 长度归一的强度。0.75 是教科书默认值(也是我们最初抄的值),三把尺子都说它偏大:
19
+ *
20
+ * | 尺子 | B=0.75 | B=0.4 |
21
+ * | --- | --- | --- |
22
+ * | LongMemEval-S 500 题 | @1 87.8% · @5 97.4% · 覆盖 91.5% · MRR 0.9140 | @1 88.4% · @5 98.2% · 覆盖 92.5% · MRR 0.9196 |
23
+ * | 合成基准 704 查询 | @1 49.9% · @4 66.6% · verbatim 98.2% | @1 50.4% · @4 67.5% · verbatim 98.7% |
24
+ * | 真库对照 C(中立那把) | top1 141/181 · @4 168/181 | top1 150/181 · @4 177/181 |
25
+ *
26
+ * 机制:同一个词在长会话/长笔记里出现得更多,长度归一按 `len/avgLen` 把整篇压下去;
27
+ * 当库里绝大多数是长文档时,这个惩罚就变成「谁长谁吃亏」,与相关性无关。
28
+ * 代价在对照 A 上能看到(top1 45→43),但 A 拿文件名当查询、结构性偏袒短名,
29
+ * 所以按一直以来的规矩:以对照 C 为准,A/B 只当「别掉分」的底线。
30
+ * 扫过的值:0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.75 / 0.9 —— 0.3~0.5 是一片平台,取中间偏下的 0.4。
31
+ */
32
+ const B = 0.4;
18
33
 
19
34
  /**
20
35
  * 同义扩展项的权重:字典里搭出来的桥,命中也不该完全压过查询里的原词。
package/mcp.js ADDED
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 零依赖的 MCP(Model Context Protocol)服务器,走 stdio。
4
+ *
5
+ * 为什么有这个东西:检索内核(`bm25.js` + `store.js`)本来就不依赖 DSH ——
6
+ * 记忆库就是一堆 markdown,索引就是词频。别的宿主(Claude Code、Codex、Cursor…)
7
+ * 只要支持 MCP,就应该能用同一份库。插件不该被绑死在一个宿主上。
8
+ *
9
+ * 四条自我约束(改掉任何一条就等于换了个东西):
10
+ * 1. 只用 `node:` 内置模块:零依赖、零子进程、零网络、零模型调用;
11
+ * 2. **只读**:没有任何写库的接口 —— 闸门、归档、体检那套留在 DSH 那一侧的家用版本里;
12
+ * 3. 不 import 任何宿主包(`@deepseek-ai/*`)。所以它在别的宿主里最坏是「没被装上」,
13
+ * 不会因为版本对不上而「装上就崩」;
14
+ * 4. **stdout 只允许出现协议帧**:日志一律走 stderr,否则客户端解析到一半就崩。
15
+ *
16
+ * 用法:`node mcp.js [--store <路径>]`,或者看 README 里各宿主的配置片段。
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { basename, join, resolve, sep } from 'node:path';
22
+ import { createInterface } from 'node:readline';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ import { buildIndex, search } from './bm25.js';
26
+ import { countLines, DEFAULT_MAX_INDEX_BYTES, DEFAULT_MAX_INDEX_LINES, measureIndex } from './index-guard.js';
27
+ import { corpusFingerprint, readDocs } from './store.js';
28
+
29
+ export const SERVER_NAME = 'dsh-engramory-recall';
30
+ /** 声明支持的 MCP 协议版本。客户端报的版本在名单里就照它回,否则回我们最新的。 */
31
+ export const PROTOCOL_VERSION = '2025-06-18';
32
+ export const SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION, '2025-03-26', '2024-11-05']);
33
+ export const DEFAULT_STORE = '~/memory/engramory';
34
+ /** 单个工具结果的字符上限:MCP 客户端会把结果塞进上下文,给个天花板。 */
35
+ export const MAX_OUTPUT_CHARS = 40000;
36
+ export const MAX_LIMIT = 20;
37
+ export const DEFAULT_LIMIT = 5;
38
+
39
+ const pkg = (() => {
40
+ try {
41
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
42
+ } catch {
43
+ return {};
44
+ }
45
+ })();
46
+ export const SERVER_VERSION = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
47
+
48
+ /** 工具执行失败:message 会原样出现在 isError 的文本里,所以写人话。 */
49
+ export class McpError extends Error {}
50
+
51
+ export const TOOLS = [
52
+ {
53
+ name: 'engramory_search',
54
+ description:
55
+ '在本地 Engramory 记忆库里检索相关笔记(BM25 词面检索,含中文分词、同义桥与别名,本机执行、零模型调用)。返回文件名、分数与命中词。',
56
+ inputSchema: {
57
+ type: 'object',
58
+ additionalProperties: false,
59
+ properties: {
60
+ query: { type: 'string', description: '查询文本,中英文都行;口语说法会被同义表桥到技术词。' },
61
+ limit: {
62
+ type: 'integer',
63
+ minimum: 1,
64
+ maximum: MAX_LIMIT,
65
+ description: `返回几条,默认 ${DEFAULT_LIMIT}。`,
66
+ },
67
+ },
68
+ required: ['query'],
69
+ },
70
+ },
71
+ {
72
+ name: 'engramory_read',
73
+ description: '按文件名读出一篇笔记的完整 markdown 原文(只读库根目录下的 .md,不接受路径)。',
74
+ inputSchema: {
75
+ type: 'object',
76
+ additionalProperties: false,
77
+ properties: { file: { type: 'string', description: '库里的文件名,例如 reference-publish-plugin-to-npm.md' } },
78
+ required: ['file'],
79
+ },
80
+ },
81
+ {
82
+ name: 'engramory_status',
83
+ description: '汇报记忆库的位置、笔记数、索引用量与上限,用来确认客户端读的是不是你以为的那个库。',
84
+ inputSchema: { type: 'object', additionalProperties: false, properties: {} },
85
+ },
86
+ ];
87
+
88
+ export const RESOURCE_TEMPLATE = {
89
+ uriTemplate: 'engramory://note/{file}',
90
+ name: '一篇笔记',
91
+ description: '把 {file} 换成 <store> 下的文件名。',
92
+ mimeType: 'text/markdown',
93
+ };
94
+
95
+ export function expandHome(value) {
96
+ const text = String(value ?? '').trim();
97
+ if (!text) return '';
98
+ if (text === '~') return homedir();
99
+ return text.startsWith('~/') ? join(homedir(), text.slice(2)) : text;
100
+ }
101
+
102
+ /** 库路径:命令行 > 环境变量 ENGRAMORY_STORE > 默认 `~/memory/engramory`。 */
103
+ export function resolveStorePath(raw) {
104
+ const expanded = expandHome(raw);
105
+ return expanded ? resolve(expanded) : resolve(expandHome(DEFAULT_STORE));
106
+ }
107
+
108
+ export function createSession(options = {}) {
109
+ const raw = options.store ?? process.env.ENGRAMORY_STORE ?? DEFAULT_STORE;
110
+ return { store: resolveStorePath(raw), storeRaw: String(raw ?? '').trim() || DEFAULT_STORE, index: null, fingerprint: '' };
111
+ }
112
+
113
+ /** 索引按语料指纹缓存:库被改过就重建,没改过就复用(readDocs 自己也有 mtime 缓存)。 */
114
+ function indexFor(session) {
115
+ const docs = readDocs(session.store);
116
+ const fingerprint = corpusFingerprint(docs);
117
+ if (!session.index || session.fingerprint !== fingerprint) {
118
+ session.index = buildIndex(docs);
119
+ session.fingerprint = fingerprint;
120
+ }
121
+ return session.index;
122
+ }
123
+
124
+ /** 库里唯一的笔记(长笔记在索引里会切成多桶,这里按 id 折回一篇)。 */
125
+ function notesOf(session) {
126
+ const notes = new Map();
127
+ for (const doc of readDocs(session.store)) {
128
+ const existing = notes.get(doc.id);
129
+ if (!existing) notes.set(doc.id, { file: doc.id, title: doc.title || doc.id, description: doc.description || '' });
130
+ else if (!existing.description && doc.description) existing.description = doc.description;
131
+ }
132
+ return [...notes.values()];
133
+ }
134
+
135
+ function safeNotePath(session, file) {
136
+ const name = String(file ?? '').trim();
137
+ if (!name) throw new McpError('需要 file 参数:库里的 markdown 文件名。');
138
+ if (name !== basename(name) || name.startsWith('.') || !name.endsWith('.md')) {
139
+ throw new McpError(`不是库里的笔记文件名:${name}。只接受 <store> 下直接的 .md 文件名,不接受路径。`);
140
+ }
141
+ const full = join(session.store, name);
142
+ if (!resolve(full).startsWith(session.store + sep)) throw new McpError(`拒绝越界路径:${name}`);
143
+ return full;
144
+ }
145
+
146
+ function readNote(session, file) {
147
+ const name = String(file ?? '').trim() || basename(safeNotePath(session, file));
148
+ const full = safeNotePath(session, name);
149
+ let text;
150
+ try {
151
+ text = readFileSync(full, 'utf8');
152
+ } catch {
153
+ throw new McpError(`读不到这篇笔记:${name}。用 engramory_status 看库的位置,或 engramory_search 找回文件名。`);
154
+ }
155
+ if (text.length > MAX_OUTPUT_CHARS) {
156
+ return `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n…(笔记超过 ${MAX_OUTPUT_CHARS} 字符,这里截断了;完整原文在 ${name})`;
157
+ }
158
+ return text;
159
+ }
160
+
161
+ function toolSearch(session, args) {
162
+ const query = String(args?.query ?? '').trim();
163
+ if (!query) throw new McpError('需要非空的 query。');
164
+ const raw = Number(args?.limit);
165
+ const limit = Number.isFinite(raw) ? Math.min(MAX_LIMIT, Math.max(1, Math.trunc(raw))) : DEFAULT_LIMIT;
166
+ const docs = readDocs(session.store);
167
+ if (!docs.length) {
168
+ throw new McpError(`库是空的或不存在:${session.store}。用 --store <路径> 或环境变量 ENGRAMORY_STORE 指定。`);
169
+ }
170
+ const hits = search(indexFor(session), query, limit);
171
+ const header = `库:${session.store}(${notesOf(session).length} 篇)· 查询:${query}`;
172
+ if (!hits.length) return `${header}\n没有命中。换个词,或先用 engramory_status 确认库对不对。`;
173
+ const lines = hits.map((hit, i) => {
174
+ const snippet = String(hit.snippet || '').replace(/\s+/g, ' ').trim();
175
+ const matched = Array.isArray(hit.matched) && hit.matched.length ? hit.matched.join('、') : '—';
176
+ return [`${i + 1}. ${hit.id} · ${Number(hit.score).toFixed(2)}`, ` ${snippet}`, ` 命中:${matched}`].join('\n');
177
+ });
178
+ return [header, ...lines].join('\n');
179
+ }
180
+
181
+ function toolStatus(session) {
182
+ const notes = notesOf(session);
183
+ let indexText = '';
184
+ try {
185
+ indexText = readFileSync(join(session.store, 'MEMORY.md'), 'utf8');
186
+ } catch {
187
+ /* 索引缺失不是错误:没有索引也能按正文召回,只是宿主不会自动打开它。 */
188
+ }
189
+ // measureIndex 返回的是「超限时的人话描述 / 没超时 null」,行数与字节数得自己数。
190
+ const over = measureIndex(indexText, DEFAULT_MAX_INDEX_LINES, DEFAULT_MAX_INDEX_BYTES);
191
+ const indexLine = indexText
192
+ ? `索引:${countLines(indexText)} 行 / ${Buffer.byteLength(indexText)} 字节(上限 ${DEFAULT_MAX_INDEX_LINES} 行 / ${DEFAULT_MAX_INDEX_BYTES} 字节${over ? `,已超限:${over}` : ''})`
193
+ : '索引:没有 MEMORY.md(召回仍可用,但宿主没有索引可读)';
194
+ return [
195
+ `库:${session.store}`,
196
+ `笔记:${notes.length} 篇`,
197
+ indexLine,
198
+ `指纹:${corpusFingerprint(readDocs(session.store)).slice(0, 12) || '(空库)'}`,
199
+ `服务器:${SERVER_NAME} ${SERVER_VERSION} · 只读 · 零模型调用`,
200
+ ].join('\n');
201
+ }
202
+
203
+ const HANDLERS = {
204
+ engramory_search: toolSearch,
205
+ engramory_read: (session, args) => readNote(session, args?.file),
206
+ engramory_status: toolStatus,
207
+ };
208
+
209
+ export async function callTool(session, params) {
210
+ const name = String(params?.name ?? '');
211
+ const handler = HANDLERS[name];
212
+ if (!handler) {
213
+ return { content: [{ type: 'text', text: `没有这个工具:${name}。可用:${Object.keys(HANDLERS).join(' / ')}` }], isError: true };
214
+ }
215
+ try {
216
+ return { content: [{ type: 'text', text: String(handler(session, params?.arguments ?? {})) }], isError: false };
217
+ } catch (error) {
218
+ const text = error instanceof McpError ? error.message : `工具 ${name} 出错:${error?.message ?? error}`;
219
+ return { content: [{ type: 'text', text }], isError: true };
220
+ }
221
+ }
222
+
223
+ export function listResources(session) {
224
+ return {
225
+ resources: notesOf(session).map((note) => ({
226
+ uri: `engramory://note/${note.file}`,
227
+ name: note.file,
228
+ title: note.title,
229
+ description: note.description || undefined,
230
+ mimeType: 'text/markdown',
231
+ })),
232
+ };
233
+ }
234
+
235
+ export function readResource(session, params) {
236
+ const uri = String(params?.uri ?? '');
237
+ const prefix = 'engramory://note/';
238
+ if (!uri.startsWith(prefix)) throw new McpError(`只认得 ${prefix}<文件名> 这种 URI,收到的是:${uri}`);
239
+ const file = decodeURIComponent(uri.slice(prefix.length));
240
+ return { contents: [{ uri, mimeType: 'text/markdown', text: readNote(session, file) }] };
241
+ }
242
+
243
+ function initializeResult(params) {
244
+ const asked = String(params?.protocolVersion ?? '');
245
+ return {
246
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.has(asked) ? asked : PROTOCOL_VERSION,
247
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
248
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION, title: 'Engramory 记忆召回(本地 markdown,只读)' },
249
+ instructions:
250
+ '这是本机 markdown 记忆库的只读检索。先用 engramory_search 找相关笔记,再用 engramory_read 打开需要的那些;' +
251
+ '不要假设库里有什么,也不要通过它写任何东西。',
252
+ };
253
+ }
254
+
255
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
256
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
257
+
258
+ /** 处理一条 JSON-RPC 消息;通知返回 null(协议规定通知不回包)。 */
259
+ export async function handleMessage(session, message) {
260
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
261
+ return fail(null, -32600, 'Invalid Request:只接受单条 JSON-RPC 2.0 对象(不支持批量)。');
262
+ }
263
+ const { id, method } = message;
264
+ const notification = id === undefined || id === null;
265
+ if (typeof method !== 'string') {
266
+ return notification ? null : fail(id, -32600, 'Invalid Request:method 必须是字符串。');
267
+ }
268
+ if (method.startsWith('notifications/')) return null;
269
+ try {
270
+ switch (method) {
271
+ case 'initialize':
272
+ return ok(id, initializeResult(message.params));
273
+ case 'ping':
274
+ return ok(id, {});
275
+ case 'tools/list':
276
+ return ok(id, { tools: TOOLS });
277
+ case 'tools/call':
278
+ return ok(id, await callTool(session, message.params));
279
+ case 'resources/list':
280
+ return ok(id, listResources(session));
281
+ case 'resources/templates/list':
282
+ return ok(id, { resourceTemplates: [RESOURCE_TEMPLATE] });
283
+ case 'resources/read':
284
+ return ok(id, readResource(session, message.params));
285
+ case 'prompts/list':
286
+ return ok(id, { prompts: [] });
287
+ default:
288
+ if (notification) return null;
289
+ return fail(id, -32601, `Method not found: ${method}`);
290
+ }
291
+ } catch (error) {
292
+ const text = error instanceof McpError ? error.message : `内部错误:${error?.message ?? error}`;
293
+ return notification ? null : fail(id, -32603, text);
294
+ }
295
+ }
296
+
297
+ /** stdio 主循环:一行一条消息,stdout 只写协议帧。 */
298
+ export async function serveStdio(session, options = {}) {
299
+ const input = options.input ?? process.stdin;
300
+ const output = options.output ?? process.stdout;
301
+ const rl = createInterface({ input, crlfDelay: Infinity });
302
+ const pending = [];
303
+ rl.on('line', (line) => {
304
+ const text = line.trim();
305
+ if (!text) return;
306
+ let message;
307
+ try {
308
+ message = JSON.parse(text);
309
+ } catch {
310
+ output.write(`${JSON.stringify(fail(null, -32700, 'Parse error:这一行不是合法 JSON。'))}\n`);
311
+ return;
312
+ }
313
+ // 逐条串行处理:保证响应顺序与请求顺序一致(工具调用本身很快,不值得为并发引入状态问题)。
314
+ const task = (pending.at(-1) ?? Promise.resolve())
315
+ .then(() => handleMessage(session, message))
316
+ .then((response) => {
317
+ if (response) output.write(`${JSON.stringify(response)}\n`);
318
+ })
319
+ .catch((error) => {
320
+ output.write(`${JSON.stringify(fail(message?.id ?? null, -32603, String(error?.message ?? error)))}\n`);
321
+ });
322
+ pending.push(task);
323
+ if (pending.length > 64) pending.shift();
324
+ });
325
+ await new Promise((done) => rl.on('close', done));
326
+ await Promise.all(pending);
327
+ }
328
+
329
+ export const USAGE = `用法:node mcp.js [--store <路径>] [--print-config] [--version] [--help]
330
+
331
+ 零依赖、只读的 MCP stdio 服务器:把 Engramory markdown 记忆库暴露成
332
+ engramory_search / engramory_read / engramory_status 三个工具。
333
+ 库路径优先级:--store > 环境变量 ENGRAMORY_STORE > ${DEFAULT_STORE}
334
+ 日志一律写 stderr;stdout 只有 JSON-RPC 帧。`;
335
+
336
+ export function parseArgs(argv) {
337
+ const args = { store: '', help: false, version: false, printConfig: false };
338
+ for (let i = 0; i < argv.length; i += 1) {
339
+ const arg = argv[i];
340
+ if (arg === '--store') args.store = argv[++i] ?? '';
341
+ else if (arg.startsWith('--store=')) args.store = arg.slice('--store='.length);
342
+ else if (arg === '--help' || arg === '-h') args.help = true;
343
+ else if (arg === '--version' || arg === '-v') args.version = true;
344
+ else if (arg === '--print-config') args.printConfig = true;
345
+ else if (arg.trim()) args.error = `听不懂的参数:${arg}`;
346
+ }
347
+ return args;
348
+ }
349
+
350
+ export async function main(argv = process.argv.slice(2)) {
351
+ const args = parseArgs(argv);
352
+ if (args.error) {
353
+ process.stderr.write(`${args.error}\n\n${USAGE}\n`);
354
+ return 2;
355
+ }
356
+ if (args.help) {
357
+ process.stdout.write(`${USAGE}\n`);
358
+ return 0;
359
+ }
360
+ if (args.version) {
361
+ process.stdout.write(`${SERVER_VERSION}\n`);
362
+ return 0;
363
+ }
364
+ const session = createSession({ store: args.store || undefined });
365
+ if (args.printConfig) {
366
+ process.stdout.write(`${JSON.stringify({ name: SERVER_NAME, version: SERVER_VERSION, store: session.store })}\n`);
367
+ return 0;
368
+ }
369
+ process.stderr.write(`[${SERVER_NAME}] 库:${session.store} · 只读 · 日志只走 stderr\n`);
370
+ await serveStdio(session);
371
+ return 0;
372
+ }
373
+
374
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
375
+ main().then(
376
+ (code) => {
377
+ process.exitCode = code;
378
+ },
379
+ (error) => {
380
+ process.stderr.write(`[${SERVER_NAME}] 退出:${error?.stack ?? error}\n`);
381
+ process.exitCode = 1;
382
+ },
383
+ );
384
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-engramory-recall",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "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.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -37,7 +37,8 @@
37
37
  "./index-guard": "./index-guard.js",
38
38
  "./skill": "./skill.js",
39
39
  "./llm": "./llm.js",
40
- "./index": "./index.js"
40
+ "./index": "./index.js",
41
+ "./mcp": "./mcp.js"
41
42
  },
42
43
  "files": [
43
44
  "index.js",
@@ -51,6 +52,7 @@
51
52
  "index-guard.js",
52
53
  "skill.js",
53
54
  "llm.js",
55
+ "mcp.js",
54
56
  "CHANGELOG.md",
55
57
  "cordis.patch.yml",
56
58
  "README.md"
@@ -73,6 +75,10 @@
73
75
  "check": "node scripts/check-recall-wiring.mjs",
74
76
  "probe": "node scripts/recall-probe.mjs",
75
77
  "bench": "node bench/run.mjs",
76
- "bench:check": "node bench/run.mjs --check"
78
+ "bench:check": "node bench/run.mjs --check",
79
+ "mcp:smoke": "node scripts/mcp-smoke.mjs"
80
+ },
81
+ "bin": {
82
+ "engramory-recall-mcp": "./mcp.js"
77
83
  }
78
84
  }