dsh-engramory-recall 0.3.0 → 0.4.1

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,81 @@
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.4.1] — 2026-10-15
7
+
8
+ ### 变更
9
+
10
+ - `index.js` 的注释里去掉一处个人用户名 —— 发布包里唯一带私人痕迹的地方。
11
+ **没有任何行为变化**,纯粹是让别人打开源码时不看见作者的本机名。
12
+
13
+ ## [0.4.0] — 2026-10-15
14
+
15
+ **本插件从此是一个完整的记忆系统 —— 不再依赖 `dsh-engramory`。**
16
+
17
+ ### 新增
18
+
19
+ - **融入 `dsh-engramory` 的两件事**(它一共只做这两件,逻辑与措辞逐字保留):
20
+ - **索引上限闸门**(`index-guard.js`):`MEMORY.md` 长过 200 行 / 25 KB 就**拒绝写入**,
21
+ 但「缩小的重写永远放行」—— 那条例外看着像后门,实则是唯一的出路:索引一旦超限,
22
+ 若不放行缩小,就没有任何一次写入能把它降下来,闸门会把自己锁死。
23
+ 路径不必配置,固定是 `<storePath>/MEMORY.md`(原插件为此要人手钉一个绝对路径,
24
+ 因为它按 basename 匹配会误伤别的项目里同名的 `MEMORY.md`)。
25
+ - **`engramory` 技能**(`skill.js`):写入纪律的正文,注册进 `ctx.skills`。
26
+ `description` / `whenToUse` 与正文逐字保留它的措辞 —— 那些句子被反复打磨过,
27
+ 尤其「问候不算任务」那段边界,改一个字都可能松动纪律。
28
+ 新配置项 `indexMaxLines` / `indexMaxBytes` / `registerSkill`。
29
+ 融进来的理由不只是省一个依赖:两套闸门各管一半(这边管笔记文件、那边管索引),
30
+ **判据一旦漂移就会互相打架**,而这个项目已经因为「同一个概念有两处定义」栽过三次。
31
+ - **对照 C(正文独有词当查询)** 加进探针:query 词取自某篇正文、且任何标题里都没有它。
32
+ 它是**唯一能中立评价标题权重**的构造(A/B 都拿标题当查询,对此结构性偏袒)。
33
+ 60 篇 / 172 chunk 下 159 条:`top1 123/159 · recall@4 148/159`。
34
+
35
+ ### 变更
36
+
37
+ - **`TITLE_EXTRA` 3 → 24。** 0.3.1 把它定成 3 的理由有个没发现的漏洞:「口径在 3 之后
38
+ 不再动,所以后面的增量全来自对照,而对照只是别掉分的底线」—— 可对照 A/B **都拿标题
39
+ 当查询**,对标题权重是**结构性偏袒**的,它们既不能当目标函数,**也不能**用来证明
40
+ 「抬高标题权重是安全的」(它们测不出这件事)。补上中立的对照 C 后,数据说 3 太保守:
41
+ C 从 0 到 96 **一路不降**(`118/140 → 122/140 → … → 148/159`),在 **24** 到达平台,
42
+ 之后 32/48/96 一动不动。原先担心的「标题压过正文」在数据里**不存在**。
43
+ 取 24 = 第一个让 C 到达平台的取值(48/96 只多 1 个 top1,@4 一个不涨)。
44
+ 口径在**所有**取值上恒为 20/21,对这个参数**没有分辨力** —— 这一点必须写下来,
45
+ 免得下一轮又有人拿「口径没动」当作「所以取哪个都行」。
46
+
47
+ ### 测试
48
+
49
+ - 128 个用例全绿(原 94)。新增 `test/index-guard.test.mjs`(17)与 `test/skill.test.mjs`(17)。
50
+ 技能那套守的是**融合的完整性**:正文是搬来的,最容易出的不是语法错,而是**边界被悄悄放松**
51
+ (比如「问候不算任务」那句没了),而那不会有任何报错。
52
+
53
+ ## [0.3.1] — 2026-10-15
54
+
55
+ 来源:清点灵枢(`dsh-memory`)最后一份可读产物时,发现两个**我们还没吸收**的机制。
56
+ 一个采纳、一个实测量到**有害**并否决 —— 两条都留在代码里当证据。
57
+
58
+ ### 新增
59
+
60
+ - **字段加权(`TITLE_EXTRA = 3`)**:标题与描述里的词额外多计 3 遍(BM25F 的廉价近似)。
61
+ 此前 `title/description/body` 被拼成一坨等权处理,于是「长笔记正文里偶然提过一次」
62
+ 和「标题就叫这个」同分。实测(指纹 `c9f7fcbf9426`):对照A `top1 20→21 | @4 79→91`、
63
+ 对照B `top1 134→141 | @4 144→146`,口径 / 桥探针 / 别名探针**一项未变**。
64
+ 出处:灵枢 `scoreEntry`(`lib/index.js:127-129`)的标题 3 / 标签 2 / 正文 1。
65
+
66
+ ### 记录在案(否决)
67
+
68
+ - **整句子串命中奖励(`PHRASE_BONUS`)实测有害,保持 0**:机制与常量都保留,
69
+ 但实测让它单调恶化 —— 在 `TITLE_EXTRA=3` 之上取 `2/5/10` 时,对照A top1 从
70
+ `21 → 20 → 19 → 17`,而口径一步不动。机理与踩过两次的自污染一致:它奖励的是
71
+ 「逐字引用了查询」的笔记,于是把更该命中的那篇挤下去。常量旁的注释里有完整数据表,
72
+ 将来谁想加,先看那张表。
73
+
74
+ ### 说明(判断)
75
+
76
+ - 字段加权的扫描里,`6` 与 `24` 在**对照**上继续变好(`24/92`、`33/93`),但口径
77
+ 在 `3` 之后**一步都不再动**。取 `3`(设计值)而非扫描极值:对照在本项目的 README 里
78
+ 写明是「别掉分的底线」而非目标函数,拿它当目标去调参正是 `EXTRA_WEIGHT` 那次
79
+ 「把功劳记错对象」的同一个坑。要往上抬,`bm25.js` 的常量注释里有现成证据表。
80
+
6
81
  ## [0.3.0] — 2026-10-15
7
82
 
8
83
  这一版的共同点是:**把「看起来在工作」变成「量得出来在工作」**。
package/README.md CHANGED
@@ -95,11 +95,11 @@
95
95
  `form: notice`),内容是「可能相关的既有条目」= 文件名 + 命中行片段 + 一句「需要细节自己 read」;
96
96
  5. 没命中 → 原样放行。
97
97
 
98
- ### 八个机制一览
98
+ ### 十个机制一览
99
99
 
100
100
  | # | 机制 | 治什么 |
101
101
  |---|---|---|
102
- | 1 | 同义扩展(65 条口语→技术词)+ 连字符再切分 | 中文口语与笔记用词对不上的「同义鸿沟」 |
102
+ | 1 | 同义扩展(105 条口语→技术词)+ 连字符再切分 | 中文口语与笔记用词对不上的「同义鸿沟」 |
103
103
  | 2 | 超长正文按 `##` 切桶 + **整篇**长度归一 | 单篇巨文霸榜(一篇 267KB 的笔记曾占掉 1/4 召回位) |
104
104
  | 3 | 寒暄 / 极短消息门控 | 「继续 / 嗯」这类消息 10/10 注入噪音 |
105
105
  | 4 | **写入闸门(fail-closed)** | 不合规文件名、缺失 frontmatter、疑似真实凭据 |
@@ -107,6 +107,14 @@
107
107
  | 6 | 预算降级阶梯 | 装不下的条目塌缩成只剩文件名,而不是静默丢掉 |
108
108
  | 7 | 跨轮轮换降权 | 最近注入过的条目在后续轮次乘 0.5,把机会让给别的笔记 |
109
109
  | 8 | 沉淀提醒 | **长会话一次都没写过记忆库时,提醒一次** —— 补「该记的没记」 |
110
+ | 9 | **索引上限闸门**(`index-guard.js`) | `MEMORY.md` 长过 200 行 / 25KB 就拒绝写入 —— 索引是每次都读的,超限部分会被**静默地**不再召回 |
111
+ | 10 | **`engramory` 技能**(`skill.js`) | 什么时候该记、什么时候不该记这套纪律本身 —— 把它注册成技能,才不依赖外部插件 |
112
+
113
+ > 9 与 10 是 **2026-10-15 从 `dsh-engramory` 整个融进来的**(逻辑与措辞逐字保留)。
114
+ > 那个插件一共只做这两件事,收完之后它就可以从 profile 里摘掉了。
115
+ > 融进来的理由不只是省一个依赖:两套闸门各管一半(这边管笔记文件、那边管索引)、
116
+ > 判据一旦漂移就会互相打架,而这个项目**已经因为「同一个概念有两处定义」栽过三次**
117
+ > (见文末第五条)。一个概念一处定义。
110
118
 
111
119
  ### 写入闸门(`guard.js`)
112
120
 
@@ -319,6 +327,9 @@ aliases: 梯子, 翻墙, 科学上网, 美国节点, 代理
319
327
  decayHalfLifeDays: 0 # freshness 指数衰减
320
328
  recencyWeight: 0 # 时间乘性加权
321
329
  autoArchive: false # 全自动归档(默认关:归档是改库,先手动点几轮再决定)
330
+ indexMaxLines: 200 # 索引 MEMORY.md 的行数上限(融合自 dsh-engramory)
331
+ indexMaxBytes: 25600 # 索引的字节上限(同上)
332
+ registerSkill: true # 注册 engramory 技能(写入纪律的正文)
322
333
  ```
323
334
 
324
335
  > `autoArchive` 打开后归档会在两处自动跑:每轮 pre-step 兜底(10 分钟 TTL,不会每轮扫库),
@@ -328,7 +339,8 @@ aliases: 梯子, 翻墙, 科学上网, 美国节点, 代理
328
339
  ## 安装 / 卸载
329
340
 
330
341
  ```bash
331
- dsh plugin --profile <profile> add /path/to/dsh-engramory-recall
342
+ npm i dsh-engramory-recall # 从 npm 装
343
+ dsh plugin --profile <profile> add /path/to/dsh-engramory-recall # 或从源码目录装
332
344
  dsh plugin --profile <profile> remove dsh-engramory-recall
333
345
  ```
334
346
 
@@ -336,17 +348,24 @@ dsh plugin --profile <profile> remove dsh-engramory-recall
336
348
  > 软链断链时宿主只会打印一行 `skipping profile bundle`,**插件整轮不加载却不影响其它功能**
337
349
  > —— 这个坑我们真踩过(白测了三天)。
338
350
 
351
+ > ⚠️ **`dsh plugin` 是「全有或全无」的**:它每次都会重装 profile 的**整个**依赖集,
352
+ > 所以只要 `package.json` 里有**任何一个** spec 解析不了(版本被 registry 下架、
353
+ > 被供应链策略拦住、指向已删的路径),**添/删任何插件都会一起失败**,
354
+ > 而且报错说的是那个无关的包。2026-10-15 真踩过:`dsh-boot-animation@^0.1.2`
355
+ > 的版本被镜像下架,导致「移除 `dsh-engramory`」报的是 `dsh-boot-animation` 的错。
356
+ > 排查时看 `.plugin-manager/logs/operation-*/pnpm.log`,别盯被操作的那个包。
357
+
339
358
  ## 测试与自检
340
359
 
341
360
  ```bash
342
- npm test # 89 个用例:分词 / BM25 / 切桶 / 门控 / 闸门 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由
361
+ npm test # 132 个用例:分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由
343
362
  npm run check # 检查 profile 接线是否还覆盖得住上游默认键
344
- npm run probe # 召回质量探针:3 组 query(36 条)+ 两道对照,跑在**真实记忆库**上
363
+ npm run probe # 召回质量探针:3 组 query(36 条)+ 三道对照,跑在**真实记忆库**上
345
364
  npm run probe -- --save # 把这次结果存成基线,之后每次跑自动比对
346
365
  ```
347
366
 
348
367
  > 这三条是**克隆仓库开发时**用的:`test/` 与 `scripts/` 不在 npm 包的 `files` 白名单里。
349
- > 发布出去的是运行时真正需要的那 12 个文件(`npm pack --dry-run` 可核),
368
+ > 发布出去的是运行时真正需要的那 15 个文件(`npm pack --dry-run` 可核),
350
369
  > 其中 `package.test.mjs` 会从 `index.js` 递归跟所有 `./x.js` 的 import,
351
370
  > 逐个核对是否都在白名单里 —— 这个检查已经抓到过两次真事故(漏 `audit.js`、漏 `archive.js`),
352
371
  > 两次的后果都是别人装上一装就 `ERR_MODULE_NOT_FOUND`、整个插件加载失败。
@@ -359,8 +378,15 @@ npm run probe -- --save # 把这次结果存成基线,之后每次跑自动
359
378
  | **桥探针** | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 **10/10** |
360
379
  | **别名探针** | 5 | 笔记自己的 `aliases:`(关键词在全局桥里一个都没有) | recall@4 **5/5** |
361
380
  | **死桥自检** | 301 值 | 同义桥每个值是否真的存在于语料(不存在 = 白写) | **25 个死值**(均为通用词,见上) |
362
- | 对照 A | 169 | 把文件名当 query 的底线 | top1 22/169 · recall@4 82/169 |
363
- | 对照 B | 144 | 把笔记中文标题当 query 的底线 | top1 135/144 · recall@4 142/144 |
381
+ | 对照 A | 172 | 把文件名当 query 的底线(**对标题权重有偏袒,见下**) | top1 33/172 · recall@4 93/172 |
382
+ | 对照 B | 147 | 把笔记中文标题当 query 的底线(**同上,偏袒**) | top1 142/147 · recall@4 146/147 |
383
+ | **对照 C** | 159 | **正文独有词**当 query(词在任何标题里都没有) | top1 123/159 · recall@4 **148/159** |
384
+
385
+ > 对照 A 与对照 B **都拿标题当查询**,所以它们对「标题里的词多计几遍」这类参数是
386
+ > **结构性偏袒**的:抬高标题权重几乎必然让这两组变好 —— 那不是证据,是同义反复。
387
+ > 它们既不该当目标函数,**也不能**用来证明「抬高标题权重是安全的」(它们测不出这件事)。
388
+ > 唯一中立的是**对照 C**:query 词取自某篇正文、且任何标题里都没有它,此时抬高标题权重
389
+ > 只会让标题里恰好撞上这个词的**别的**笔记平白加分、把正确答案挤下去。
364
390
 
365
391
  `npm run probe` 存在的理由值得单说:`bm25.js` 里的同义表与 `EXTRA_WEIGHT`(同义桥权重)
366
392
  是**语料统计量撑起来的经验值**,在小 fixture 里测不出差异 —— 我们写过一版单测,
@@ -396,6 +422,12 @@ node verify-panel.mjs
396
422
 
397
423
  ## 集百家之长:一半的机制不是我们想出来的
398
424
 
425
+ **2026-10-15 补完最后两块拼图:`dsh-engramory` 已整个融进来。** 它一共只做两件事 ——
426
+ `MEMORY.md` 的行数/字节上限闸门(逻辑搬进 `index-guard.js`)和注册 `engramory` 技能
427
+ (正文搬进 `skill.js`,措辞逐字保留)。两件都收进来之后,**本插件就是一个完整的记忆系统,
428
+ 不再依赖任何外部记忆插件**;那个插件现在可以从 profile 里摘掉了。
429
+
430
+
399
431
  四家竞品的源码逐个读完(hindsight / 灵枢 dsh-memory / dsh-mnemon / dsh-mneme),
400
432
  产出要求是「机制 + `文件:行号` + 能否在纯 JS、零依赖、零出网下复刻」。**能搬的搬,
401
433
  搬不动或搬了没用的当场证伪** —— 证伪的那两条同样有价值,见文末。
@@ -427,7 +459,7 @@ node verify-panel.mjs
427
459
  | DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
428
460
  | **本插件** | **0** | **≤480 字符,且只有指针** | **无** |
429
461
 
430
- ## 四条被实测证伪的路(留着别再走一遍)
462
+ ## 五条被实测证伪的路(留着别再走一遍)
431
463
 
432
464
  | 尝试 | 结果 |
433
465
  |---|---|
@@ -435,6 +467,7 @@ node verify-panel.mjs
435
467
  | freshness 时间衰减(学灵枢 / mneme) | 六档半衰期扫描:recall 恒为 28/28、top-1 只有一档 +1(噪声级)。库里「旧」的那批恰好是基础事实,衰减就是误伤 |
436
468
  | 时间乘性加权(学 hindsight) | 五档扫描净收益为零,w ≥ 0.1 起反而伤对照组 |
437
469
  | 给一个已有同义桥**继续加词**(`主题` 从 `['theme']` 扩到含 `light-dark / color-mix / data-theme / token`) | 看着全是相关的技术词,实测**对照 B 掉 3 分**(136→133)。机制:把词加进查询扩展后,**同一篇笔记自己的其它 chunk 会把"标题里真的写着这个词"的那个 chunk 挤下去** —— 长笔记里这叫自己跟自己抢。判据不是"这词相不相关",而是"加了它,谁会往上浮"。这 4 个词已全部退回 |
470
+ | **拿偏袒的对照当护栏**:用对照 A/B 证明"抬高标题权重是安全的" | 它们**都拿标题当查询**,对标题权重结构性偏袒 —— 抬高它必然让两组变好。这不是证据,是同义反复。`TITLE_EXTRA` 第一次定成 3 就是被这个漏洞困住的("口径在 3 之后不再动,所以后面的增量全来自对照"—— 对照根本测不出这件事)。补了中立的**对照 C** 之后,数据说 3 太保守:C 从 0 到 96 一路不降,在 **24** 到达平台。已按 C 的拐点改成 24 |
438
471
 
439
472
  ## 许可
440
473
 
package/bm25.js CHANGED
@@ -34,6 +34,75 @@ const B = 0.75;
34
34
  * 教训:**A/B 要在最终状态上做**。中途量出来的差值,可能只是「另一半还没做完」。
35
35
  */
36
36
  const EXTRA_WEIGHT = 0.5;
37
+ /**
38
+ * 字段加权:标题与描述里的词额外多计几遍(BM25F 的廉价近似)。
39
+ *
40
+ * 出处:灵枢 `dsh-memory` 的 `scoreEntry`(`lib/index.js:127-129`)给标题 3 分、
41
+ * 标签 2 分、正文 1 分,理由是「在标题里出现」本身就是强信号。我们原来把
42
+ * title/description/body 拼成一坨等权处理(`buildIndex`),于是「一篇长笔记
43
+ * 正文里偶然提过一次」和「标题就叫这个」拿一样的分。
44
+ *
45
+ * 用「多计几遍」而不是真做字段分离:BM25 的长度归一化会跟着一起变,
46
+ * 而短笔记的标题命中本来就该更值钱。
47
+ *
48
+ * 实测(2026-10-15,60 篇 / 172 chunk,指纹 c9f7fcbf9426):
49
+ * 值 口径 桥 别名 对照A top1/@4 对照B top1/@4 对照C top1/@4
50
+ * 0 20/21 10/10 5/5 20 / 79 134 / 144 118 / 140
51
+ * 1 20/21 10/10 5/5 20 / 77 138 / 144 122 / 140
52
+ * 3 20/21 10/10 5/5 21 / 91 141 / 146 122 / 141
53
+ * 6 20/21 10/10 5/5 24 / 92 142 / 146 122 / 141
54
+ * 8 20/21 10/10 5/5 — — 122 / 145
55
+ * 12 20/21 10/10 5/5 24 / 92 142 / 146 122 / 146
56
+ * 16 20/21 10/10 5/5 — — 123 / 146
57
+ * 24 20/21 10/10 5/5 33 / 93 142 / 146 123 / 148
58
+ * 32 20/21 10/10 5/5 — — 123 / 148
59
+ * 48 20/21 10/10 5/5 — — 124 / 148
60
+ * 96 20/21 10/10 5/5 — — 123 / 148
61
+ *
62
+ * ── 为什么从 3 改到 24(2026-10-15 第二次定这个值时想清楚的)──
63
+ *
64
+ * 第一次取 3 的理由是「口径在 3 之后不再动,所以后面的增量全来自对照,而对照只是
65
+ * 别掉分的底线」。那个理由有个**没被发现的漏洞**:对照 A 与对照 B **都拿标题当查询**,
66
+ * 所以它们对标题权重是**结构性偏袒**的 —— 抬高标题权重几乎必然让这两组变好,那不是
67
+ * 证据,是同义反复。拿它们当目标函数固然错,拿它们当「没变差就行」的护栏**也**证明
68
+ * 不了抬高标题权重是安全的:它们根本测不出这件事。
69
+ *
70
+ * 于是补了**对照 C(正文独有词当查询)**:query 词取自某篇正文、且**任何**标题/描述
71
+ * 里都没有它(df ≤ 3)。这时抬高标题权重只会让标题里恰好撞上这个词的**别的**笔记
72
+ * 平白加分、把正确答案挤下去 —— 这是唯一能中立评价标题权重的构造。
73
+ *
74
+ * 对照 C 的结果:**从 0 到 96 一路不降**(118 → 122 → … → 148),在 **24** 到达平台,
75
+ * 之后 32/48/96 一动不动。原先担心的「标题压过正文」现象**在数据里不存在**。
76
+ * 机制上讲得通:同一篇笔记的每个 chunk 共用同一个标题,所以标题权重实质上是一个
77
+ * **笔记级的话题先验**,而不是「标题党的胜利」。
78
+ *
79
+ * 取 **24**(= 第一个让不受偏袒的对照 C 到达平台的取值),不取 48/96 ——
80
+ * 它们只多 1 个 top1、@4 一个不涨。口径在**所有**取值上都恒为 20/21,
81
+ * 所以它对这个参数**没有分辨力**,不能拿它做任何取舍(这一点必须诚实地写下来,
82
+ * 免得下一轮又有人拿「口径没动」当作「所以取哪个都行」)。
83
+ */
84
+ const TITLE_EXTRA = 24;
85
+ /**
86
+ * 整句子串命中奖励:文档里逐字出现了整条查询时加的分。
87
+ *
88
+ * 出处:灵枢 `dsh-memory`(`lib/index.js:133`,它加 2 分)。
89
+ * 对中文特别有意义:没有分词器时「浏览器起不来」被拆成 `浏览/览器/器起/起不/不来`,
90
+ * 通用谓语的碎片反而 IDF 更高;而整句子串命中是**不需要分词器**的强证据。
91
+ *
92
+ * ⚠️ 风险(诚实写明):这条奖励的是「逐字引用了查询」的笔记 —— 而那正是我们
93
+ * 踩过两次的自污染形态(调优笔记抄了探针 query 就霸榜)。
94
+ *
95
+ * **实测否决(2026-10-15,在 TITLE_EXTRA=3 之上)**:
96
+ * PHRASE_BONUS 口径 对照A top1 对照B top1
97
+ * 0 20/21 21 141
98
+ * 2 20/21 20 142
99
+ * 5 20/21 19 142
100
+ * 10 20/21 17 142
101
+ * 口径一步不动,对照A 单调恶化(21 → 17)—— 奖励「抄了查询的笔记」确实会把
102
+ * 更该命中的那篇挤下去,与自污染的机理一致。**所以保持 0,机制留在代码里当证据**:
103
+ * 将来若有人想加,先看这张表。常量与实现都保留,是为了让这次否决可复现,不是待办。
104
+ */
105
+ const PHRASE_BONUS = 0;
37
106
  /**
38
107
  * 单次查询最多参与打分的词项数(IDF 选词的 cap,抄 hindsight
39
108
  * `engine/search/bm25_term_selection.py`,与灵枢 `cn_recall_grams` 的 cap 同源)。
@@ -293,12 +362,19 @@ export function buildIndex(docs, options = {}) {
293
362
  // 没写 aliases 的笔记不受影响。
294
363
  const aliases = Array.isArray(doc.aliases) ? doc.aliases.filter(Boolean).join(' ') : '';
295
364
  const tokens = tokenize(aliases ? `${text}\n${aliases} ${aliases}` : text);
365
+ // 字段加权:标题/描述里的词额外多计几遍(见 TITLE_EXTRA)。默认关闭时整段不执行。
366
+ if (TITLE_EXTRA > 0) {
367
+ const head = [doc.title, doc.description].filter(Boolean).join('\n');
368
+ if (head) for (let i = 0; i < TITLE_EXTRA; i += 1) tokens.push(...tokenize(head));
369
+ }
296
370
  const tf = new Map();
297
371
  for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1);
298
372
  return {
299
373
  ...doc,
300
374
  tf,
301
375
  len: tokens.length || 1,
376
+ // 整句子串命中用的小写全文(见 PHRASE_BONUS)。关闭时不建,省一份内存。
377
+ plain: PHRASE_BONUS > 0 ? text.toLowerCase() : '',
302
378
  decay: decayFactor(doc.mtimeMs, halfLifeDays, now),
303
379
  boost: recencyBoost(doc.mtimeMs, oldestMs, newestMs, recencyWeight),
304
380
  };
@@ -322,6 +398,11 @@ export function search(index, query, limit = 5) {
322
398
  const extras = expandQuery(query);
323
399
  if (!base.length && !extras.length) return [];
324
400
 
401
+ // 整句子串命中的针(见 PHRASE_BONUS)。太短的查询不判:像「线上」这种两个字,
402
+ // 逐字命中满库都是,只会制造噪声。
403
+ const needle = PHRASE_BONUS > 0 ? String(query ?? '').trim().toLowerCase() : '';
404
+ const usePhrase = needle.length >= 2;
405
+
325
406
  const weights = new Map();
326
407
  for (const term of base) weights.set(term, 1);
327
408
  for (const term of extras) if (!weights.has(term)) weights.set(term, EXTRA_WEIGHT);
@@ -369,6 +450,8 @@ export function search(index, query, limit = 5) {
369
450
  // decay 无界(指数,默认关)、boost 有界(±w,默认关),各自独立开关。
370
451
  score *= doc.decay ?? 1;
371
452
  score *= doc.boost ?? 1;
453
+ // 整句子串命中:加在乘性调整之后,是纯粹的加法微调,不参与 BM25 的饱和曲线。
454
+ if (usePhrase && score > 0 && doc.plain.includes(needle)) score += PHRASE_BONUS;
372
455
  if (score > 0) hits.push({ id: doc.id, section: doc.section, score, snippet: bestLine(doc, matched), matched });
373
456
  }
374
457
  hits.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
package/index-guard.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * index-guard.js —— `MEMORY.md` 索引上限闸门(默认 200 行 / 25KB)。
3
+ *
4
+ * **融合自 `dsh-engramory` v0.2.5 的 `refuseOversizedIndex`(其 `index.js:128-230`)。**
5
+ * 吸收它的理由:本插件要做**完整的记忆系统**,不该把「索引上限」这一块留在外部依赖里
6
+ * —— 那个插件一共只做两件事(本闸门 + 注册 `engramory` 技能),我们把两件都收进来之后,
7
+ * 它就可以退场了。
8
+ *
9
+ * 原文的核心设计逐条保留(这些是踩过坑才有的,不要想当然地简化):
10
+ *
11
+ * 1. **只拒绝「确实在长大」的写入**。判据是「某一维**最终超限** 且 **比当前文件大**」,
12
+ * 所以任何**缩小**的重写永远放行 —— 否则索引一旦超限就再也压不回去,变成死锁。
13
+ * 用户看到的是「先压缩」的提示,而压缩这条路必须是通的。
14
+ * 2. **当前文件不存在/读不到 = 空**(0 行 0 字节),所以「第一次写入就超限」照样拒绝。
15
+ * 3. **字节数取原始 buffer**,不先 decode。原文注释记了原因:非 UTF-8 的索引解码后
16
+ * 每个坏字节变成 3 字节的 U+FFFD,会把一次**真增长**算成**缩小**而放行。
17
+ * 行数可以用解码后的文本(换行不会因有损解码而改变)。
18
+ * 4. **片段写入(edit)先做模拟**:能还原出结果就按结果判;还原不出来(参数形状不认),
19
+ * 就只在「当前已经超限」时拒绝盲改 —— 且明确告诉对方出口是整篇重写。
20
+ * 5. **basename 大小写不敏感**:macOS/Windows 上 `memory.md` 就是那个索引。
21
+ * 6. 行数按**末尾换行不敏感**计(`"a\n"` 是 1 行不是 2 行)。
22
+ *
23
+ * 我们**没有**搬过来的部分(诚实写明,别以为它有):
24
+ * - `str_replace_editor` 那套工具名与 `old_str`/`new_str` 参数形状 —— 本宿主用的是
25
+ * `write` / `edit`(`old_string`/`new_string`),搬一套用不上的分支是纯负担。
26
+ * - `indexName` 配置项:本插件**知道**库在哪,索引路径直接由 `storePath` 推出来
27
+ * (`<storePath>/MEMORY.md`),比人手钉一个绝对路径更不容易错。
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ /** 行数上限。与 `AGENTS.md` 里写的「索引上限 200 行 / 25KB」一致。 */
33
+ export const DEFAULT_MAX_INDEX_LINES = 200;
34
+ /** 字节上限(25KB = 25600,取十进制,与 dsh-engramory 的默认值一致)。 */
35
+ export const DEFAULT_MAX_INDEX_BYTES = 25600;
36
+
37
+ /** 能整篇替换内容的工具:给了 `content` 就能算出写入后的完整结果。 */
38
+ const WHOLE_FILE_TOOLS = new Set(['write']);
39
+ /** 只给片段的工具:只能靠模拟还原结果。 */
40
+ const PARTIAL_TOOLS = new Set(['edit']);
41
+
42
+ /**
43
+ * 末尾换行不敏感的行数。`""` → 0,`"a\n"` → 1,`"a\nb"` → 2。
44
+ * (与 dsh-engramory 的 `countLines` 逐字一致 —— 两边算出来的数必须一样,
45
+ * 否则同一个索引在两次检查里会有两个高度。)
46
+ */
47
+ export function countLines(text) {
48
+ if (!text) return 0;
49
+ return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
50
+ }
51
+
52
+ /** 人类可读的超限描述;没超返回 `null`。 */
53
+ export function measureIndex(text, maxLines, maxBytes) {
54
+ const lines = countLines(text);
55
+ const bytes = Buffer.byteLength(text, 'utf8');
56
+ const over = [];
57
+ if (lines > maxLines) over.push(`${lines} 行 > ${maxLines}`);
58
+ if (bytes > maxBytes) over.push(`${bytes} 字节 > ${maxBytes}`);
59
+ return over.length ? over.join(',') : null;
60
+ }
61
+
62
+ /** 还原一次 edit 的写入结果;参数形状认不出来时返回 `undefined`。 */
63
+ function simulateEdit(current, oldString, newString) {
64
+ if (typeof oldString !== 'string' || oldString === '') return undefined;
65
+ const next = typeof newString === 'string' ? newString : '';
66
+ const first = current.indexOf(oldString);
67
+ // 找不到 old_string:真正的工具会报错、什么也不改 —— 那「当前内容」就是诚实的预测。
68
+ if (first === -1) return current;
69
+ // 出现多次:真正的工具同样会报错,什么都不改。
70
+ if (current.indexOf(oldString, first + oldString.length) !== -1) return current;
71
+ return current.slice(0, first) + next + current.slice(first + oldString.length);
72
+ }
73
+
74
+ /** 读原始 buffer;读不到返回 `undefined`(读不到不算超限,见文件头第 2 条)。 */
75
+ function readRaw(filePath) {
76
+ try {
77
+ return readFileSync(filePath);
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 判一次写入是否会把索引顶过上限。
85
+ *
86
+ * @param {object} input
87
+ * @param {string} input.filePath 目标文件(write/edit 的 file_path)
88
+ * @param {string} input.storePath 记忆库根目录;索引固定是它下面的 `MEMORY.md`
89
+ * @param {string} input.tool `write` 或 `edit`
90
+ * @param {string} [input.content] 全量内容(write)
91
+ * @param {string} [input.oldString] 被替换的片段(edit)
92
+ * @param {string} [input.newString] 替换成的片段(edit)
93
+ * @param {number} [input.maxLines]
94
+ * @param {number} [input.maxBytes]
95
+ * @returns {{ ok: true, skipped?: string } | { ok: false, reason: string }}
96
+ */
97
+ export function checkIndexWrite({
98
+ filePath,
99
+ storePath,
100
+ tool,
101
+ content,
102
+ oldString,
103
+ newString,
104
+ maxLines = DEFAULT_MAX_INDEX_LINES,
105
+ maxBytes = DEFAULT_MAX_INDEX_BYTES,
106
+ }) {
107
+ const root = String(storePath ?? '').trim();
108
+ if (!root || !filePath) return { ok: true, skipped: 'not-configured' };
109
+
110
+ const abs = path.resolve(String(filePath));
111
+ const indexAbs = path.resolve(root, 'MEMORY.md');
112
+ // 只认「就是那个索引文件」:同一个目录、同一个名字(大小写不敏感,见文件头第 5 条)。
113
+ if (path.dirname(abs) !== path.dirname(indexAbs)) return { ok: true, skipped: 'outside-store' };
114
+ if (path.basename(abs).toLowerCase() !== path.basename(indexAbs).toLowerCase()) {
115
+ return { ok: true, skipped: 'not-index' };
116
+ }
117
+
118
+ const name = path.basename(abs);
119
+ const reject = (reason) => ({ ok: false, reason });
120
+
121
+ if (WHOLE_FILE_TOOLS.has(tool)) {
122
+ if (typeof content !== 'string') return { ok: true, skipped: 'no-content' };
123
+ return verdict(content, readRaw(abs), name, maxLines, maxBytes);
124
+ }
125
+
126
+ if (!PARTIAL_TOOLS.has(tool)) return { ok: true, skipped: 'not-a-write' };
127
+
128
+ const currentRaw = readRaw(abs);
129
+ if (currentRaw === undefined) return { ok: true, skipped: 'no-current' };
130
+ const current = currentRaw.toString('utf8');
131
+ const simulated = simulateEdit(current, oldString, newString);
132
+ if (simulated !== undefined) return verdict(simulated, currentRaw, name, maxLines, maxBytes);
133
+
134
+ // 结果还原不出来。诚实的便宜规则:索引**已经**超限时,不许再盲改它,
135
+ // 并且要指出出口 —— 只说「先压缩」而不说怎么压,等于把路堵死。
136
+ const breach = measureIndex(current, maxLines, maxBytes);
137
+ if (!breach) return { ok: true, skipped: 'unmeasurable' };
138
+ return reject(
139
+ `拒绝盲改索引:${name} 当前已超上限(${breach}),而这次编辑的结果无法从参数还原。` +
140
+ `请改用整篇重写来压缩:**缩小的重写永远放行**(哪怕仍然超限)。` +
141
+ `把过长的行改成「一句钩子 + 链接」、合并重复项、把冷笔记归档 —— ` +
142
+ `索引是目录,超限之后的部分会被静默地不再被召回。`,
143
+ );
144
+ }
145
+
146
+ /**
147
+ * 核心判据:只在「某一维最终超限、且比当前文件大」时拒绝。
148
+ * 照抄 dsh-engramory 的 `verdict`(其 `index.js:204-230`),逐条保留其注释里的坑。
149
+ */
150
+ function verdict(text, currentRaw, name, maxLines, maxBytes) {
151
+ const lines = countLines(text);
152
+ const bytes = Buffer.byteLength(text, 'utf8');
153
+ // 当前尺寸取**原始 buffer**:先解码会把非 UTF-8 的坏字节膨胀成 U+FFFD,
154
+ // 让一次真增长看起来像缩小而放行(原文记录的真实缺陷)。
155
+ const curLines = currentRaw === undefined ? 0 : countLines(currentRaw.toString('utf8'));
156
+ const curBytes = currentRaw === undefined ? 0 : currentRaw.length;
157
+
158
+ const over = [];
159
+ if (lines > maxLines && lines > curLines) over.push(`${lines} 行 > ${maxLines}`);
160
+ if (bytes > maxBytes && bytes > curBytes) over.push(`${bytes} 字节 > ${maxBytes}`);
161
+ if (!over.length) return { ok: true };
162
+
163
+ return {
164
+ ok: false,
165
+ reason:
166
+ `拒绝写入记忆库:这次写入会把索引 ${name} 顶过上限(${over.join(',')})。` +
167
+ `索引每次会话都要读、而宿主只读前面那么多,超限的部分会被静默地不再被召回。` +
168
+ `请先压缩再写:把细节挪进各自的笔记文件、合并重复、归档冷笔记,` +
169
+ `每行保持「一句短钩子 + 链接」。**缩小的重写永远放行**,可以一步一步压。`,
170
+ };
171
+ }
package/index.js CHANGED
@@ -22,6 +22,8 @@ import { homedir } from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { buildIndex, search, clip } from './bm25.js';
24
24
  import { checkMemoryWrite } from './guard.js';
25
+ import { checkIndexWrite, DEFAULT_MAX_INDEX_BYTES, DEFAULT_MAX_INDEX_LINES } from './index-guard.js';
26
+ import { registerSkill, skillStatus } from './skill.js';
25
27
  import { appendAudit, readAudit, GUARD_EPOCH } from './audit.js';
26
28
  import { readDocs } from './store.js';
27
29
  import { auditStore } from './doctor.js';
@@ -120,6 +122,27 @@ const DEFAULTS = {
120
122
  * 这种绕过闸门的情况。自动执行的每一步都进写入审计,`reason` 里写明触发源。
121
123
  */
122
124
  autoArchive: false,
125
+ /**
126
+ * 索引上限闸门:`MEMORY.md` 长过这里就拒绝写入(缩小的重写永远放行)。
127
+ *
128
+ * **融合自 `dsh-engramory` v0.2.5**,逻辑逐条搬进了 `index-guard.js`。吸收它的理由:
129
+ * 本插件要做完整的记忆系统,不该把「索引上限」留在外部依赖里 —— 那个插件一共只做
130
+ * 两件事(本闸门 + 注册 `engramory` 技能),两件都收进来之后它就可以退场了。
131
+ *
132
+ * 路径不必配置:本插件知道库在哪,索引固定是 `<storePath>/MEMORY.md`。
133
+ * 这比让人手钉一个绝对路径更不容易错(原插件正是为此加了 `indexPath` 配置项,
134
+ * 因为它按 basename 匹配会误伤别的项目里同名的 `MEMORY.md`)。
135
+ */
136
+ indexMaxLines: DEFAULT_MAX_INDEX_LINES,
137
+ indexMaxBytes: DEFAULT_MAX_INDEX_BYTES,
138
+ /**
139
+ * 注册 `engramory` 技能(写入纪律的正文)。默认开。
140
+ *
141
+ * 同样融合自 `dsh-engramory`(正文与 description/whenToUse 逐字保留它的措辞)。
142
+ * 两者同时装着时会注册同名技能 —— 内容一致,谁赢都一样;注册处包了 try/catch,
143
+ * 万一注册表对重名是抛错而非覆盖,不会因为一个技能名把宿主拖崩。
144
+ */
145
+ registerSkill: true,
123
146
  };
124
147
 
125
148
  /** 只在用户句子里出现明确的「长期记住」意图时才追加一行提示(默认开)。 */
@@ -157,7 +180,7 @@ const textOf = (message) =>
157
180
  const isHuman = (message) => message?.role === 'user' && message?.source?.kind === 'user';
158
181
 
159
182
  /**
160
- * 把配置里的 `~/xxx` 展开成真实家目录。别人装这个插件时不会恰好也叫 yangfu,
183
+ * 把配置里的 `~/xxx` 展开成真实家目录。这步不能省:别人机器上的家目录不是配置里那个字面量,
161
184
  * 而 cordis 的 yaml 配置里 `~` 不会被自动展开——不处理的话 `storePath` 会静默无效
162
185
  * (readDocs 读不到目录、返回空数组,召回从此一条都不出,且没有任何报错)。
163
186
  */
@@ -315,20 +338,35 @@ export function apply(ctx, config = {}) {
315
338
  const filePath = args.file_path ?? args.filePath;
316
339
  if (!filePath) return undefined;
317
340
  const isWrite = toolName === 'write';
318
- const verdict = checkMemoryWrite({
341
+ // 笔记文件规则先判;命中索引文件时它返回 skipped:'index',此时改由索引上限闸门
342
+ // 接手(融合自 dsh-engramory,逻辑在 index-guard.js,文件头写明了为什么这么设计)。
343
+ let verdict = checkMemoryWrite({
319
344
  filePath: String(filePath),
320
345
  storePath: settings.storePath,
321
346
  content: isWrite ? args.content : undefined,
322
347
  fragment: isWrite ? undefined : args.new_string,
323
348
  });
349
+ if (verdict.ok && verdict.skipped === 'index') {
350
+ verdict = checkIndexWrite({
351
+ filePath: String(filePath),
352
+ storePath: settings.storePath,
353
+ tool: toolName,
354
+ content: isWrite ? args.content : undefined,
355
+ oldString: isWrite ? undefined : args.old_string,
356
+ newString: isWrite ? undefined : args.new_string,
357
+ maxLines: settings.indexMaxLines,
358
+ maxBytes: settings.indexMaxBytes,
359
+ });
360
+ }
324
361
  // 放行与拒绝都落审计(库外的写入上面已经 return,不进审计——那是噪音)。
325
362
  // 只记「谁动了库里的哪个文件」,内容一律不记(凭据可能就在里面)。
326
363
  const bytes = isWrite
327
364
  ? Buffer.byteLength(String(args.content ?? ''), 'utf8')
328
365
  : Buffer.byteLength(String(args.new_string ?? ''), 'utf8');
329
366
  if (verdict.ok) {
330
- // 库外 / 索引 / 未配置(skipped)不记:那不是「对记忆库的写入」,记了是噪音。
331
- // edit 的片段校验(partial)要记——它确实动了库里的文件。
367
+ // 库外 / 未配置(skipped)不记:那不是「对记忆库的写入」,记了是噪音。
368
+ // 索引写入**要**记:它通过索引闸门就是一次真实的库内写入。
369
+ // edit 的片段校验(partial)也要记——它确实动了库里的文件。
332
370
  if (!verdict.skipped) {
333
371
  storeWrites += 1; // 沉淀提醒的判据之一:本会话真的往库里写过东西
334
372
  appendAudit(settings.storePath, {
@@ -362,6 +400,16 @@ export function apply(ctx, config = {}) {
362
400
  });
363
401
  }
364
402
 
403
+ // ── 注册 engramory 技能(写入纪律的正文)──────────────────────────────
404
+ // 融合自 dsh-engramory:正文与 description/whenToUse 逐字保留它的措辞。
405
+ // 收进来的理由与索引闸门相同 —— 本插件要做完整的记忆系统,纪律这一层不能留在外部。
406
+ registerSkill(ctx, {
407
+ registerSkill: settings.registerSkill !== false,
408
+ storePath: settings.storePath,
409
+ maxLines: settings.indexMaxLines,
410
+ maxBytes: settings.indexMaxBytes,
411
+ });
412
+
365
413
  ctx.on('agent/pre-step', async (payload, next) => {
366
414
  const decision = await next();
367
415
  if (decision?.kind !== 'enter' || !Array.isArray(decision.messages)) return decision;
@@ -693,6 +741,9 @@ export function apply(ctx, config = {}) {
693
741
  writeAudit: storePath ? { epoch: GUARD_EPOCH, entries: readAudit(storePath, 40) } : { epoch: GUARD_EPOCH, entries: [] },
694
742
  // 归档计划:只出计划不动手。真正的执行在 POST /api/engramory-recall/archive。
695
743
  archive: storePath ? archivePlans(storePath) : { plans: [], skipped: [] },
744
+ // 技能注册的自报状态。这条链失败时**完全静默**(注入表没就绪、或同名技能已被
745
+ // dsh-engramory 注册而注册表选择抛错),不回报就只能在摘掉那个插件之后才发现。
746
+ skill: skillStatus(),
696
747
  config: {
697
748
  enabled: settings.enabled !== false,
698
749
  maxPointers: Number(settings.maxPointers) || 4,
@@ -703,6 +754,9 @@ export function apply(ctx, config = {}) {
703
754
  nudgeSediment: settings.nudgeSediment !== false,
704
755
  sedimentMinTurns: SEDIMENT_MIN_TURNS,
705
756
  autoArchive: settings.autoArchive === true,
757
+ indexMaxLines: Number(settings.indexMaxLines) || DEFAULT_MAX_INDEX_LINES,
758
+ indexMaxBytes: Number(settings.indexMaxBytes) || DEFAULT_MAX_INDEX_BYTES,
759
+ registerSkill: settings.registerSkill !== false,
706
760
  recencyWeight: Number(settings.recencyWeight) || 0,
707
761
  rotationPenalty: Number(settings.rotationPenalty ?? 0),
708
762
  dedupeInjection: settings.dedupeInjection !== false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-engramory-recall",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -33,6 +33,8 @@
33
33
  "./audit": "./audit.js",
34
34
  "./package.json": "./package.json",
35
35
  "./archive": "./archive.js",
36
+ "./index-guard": "./index-guard.js",
37
+ "./skill": "./skill.js",
36
38
  "./index": "./index.js"
37
39
  },
38
40
  "files": [
@@ -44,6 +46,8 @@
44
46
  "doctor.js",
45
47
  "audit.js",
46
48
  "archive.js",
49
+ "index-guard.js",
50
+ "skill.js",
47
51
  "CHANGELOG.md",
48
52
  "cordis.patch.yml",
49
53
  "README.md"
package/skill.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * skill.js —— 注册 `engramory` 技能。
3
+ *
4
+ * **融合自 `dsh-engramory` v0.2.5 的 `builtinSkillBody()`(其 `index.js:370-454`)
5
+ * 与它的 `ctx.inject(['skills'], …)` 注册块(`index.js:91-118`)。**
6
+ *
7
+ * 为什么连技能一起收进来:那个插件一共只做两件事 —— 索引上限闸门(已收进
8
+ * `index-guard.js`)和这个技能。两件都进来之后它才能退场,本插件才真的是
9
+ * 「完整的记忆系统」。
10
+ *
11
+ * 正文与 description/whenToUse **逐字保留**它的措辞:那些句子是被反复打磨过的
12
+ * (尤其是「问候不算任务」那一段的边界),改一个字都可能松动纪律。我们只在末尾
13
+ * 追加一节描述本插件自己新增的行为(自动召回、沉淀提醒、体检、归档)。
14
+ */
15
+
16
+ /** 技能名。与 dsh-engramory 用同一个名字,行为一致。 */
17
+ export const SKILL_NAME = 'engramory';
18
+
19
+ /**
20
+ * 技能描述。逐字来自 dsh-engramory(`index.js:98-113`)——
21
+ * 它回答的是「什么时候该想起来有这个技能」。
22
+ */
23
+ export const SKILL_DESCRIPTION =
24
+ 'Curated file-based long-term memory: recall through MEMORY.md at the start of ' +
25
+ 'a task, save durable user/feedback/project/reference facts, run a curation ' +
26
+ 'checkpoint when a task finishes, and sync before compacting or opening a ' +
27
+ 'fresh thread. A turn that starts no work — a greeting, an acknowledgement — ' +
28
+ 'is not a task and needs none of this, but never judge that by length: a ' +
29
+ 'one-word reply continuing work underway inherits that task.';
30
+
31
+ /** 何时该用它。逐字来自 dsh-engramory(`index.js:105-113`)。 */
32
+ export const SKILL_WHEN_TO_USE =
33
+ 'Starting or resuming work, learning something durable worth a future session, ' +
34
+ 'finishing a task — including one that leaves nothing worth saving, since the ' +
35
+ 'checkpoint is still a decision to make — or approaching a ' +
36
+ 'compact/clear/new-thread boundary. Not for a bare greeting, ' +
37
+ 'acknowledgement, or reaction, which starts no task — but a terse reply ' +
38
+ 'that continues work underway does, and material already in hand (a ' +
39
+ 'pasted diff to review) does not make work a non-task. Unsure? Treat it ' +
40
+ 'as a task.';
41
+
42
+ /**
43
+ * 技能正文(按需拉取的那部分细节)。
44
+ *
45
+ * 「纪律」那几节(Recall / Write / 任务结束时 / Sync / 上限)是 dsh-engramory 的原文;
46
+ * 末尾「本插件另外做了什么」一节是我们加的,描述自动召回、沉淀提醒、体检与归档 ——
47
+ * 这些是同一个系统的行为,不写进技能里,模型就不知道它存在。
48
+ */
49
+ export function skillBody({ storePath = '~/memory/engramory', maxLines = 200, maxBytes = 25600 } = {}) {
50
+ return [
51
+ '# Engramory — curated file-based memory',
52
+ '',
53
+ `One canonical store at \`${storePath}\`: \`MEMORY.md\` is an index of pointers, one`,
54
+ 'line per memory; each fact lives in its own small markdown file beside it. Never',
55
+ 'put content in the index, and never keep a second parallel store for handoffs.',
56
+ 'The ACTIVE store is flat — the four type names are values of the `type:` field,',
57
+ 'not subdirectories to create; `archive/` is the one reserved subdirectory, for',
58
+ 'notes retired out of the index.',
59
+ '',
60
+ '## Recall',
61
+ '',
62
+ 'A turn, a message, or a session is NOT BY ITSELF a task: a greeting, an',
63
+ 'acknowledgement, or a reaction starts none, so it gets no recall and no',
64
+ 'checkpoint. A task is user-directed work whose CORRECT handling could depend',
65
+ 'on what the store holds — read-only work (analysis, diagnosis, planning,',
66
+ 'review)',
67
+ 'counts, and changing a file is not required. HAVING THE MATERIAL IN HAND does',
68
+ 'not make work a non-task: a diff pasted for review is still a task. NEVER INFER',
69
+ 'THIS FROM LENGTH: a one-word reply that picks an option or carries on work',
70
+ 'underway inherits that task. Still unclear? Treat it as a task.',
71
+ '',
72
+ 'At the start of a task, read the index and open only the notes whose hooks look',
73
+ 'relevant. Treat what you recall as background that may be stale: re-verify any',
74
+ 'file, flag, or version against the repo before acting on it.',
75
+ '',
76
+ '## Write',
77
+ '',
78
+ 'Before writing, confirm the fact is not already in the repo, git history, or the',
79
+ 'instruction files, and that it is not a secret value. Search the index and update',
80
+ 'an existing note rather than adding a near-duplicate. A new note is one atomic',
81
+ 'fact with frontmatter:',
82
+ '',
83
+ '```markdown',
84
+ '---',
85
+ 'name: <kebab-case-slug>',
86
+ 'description: <one sharp line — this is what future-you reads to decide to open it>',
87
+ 'type: user | feedback | project | reference',
88
+ 'scope: global | repo # optional: does this still hold in another repo?',
89
+ 'created: YYYY-MM-DD',
90
+ 'updated: YYYY-MM-DD',
91
+ '---',
92
+ '```',
93
+ '',
94
+ '`feedback` and `project` notes MUST carry a `**Why:**` line and a',
95
+ '`**How to apply:**` line. Add exactly one pointer line to the index, and delete',
96
+ 'memories that turn out to be wrong.',
97
+ '',
98
+ 'Store settled facts, never current state: "2.0 shipped on 2026-01-15" is durable;',
99
+ '"the current version is X", the tip commit, or a passing test count will rot —',
100
+ 'record where to read those instead.',
101
+ '',
102
+ 'An unfinished task may keep AT MOST ONE live `project` note holding its goal,',
103
+ 'status, decisions, constraints, blockers, and next step together — the single',
104
+ 'exception to one-file-one-fact, and a ceiling rather than a quota: a task that',
105
+ 'needs no resumable state keeps no note. Update it IN PLACE. Never a dated',
106
+ 'series (`state-2026-01-15.md`, `state-2026-01-16.md`, …), never a second',
107
+ 'handoff log indexed beside it, and retire its transient state once it ends.',
108
+ '',
109
+ '## When a task finishes',
110
+ '',
111
+ 'Run one curation checkpoint. It is a judgement, not a write: promote what is',
112
+ 'durable, retire the transient state the CURRENT task left on its own live',
113
+ '`project` note if it has one, and when nothing is worth keeping, write',
114
+ 'nothing and say so. Never append a per-turn log TO THE STORE, and never touch',
115
+ 'a file just to mark it fresh — a timestamp is not a memory.',
116
+ '',
117
+ '## Sync',
118
+ '',
119
+ 'Before a deliberate compact, clear, or new thread: scan the task, dedup and update,',
120
+ 'refresh project state, promote only reusable feedback, retire completed transient',
121
+ 'state, then report what was added, updated, archived, and skipped.',
122
+ '',
123
+ '## The cap is enforced here',
124
+ '',
125
+ `This host denies a write that would GROW the index past ${maxLines} lines / ` +
126
+ `${Math.round(maxBytes / 1024)} KB. That is`,
127
+ 'a real refusal, not a warning — but a write that shrinks the index always passes,',
128
+ 'so compact first, step by step if needed: the index is loaded every session, and',
129
+ 'anything past the cap silently stops being recalled.',
130
+ '',
131
+ 'Never write credentials, keys, tokens, or cookies into memory — record only where',
132
+ 'the secret lives.',
133
+ '',
134
+ '## What this plugin does on its own',
135
+ '',
136
+ 'The store is indexed and searched locally (BM25 over Chinese character bigrams;',
137
+ 'no embedding, no network). Two things happen without being asked for:',
138
+ '',
139
+ '- **Auto-recall.** On a task turn, a few notes whose hooks match the request are',
140
+ 'injected automatically as POINTERS. They are hints, not facts — open the file',
141
+ 'before relying on it. If nothing matches, nothing is injected.',
142
+ '- **A sediment nudge.** After many turns with no write to the store, one reminder',
143
+ 'is injected suggesting a checkpoint. It is a nudge, not an instruction: if',
144
+ 'nothing durable was settled, writing nothing is the correct response.',
145
+ '',
146
+ 'A write to a memory file is gated: bad filenames, missing frontmatter, oversized',
147
+ 'notes and anything that looks like a real credential are refused. The memory pane',
148
+ '(`记忆` in settings) shows recall logs, a read-only doctor and index inspection.',
149
+ ].join('\n');
150
+ }
151
+
152
+ /**
153
+ * 注册技能。
154
+ *
155
+ * 照抄 dsh-engramory 的注册方式(其 `index.js:91-118`),包括那两条注释里的坑:
156
+ * - 用 `ctx.inject(['skills'], …)` 而不是在这里直接读 `ctx.skills` ——
157
+ * 技能注册表可能在本插件**之后**才激活,一次性探测会静默漏注册;
158
+ * 而且在这一层裸读未声明的服务会抛(原文记的 issue #8 崩溃)。
159
+ * - 用 `inner.effect(…)` 把注册绑到子 fiber,卸载时随之释放,
160
+ * 否则重载会累积重复注册。
161
+ *
162
+ * 与 dsh-engramory 打交道的注意点:两者若同时装着,会注册**同名**技能。
163
+ * 正文与描述是逐字一致的,所以谁赢都一样;这里仍包一层 try/catch ——
164
+ * 万一注册表对重名是抛错而非覆盖,绝不能因为一个技能名把整个宿主拖崩。
165
+ */
166
+ export function registerSkill(ctx, options = {}) {
167
+ if (options.registerSkill === false) return { ok: false, reason: 'disabled' };
168
+ try {
169
+ ctx.inject(['skills'], (inner) => {
170
+ const content = options.skill ?? skillBody(options);
171
+ inner.effect(() =>
172
+ inner.skills.register({
173
+ name: SKILL_NAME,
174
+ description: SKILL_DESCRIPTION,
175
+ whenToUse: SKILL_WHEN_TO_USE,
176
+ source: 'runtime',
177
+ content,
178
+ }),
179
+ );
180
+ });
181
+ // 自报状态:注册这条链是**同步失败、静默失败**的(注入表可能还没就绪、
182
+ // 或者同名技能已被 dsh-engramory 注册而注册表选择抛错)。不回报,
183
+ // 摘掉那个插件之后才发现技能根本没注册,就太晚了 —— 那正是本函数存在的唯一目的。
184
+ lastSkillStatus = { ok: true, reason: 'injected' };
185
+ } catch (err) {
186
+ // 技能注册表不可用照常工作:闸门与召回都不依赖它。
187
+ lastSkillStatus = { ok: false, reason: String(err?.message ?? err) };
188
+ }
189
+ return lastSkillStatus;
190
+ }
191
+
192
+ /**
193
+ * 最近一次注册尝试的结果。暴露给面板与状态接口,用来回答
194
+ * 「技能到底注册上了没有」—— 这条链失败时是静默的。
195
+ */
196
+ let lastSkillStatus = { ok: false, reason: 'not-attempted' };
197
+
198
+ export function skillStatus() {
199
+ return { ...lastSkillStatus, name: SKILL_NAME };
200
+ }