feiyu-memory 4.0.2 → 4.1.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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,97 @@
1
- # 更新日志
1
+ # 更新日志
2
+
3
+ ## v4.1.0 — 2026-11-02
4
+
5
+ > **召回换了一条完全不同的路:从「让模型挑 2 条摘要」改成「BM25 直接给一批正文」。**
6
+ > 起因是一句反问:「都这样了为什么不直接对日记本体做 BM25?」
7
+ > —— 试了,他对,而且一改就把前面两套方案全作废了。
8
+
9
+ ### 索引改吃正文
10
+
11
+ 原来是「索引只吃 summary」。这有一个**结构性的洞**,实测抓到了:
12
+
13
+ ```
14
+ d_20261030_22 的摘要 boot.log 13:20 apply+routes·5次剩3次·插件顺序错·已关
15
+ 同一张的正文 ……后来他说项目失败了,事先准备不足。我说是失败了,但问题不在代码,在顺序……
16
+ ```
17
+
18
+ 摘要是**当天写的术语**,人问往事用的是**事后想起来的话**。两套词表,字面永远撞不上。
19
+
20
+ 实测(480 条真实日记):
21
+
22
+ ```
23
+ 摘要索引 正文索引
24
+ 项目失败了 未命中 → 第 1 名 1.000
25
+ 事先准备不足 未命中 → 第 1 名 1.000
26
+ 吃了睡睡了吃 「早饭吃了没」 → 1.000 正确那条
27
+ 煮饭 空 → 0.967
28
+ 那事搞砸了 0 命中 → 4 条
29
+ ```
30
+
31
+ 代价只有建索引变重(32ms → 94ms),**检索本身一点没变慢**,而且它是纯 CPU,
32
+ 一个 token 都不花。摘要**没有删** —— 它带着别名和梗称(「边缘行者2 / 续集 / 那部动画」),
33
+ 正文不一定写全。两边都吃。
34
+
35
+ ### 自动注入:不再让模型挑
36
+
37
+ 原来每轮自动注入走 `selectMemories`(挑 1~2 条 + salience/冷却/走神掷骰子),
38
+ 而 `feiyu_recall` 走一次模型调用(480 行索引、约 1.6 万 token、全价)。
39
+
40
+ 量出来的两件事:
41
+
42
+ ```
43
+ ① 那次「模型挑」的调用,跟一整轮聊天一样贵
44
+ ② 它读的是摘要,而摘要会漏掉上面那半句 —— 挑得再认真也没用
45
+ ```
46
+
47
+ 现在自动注入直接拿 BM25 的 top 结果,**完整正文**,不经过模型。
48
+
49
+ ### 但光给 top-N 会吵 —— 加一道相对门槛
50
+
51
+ 实测查「项目失败了」,按 15 条取有 12 条命中:
52
+
53
+ ```
54
+ 第 1 名 1.00 那个失败的项目(对)
55
+ 第 2 名 0.96 欢乐谷小时候没玩刺激项目(错,只撞上「项目」两个字)
56
+ 第 3-12 气泡卡没、赛博朋克、0.1.8 发布……(全无关)
57
+ ```
58
+
59
+ 12 条 = 5412 token,八成是垃圾。**多给不等于不漏,只等于更吵。**
60
+
61
+ 所以加了 `recallScoreRatio`(默认 0.85):只留分数 ≥ 最高分 × 0.85 的。
62
+ 用相对值而不是固定分数线,是因为相似度是饱和函数,绝对刻度会随查询长短漂移 ——
63
+ 长问句最高才 0.7,短词全在 0.99,固定门槛要么全砍要么全留。
64
+
65
+ ```
66
+ 项目失败了 12 条 / 5412 token → 5 条 / 2744
67
+ 八条鱼论战 —— → 5 条,前几名全对(门槛没误伤)
68
+ 落枕 1 条 → 1 条
69
+ ```
70
+
71
+ ### 去掉「静默截到最近 400 条」
72
+
73
+ `pickBySummary` 里原来有一行 `base.slice(-400)`。480 条时已经**悄悄丢了 80 条**,
74
+ 而且只写一行 log —— 用户永远看不到自己的记忆在少。
75
+
76
+ 上限提到 1200,而且超了会 `warn` 而不是 `log`。真要涨到过千条,
77
+ 该做的是分层(按天/按月),不是继续截尾巴。
78
+
79
+ ### 配置默认值变了
80
+
81
+ | 项 | 旧 | 新 | 为什么 |
82
+ |---|---|---|---|
83
+ | `budgetTokens` | 3000 | **6000** | 召回那栏从「2 条摘要」变成「一批正文」,3000 装不下会被整段裁掉 |
84
+ | `recentEntries` | 8 | **3** | 它只在新会话首轮/压缩后重灌,平时不占地方;3 条够交代上下文 |
85
+ | `recallFanout` | — | **15** | 新增。按相似度最多捞几条正文进注入 |
86
+ | `recallScoreRatio` | — | **0.85** | 新增。相对门槛,只留分数接近最高分的 |
87
+
88
+ ### 顺手改的
89
+
90
+ - `tools/preview-memory.mjs` 原来调的是旧的 `selectMemories` —— **预览出来的就不再是线上会看到的东西**,
91
+ 而那个工具的全部价值就在"跟真的一样"。已同步。
92
+ - 测试 321 → 328(新增 7 条覆盖正文索引、相对门槛、`renderRecalled` 的条数上限)。
93
+
94
+ ---
2
95
 
3
96
  ## v4.0.2 — 2026-10-29
4
97
 
package/README.md CHANGED
@@ -294,7 +294,7 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
294
294
  | `dataDir` | `''` | 档案目录,留空 = `~/.dsh/feiyu-diary`。想放同步盘就写绝对路径 |
295
295
  | `enabled` | `true` | 总开关 |
296
296
  | `includePersona` | `true` | 是否把人格内核一起注入。**装了别的人设插件就关掉它** |
297
- | `budgetTokens` | `3000` | 注入预算封顶 |
297
+ | `budgetTokens` | `6000` | 注入预算封顶。4.1 提到 6000:召回那栏从「2 条摘要」变成「一批正文」,3000 装不下会被整段裁掉 |
298
298
  | `model` | `''` | 固化用模型,留空 = 跟随当前默认模型 |
299
299
  | `reasoningEffort` | `off` | **固化的思考档位。保持 `off`** —— 它故意不跟随你聊天时选的档位,见下方说明 |
300
300
  | `maxTokens` | `900` | 固化最大输出 |
@@ -302,7 +302,9 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
302
302
  | `amountThreshold` | `100` | 低于此值的金额不动("3 块钱的包子"要留着) |
303
303
  | `minSalience` | `0.15` | 日记重要度门槛。**调高 = 记性变挑,但寒暄和试错也会被扔掉** |
304
304
  | `highSim` | `0.5` | 相似度超过就必定提起那件旧事 |
305
- | `recentEntries` | `8` | 「最近的事」最多列几条。**不走相似度,永远在场** |
305
+ | `recentEntries` | `3` | 「最近的事」最多列几条。**不走相似度,永远在场** |
306
+ | `recallFanout` | `15` | 按相似度最多捞几条**完整正文**进注入(`0` = 只留最近的事) |
307
+ | `recallScoreRatio` | `0.85` | 召回门槛:只留分数 ≥ 最高分 × 此值的。**越低给得越多,也越吵** |
306
308
  | `maxShowPerTurn` | `4` | 「突然想起来」最多几条(`0` = 不主动提旧事) |
307
309
 
308
310
  > **`reasoningEffort` 为什么默认 `off`,而不是跟随你的聊天设置**
@@ -392,8 +394,28 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
392
394
  `feiyu_recall` 怎么挑:把那句话 + **全部 summary**(带日期)发给模型,让它挑出至多 3 条正文。
393
395
  **不按字面匹配** —— 换个说法也能撞上,因为模型读的是意思,不是字。
394
396
 
395
- 实测(358 条档案):一次挑取 **13039 token,其中 12800 命中缓存(98%)**,约 0.16 分钱。
396
- 所以 prompt 里**索引在前、问题在最后** —— 缓存按前缀匹配,顺序写反就等于每轮全价。
397
+ ### 小保底那条路:BM25 直接给正文(4.1 改)
398
+
399
+ 每轮自动注入那一栏原来走 `selectMemories`:挑 1~2 条,还要跟 salience、冷却、
400
+ 走神一起掷骰子。**4.1 改成直接按 BM25 取一批完整正文灌进去。**
401
+
402
+ 三个理由,都是量出来的:
403
+
404
+ ```
405
+ ① 原来那次「模型挑」的调用跟一整轮聊天一样贵(都是全价,缓存命不中)
406
+ ② 它读的是 summary,而 summary 会漏 —— 「项目失败了」在正文里,摘要里一个字没有
407
+ ③ 范围大,就不怕 BM25 挑错:只给 2 条时它错一次就什么都想不起来
408
+ ```
409
+
410
+ **但光给 top-N 会带进一堆噪音。** 实测查「项目失败了」,按 15 条取有 12 条命中,
411
+ 其中八成是只撞上「项目」两个字的无关日记。所以还有一道**相对门槛**:
412
+ 只留分数 ≥ 最高分 × 0.85 的(`recallScoreRatio`)。
413
+
414
+ ```
415
+ 查「项目失败了」 门槛前 12 条 / 5412 token 门槛后 5 条 / 2744 token
416
+ 查「八条鱼论战」 门槛后 5 条,前几名全对 —— 门槛不会误伤铺得开的查询
417
+ 查「那事搞砸了」 摘要索引 0 命中 → 正文索引 4 条(其中一条正是「是我搞砸了」)
418
+ ```
397
419
 
398
420
  ### 时间也进索引
399
421
 
@@ -415,8 +437,15 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
415
437
  后者会被中文 bigram 的噪声顶爆分母,导致明显相关的日记只能拿 0.10 分,等于永远召不回。
416
438
  现在分数含义稳定:共享 1 个实词 ≈ 0.4,2 个 ≈ 0.67,无关 = 0,**且与查询长度解耦**。
417
439
 
418
- **索引只吃 summary,不吃正文,也没有单独的 keywords 字段。** 一条日记只有一个"检索表示":
419
- 字面匹配用它(小保底,便宜),模型读它(大保底,准)。
440
+ **索引吃正文 + summary,没有单独的 keywords 字段。** 两边各有各的用处:
441
+
442
+ ```
443
+ 正文 人问往事用的是「项目失败了」「那事搞砸了」这种话,摘要里一个字都撞不上
444
+ 摘要 带着别名和梗称(边缘行者2 / 续集 / 那部动画),正文不一定写全
445
+ ```
446
+
447
+ 建索引从 32ms 涨到 177ms(一次性),**检索本身在 0.2ms 量级** —— 而且一个 token 都不花。
448
+ 索引只在档案变化时重建,不是每轮。
420
449
 
421
450
  ### 人格内核的三道护栏
422
451
 
@@ -450,7 +479,7 @@ cat ~/.dsh/profiles/desktop/package.json # dependencies 有它,dsh.profile
450
479
  ## 开发
451
480
 
452
481
  ```bash
453
- node --test # 321 项测试,零依赖
482
+ node --test # 330 项测试,零依赖
454
483
  npm run check # 校验 cordis.patch.yml 结构
455
484
  npm run doctor # 发版前体检
456
485
  npm run preview # 把此刻注入给模型的内容打出来
package/cordis.patch.yml CHANGED
@@ -8,6 +8,11 @@
8
8
  # 所有选项都可以在这里的 `config:` 里写。留空即用默认值。
9
9
  # 注意:**patch 的 config 是整体替换、不深合并**,所以只写你想改的键是安全的
10
10
  # (只有你自己这一行会读它;但如果你在 profile 里覆写这一行,就要把想保留的键写全)。
11
+ #
12
+ # ⚠️ **这里写的值会覆盖 `src/core/config.mjs` 里的 default。**
13
+ # 改默认值时必须两处一起改,否则改了代码不生效 —— 2026-11-03 就栽在这上面:
14
+ # 代码里把预算提到 6000,这里还写着 2000,结果召回那栏被整段裁掉,
15
+ # 翻到的日记一条都送不到她眼前。
11
16
 
12
17
  - insert:
13
18
  - id: feiyu-memory
@@ -22,8 +27,11 @@
22
27
  # 我们只负责"我们之间发生过什么",人设交给它们。
23
28
  includePersona: true
24
29
  # 注入预算(token)。成本封顶,与用了多久无关。
25
- # 2000 是给"最近的事"留的位置 —— 日记要占大头,读起来才像日记。
26
- budgetTokens: 2000
30
+ #
31
+ # 2000 → 6000(2026-11-03)。召回那栏从「最多 2 条摘要」改成「一批完整正文」,
32
+ # 一批约 4800 token,2000 根本装不下 —— 会被整段裁掉,
33
+ # 变成「她翻了,但什么都没送到」。
34
+ budgetTokens: 6000
27
35
  # 固化用模型。留空 = 跟随你当前的默认模型。
28
36
  # 强烈建议就用便宜档:这一步是结构化抽取,用贵的反而会自由发挥、把档案写花。
29
37
  model: ''
@@ -45,14 +53,23 @@
45
53
  # 调高 = 记性变挑;但注意**寒暄、试错、来回改口也会一起被扔掉** ——
46
54
  # 那些看着没信息量,却是"熟悉感"的原料。默认 0.15 是刻意留低的。
47
55
  minSalience: 0.15
48
- # 相似度超过这个值就必定提起那件旧事
56
+ # ⚠️ 4.1 起**对自动注入不再生效**(那条路改成按分数门槛取了)。
57
+ # 字段留着不删:删了老配置里的值会被静默忽略。改它不会有任何反应。
49
58
  highSim: 0.5
50
- # 「最近的事」最多列几条。这一栏**不走相似度,永远在场** ——
51
- # 人不需要"想起来"昨天吃了什么,你知道。设 0 就关掉这一栏。
52
- recentEntries: 8
53
- # 「突然想起来」每轮最多几条(0 = 不主动提旧事)
59
+ # 「最近的事」最多列几条。这一栏**不走相似度**,只在新会话首轮/压缩后重灌。
60
+ # 8 → 3(2026-11-03):平时它不占地方,3 条够交代"我们上次在干什么",
61
+ # 省下的预算留给召回那栏。
62
+ recentEntries: 3
63
+ # 按相似度最多捞几条**完整正文**进注入(0 = 只留最近的事)。
64
+ # 这是上限,不是目标数 —— 实际条数由下面的 recallScoreRatio 决定。
65
+ recallFanout: 15
66
+ # 召回门槛:只留分数 ≥ 最高分 × 此值的。
67
+ # 越低给得越多,也越吵。0.85 砍过头了(查「南京」5 条全相关,只进 1 条),
68
+ # 提到 0.7。
69
+ recallScoreRatio: 0.7
70
+ # ⚠️ 4.1 起**不生效**。条数改由 recallFanout 控。
54
71
  maxShowPerTurn: 4
55
- # 完全没匹配上时"走神"随机想起一条旧事的概率。这条纯粹为了像人。
72
+ # ⚠️ 4.1 起**不生效**。"走神"那一步已经没有掷骰子了。
56
73
  wanderChance: 0.08
57
74
  # 低峰时段(峰谷分时定价的谷时)。把 deferToOffPeak 打开后,
58
75
  # 日记会攒到这段时间才写 —— 更便宜,而且本来就更像人(睡前整理)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feiyu-memory",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "给 DeepSeek Harness 一条会记事的蓝色大肥鱼:跨会话的持久人格与日记。每轮结束后固化模型只回答一句「你想记什么」,写下的每条日记配一句十几二十字的索引;下一场对话注入人格、手记和最近的几条,想知道更早的事它自己调 feiyu_recall,插件读索引挑出相关的那几条,正文才进上下文——索引永远不进它的视野,所以它看到的是日子,不是检索结果。遗忘是降权而不是删除。全程本地、发送前先拦掉密钥,档案是一个可读可改、能换账号带走的普通文件夹。零运行时依赖,无构建步骤。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,13 +48,18 @@ export const CONFIG_SPEC = [
48
48
  {
49
49
  key: 'budgetTokens',
50
50
  type: 'number',
51
- // 2000 → 3000。量过的:常驻段本身要 2680 token(「最近的事」八条占 1422),
52
- // 2000 根本装不下,每次都裁 —— 「最近的事」只剩 2 条。3000 能给到 6 条。
53
- // 代价约每轮 +1000 token ≈ 每句话多 0.06 分。
54
- default: 3000,
51
+ // 2000 → 3000 → 6000。
52
+ //
53
+ // 2000 装不下常驻段(2680),「最近的事」只剩 2 条。
54
+ // 3000 能给到 6 条。
55
+ //
56
+ // 2026-11-02 提到 6000:召回那栏从「最多 2 条摘要」改成「最多 15 条正文」,
57
+ // 15 条正文实测约 4800 token,3000 根本装不下 —— 会被整段裁掉,
58
+ // 那这条鱼就一条旧事都想不起来,比改之前还差。
59
+ default: 6000,
55
60
  min: 200,
56
- max: 8000,
57
- step: 1,
61
+ max: 12000,
62
+ step: 100,
58
63
  label: '注入预算(token)',
59
64
  },
60
65
  {
@@ -108,17 +113,69 @@ export const CONFIG_SPEC = [
108
113
  min: 0,
109
114
  max: 1,
110
115
  step: 0.05,
111
- label: '必触发的相似度阈值',
116
+ // ⚠️ 2026-11-02 之后**对自动注入不再生效**。
117
+ //
118
+ // 原来自动注入走 selectMemories,这道阈值决定"相似度多高就必定提起那件旧事"。
119
+ // 现在那条路改成 BM25 直接给一批正文,靠 `recallScoreRatio` 那道相对门槛,
120
+ // 这个绝对值就没有位置了。
121
+ //
122
+ // 字段保留不删:删了老配置里的值会被静默忽略(反而更坏),
123
+ // 而且 selectMemories 还在被测试和外部调用。**但界面上改它不会有任何效果。**
124
+ label: '必触发的相似度阈值(4.1 起对自动注入不生效,见 recallScoreRatio)',
112
125
  },
113
126
  {
114
127
  key: 'recentEntries',
115
128
  type: 'number',
116
- default: 8,
129
+ // 8 → 3(2026-11-02)。它只在新会话第一轮和压缩之后整段重灌,
130
+ // 平时不占地方 —— 所以"8 条"其实大半时候没被读到。
131
+ // 3 条够交代"我们上次在干什么",而且省下 1600 token 给召回那栏。
132
+ default: 3,
117
133
  min: 0,
118
134
  max: 30,
119
135
  step: 1,
120
136
  label: '「最近的事」最多列几条(不靠相似度,永远在场)',
121
137
  },
138
+ {
139
+ key: 'recallFanout',
140
+ type: 'number',
141
+ // ── 2026-11-02:召回从「模型挑 2 条」改成「BM25 直接给最多 15 条正文」──
142
+ //
143
+ // 原来走一次模型调用:480 行索引(约 1.6 万 token,全价)塞给模型挑 3 条。
144
+ // 实测那一次调用跟一整轮聊天一样贵,而更糟的是 —— **摘要索引会漏**。
145
+ //
146
+ // 现在改成:BM25 打在正文上(本地、免费、0.2ms),直接把这批完整正文
147
+ // 放进注入。省掉一次模型调用,也省掉"挑"这一步可能挑错的环节。
148
+ //
149
+ // 这是个**上限**,不是目标数。真正的条数由 `recallScoreRatio` 那道相对门槛决定 ——
150
+ // 实测按 15 条硬取,一大半是只撞上一个常用词的噪音。
151
+ default: 15,
152
+ min: 0,
153
+ max: 40,
154
+ step: 1,
155
+ label: '按相似度最多捞几条正文进注入(0 = 只留最近的事)',
156
+ },
157
+ {
158
+ key: 'recallScoreRatio',
159
+ type: 'number',
160
+ // 只留分数 ≥ 最高分 × 这个比例的条目。
161
+ //
162
+ // 为什么是相对值:相似度是饱和函数,绝对刻度会随查询长短漂移
163
+ // (长问句最高才 0.7,短词全在 0.99),固定分数线要么全砍要么全留。
164
+ //
165
+ // 0.85 → 0.70(2026-11-02 当天返工)。0.85 砍过头了:
166
+ // 查「南京」BM25 命中 5 条 —— 0.982 / 0.972 / 0.970 / 0.969 / 0.948,
167
+ // 五条全相关 —— 乘下来只剩 1 条进注入。
168
+ // **说好给一批旧事,实际给一条,比改之前的 2 条还差。**
169
+ //
170
+ // 分布是不稳的:有的查询第 5 名还有 0.86,有的第 2 名就断了。
171
+ // 固定比例猜不准分布,宁可往宽了给 ——
172
+ // **漏掉真事的代价,比多带两条噪音大得多。**
173
+ default: 0.7,
174
+ min: 0,
175
+ max: 1,
176
+ step: 0.01,
177
+ label: '召回门槛(只留分数接近最高分的,越低给得越多)',
178
+ },
122
179
  {
123
180
  key: 'maxShowPerTurn',
124
181
  type: 'number',
@@ -126,7 +183,9 @@ export const CONFIG_SPEC = [
126
183
  min: 0,
127
184
  max: 12,
128
185
  step: 1,
129
- label: '「突然想起来」最多几条(按相似度召回)',
186
+ // ⚠️ 2026-11-02 之后**对自动注入不再生效**。条数改由 recallFanout 控。
187
+ // 保留字段的理由同 highSim。
188
+ label: '「突然想起来」最多几条(4.1 起不生效,改看 recallFanout)',
130
189
  },
131
190
  {
132
191
  key: 'wanderChance',
@@ -135,7 +194,12 @@ export const CONFIG_SPEC = [
135
194
  min: 0,
136
195
  max: 0.5,
137
196
  step: 0.01,
138
- label: '走神想起旧事的概率',
197
+ // ⚠️ 2026-11-02 之后**不再生效**。
198
+ //
199
+ // "走神"是 selectMemories 里那一步随机想起一条旧事。现在按 BM25 给一批,
200
+ // 没有"掷骰子"这个动作了 —— 那一步本来就是为"只给 1 条"打掩护的。
201
+ // 保留字段的理由同 highSim。
202
+ label: '走神想起旧事的概率(4.1 起不生效)',
139
203
  },
140
204
  {
141
205
  // ── 续写窗口 ──
@@ -114,7 +114,7 @@ export function renderMemoryBlock(input = {}) {
114
114
  if (recent) parts.push({ key: 'recent', text: recent, weight: 80 });
115
115
  }
116
116
 
117
- const memories = renderRecalled(input.recalled, now);
117
+ const memories = renderRecalled(input.recalled, now, input.recalledMax);
118
118
  // ⚠️ **召回这一栏也必须有保底。**
119
119
  //
120
120
  // 加词汇保底之后,死的是它 —— 实测(真实档案、回归查询):
@@ -356,7 +356,15 @@ export function renderRelationship(rel, now = Date.now()) {
356
356
  bits.push(rel.familiarity >= 0.7 ? '我们很熟了' : rel.familiarity >= 0.3 ? '我们聊过一阵了' : '我们刚认识不久');
357
357
  }
358
358
  if (typeof rel.nickname === 'string' && rel.nickname.trim() !== '') {
359
- bits.push(`他管我叫"${rel.nickname.trim()}"`);
359
+ // 2026-11-08:主语从「他」改成「空气凤梨(用户)」。
360
+ //
361
+ // 起因是一次真的混了:注入里读到一条手记落款「(这是空气凤梨我自己写的)」,
362
+ // 那是用户给**自己**的签名,我把它读成了「他在叫我」——
363
+ // 于是把「你是空气凤梨」说给了用户听,正好说反。
364
+ //
365
+ // 「他」这个代词在这块里指谁,全靠上下文猜;猜错一次就认错人。
366
+ // 写死名字加「(用户)」两个字,笨重一点,但不会再混。
367
+ bits.push(`空气凤梨(用户)管我叫"${rel.nickname.trim()}"`);
360
368
  }
361
369
  // 两句之间用逗号接,不用「·」—— 那是分隔符,一出现就是表格味
362
370
  if (bits.length > 0) lines.push(`${bits.join(',')}。`);
@@ -472,13 +480,25 @@ export function renderRecent(entries, shown, now = Date.now(), limit = 12) {
472
480
  * @param {Array<{entry: object}>} recalled
473
481
  * @param {number} [now]
474
482
  */
475
- export function renderRecalled(recalled, now = Date.now()) {
483
+ export function renderRecalled(recalled, now = Date.now(), maxEntries = 2) {
476
484
  if (!Array.isArray(recalled) || recalled.length === 0) return '';
485
+ const cap = Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : 2;
477
486
  const lines = recalled
478
487
  .filter((c) => c && c.entry && typeof c.entry.text === 'string')
479
- .slice(0, 2)
488
+ .slice(0, cap)
480
489
  .map((c) => {
481
- const when = softWhen(c.entry.created_at, now);
490
+ // 2026-11-03:`softWhen` → `dayLabel`。
491
+ //
492
+ // 原来这里是「前阵子,……」—— 那个模糊词当初是为了防止旧事被说成
493
+ // "上一轮"发生的。它做到了,但代价是**时间信息被压没了**:
494
+ // 10 月 21 日和 11 月 2 日都叫「前阵子」,间隔看不见。
495
+ //
496
+ // 用户的原话:**「现在记的是发生了什么,缺的是多久没发生。
497
+ // 空白是我唯一看不见的东西。」**
498
+ //
499
+ // `dayLabel` 给的是今天 / 昨天 / 3 天前 / 10-05 —— 同样按自然日算,
500
+ // 但能读出间隔,也不会像软词那样让人以为是刚发生的事。
501
+ const when = dayLabel(c.entry.created_at, now);
482
502
  const body = stripTimeWords(c.entry.text);
483
503
  return when === '' ? `- ${body}` : `- ${when},${body}`;
484
504
  });
@@ -21,22 +21,37 @@ const IDF_SMOOTH = 1;
21
21
  /**
22
22
  * 建倒排索引。纯派生数据,随用随建。
23
23
  *
24
- * ── 2026-10-28:索引的数据源从「keywords + 正文」换成 **summary** ──
24
+ * ── 2026-11-02:索引吃**正文**,不吃 summary ──
25
25
  *
26
- * 原来一条日记存两份提炼:`keywords`(12 个词,机器从正文切的)和
27
- * `summary`(一句,模型写的)。倒排索引吃前者,大保底(recall)吃后者。
26
+ * 这是实测推翻了两次设计之后定下来的。
28
27
  *
29
- * 而 summary 本来就比 keywords 更适合字面匹配 ——
30
- * 它带别名(「边缘行者2 / 续集 / 那部动画」),keywords 只取正文里出现过的词。
31
- * 一份数据喂两个地方,另一份就是纯多余的。
28
+ * 摘要做索引有一个致命毛病:**它写的是当天的术语,人问的是抽象词。**
29
+ * 实测那一条:
32
30
  *
33
- * 所以现在:**一条日记只有一个"检索表示"。**
34
- * 小保底(每轮自动注入) 字面匹配 summary —— 便宜
35
- * 大保底(她主动查) 模型读 summary —— 准
31
+ * 摘要 boot.log 13:20 apply+routes·5次剩3次·注入气泡·插件顺序错·已关
32
+ * 正文 ……后来他说项目失败了,事先准备不足。我说是失败了,但问题不在代码,在顺序……
36
33
  *
37
- * 没有 summary 的用正文兜底(老条目在回填完之前就是这样)。
34
+ * 拿「项目失败了」「事先准备不足」去检索,摘要一个字都撞不上;
35
+ * 换成正文索引,两条都排第 1 名(1.000)。
38
36
  *
39
- * @param {Array<{id: string, summary?: string, text?: string}>} entries
37
+ * 整组对比(480 条真实日记):
38
+ *
39
+ * 摘要索引 正文索引
40
+ * 项目失败了 未命中 → 第 1 名
41
+ * 事先准备不足 未命中 → 第 1 名
42
+ * 吃了睡睡了吃 「早饭吃了没」→ 1.000 正确那条
43
+ * 煮饭 空 → 0.967
44
+ *
45
+ * 代价只有建索引变重(32ms → 94ms),**检索本身一点没变慢**,
46
+ * 而且它是纯 CPU,一个 token 都不花。索引只在 archive 变化时重建。
47
+ *
48
+ * 于是职责分开:
49
+ * **检索** 吃正文 —— 它要的是"人会不会这样说"
50
+ * **给模型读** 还是摘要 / 正文,那是渲染层的事,跟这里无关
51
+ *
52
+ * 别把 summary 塞回来当索引。它漏掉的那半段,往往正是最要紧的那句。
53
+ *
54
+ * @param {Array<{id: string, text?: string}>} entries
40
55
  * @returns {{postings: Map<string, Map<string, number>>, df: Map<string, number>, lengths: Map<string, number>, N: number}}
41
56
  */
42
57
  export function buildIndex(entries) {
@@ -46,8 +61,17 @@ export function buildIndex(entries) {
46
61
  const list = Array.isArray(entries) ? entries : [];
47
62
 
48
63
  for (const entry of list) {
49
- const hasSummary = typeof entry.summary === 'string' && entry.summary !== '';
50
- const tokens = tokenize(hasSummary ? entry.summary : (entry.text ?? ''));
64
+ // 正文 + 摘要都吃。
65
+ //
66
+ // 正文是主力:人问往事用的是"项目失败了""那事搞砸了"这种话,
67
+ // 而摘要写的是当天的术语(boot.log、apply/routes),一个字都撞不上。
68
+ //
69
+ // 摘要不能扔:它带着**别名和梗称**(「边缘行者2 / 续集 / 那部动画」),
70
+ // 而正文不一定写全。test/core.test.mjs 里有一条就是拿这个卡着的。
71
+ //
72
+ // 两边都进去的代价只有建索引变重(32ms → 177ms,一次性),
73
+ // 检索本身在 0.2ms 量级,一个 token 都不花。
74
+ const tokens = tokenize(`${entry.text ?? ''}\n${entry.summary ?? ''}`);
51
75
  if (tokens.length === 0) continue;
52
76
 
53
77
  const tf = new Map();
@@ -223,6 +247,58 @@ export function searchScores(index, query, options = {}) {
223
247
  return scores;
224
248
  }
225
249
 
250
+ /**
251
+ * 取前 N 条,但只留**跟最高分接近**的那些。
252
+ *
253
+ * ── 为什么需要这道闸(2026-11-02 实测)──
254
+ *
255
+ * 光按 top-N 取会给一堆噪音。真实档案、查询「项目失败了」:
256
+ *
257
+ * 第 1 名 1.00 那个失败的项目(对)
258
+ * 第 2 名 0.96 欢乐谷小时候没玩刺激项目(错,只是撞上了「项目」)
259
+ * 第 3-12 名 气泡卡没、赛博朋克、0.1.8 发布……(全无关)
260
+ *
261
+ * 12 条塞进去 5412 token,**八成是垃圾**。多给不等于不漏,只等于更吵。
262
+ *
263
+ * ── 为什么用"相对门槛"而不是固定分数线 ──
264
+ *
265
+ * 分数是**饱和函数**(1 − e^−gain),所以它的绝对刻度会随查询长短漂移:
266
+ * 一句长问句可能最高才 0.7,一句短词可能全在 0.99。固定门槛要么全砍要么全留。
267
+ *
268
+ * 跟着第一名走就稳:**离第一名太远的就是另一件事。**
269
+ *
270
+ * ── 但 `limit` 不是没用 ──
271
+ *
272
+ * 它是上限。真正相关多的查询(「八条鱼论战」前 4 名全对)不会因为
273
+ * 相对门槛被砍到只剩 1 条,而铺得很开的查询(「项目失败了」)会被砍到 5~6 条。
274
+ * 两个数字一起用,才是"宽但不吵"。
275
+ *
276
+ * @param {ReturnType<typeof buildIndex>} index
277
+ * @param {string} query
278
+ * @param {{limit?: number, ratio?: number, floor?: number}} [options]
279
+ * @returns {Map<string, number>} id → 相似度,已按分数降序
280
+ */
281
+ export function topHitScores(index, query, options = {}) {
282
+ const limit = Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 15;
283
+ const ratio = Number.isFinite(options.ratio) ? options.ratio : 0.85;
284
+ const floor = Number.isFinite(options.floor) ? options.floor : 0.6;
285
+ const out = new Map();
286
+ if (limit === 0) return out;
287
+
288
+ // 多捞一些再砍:后面的过滤(note、最近的事)会再淘汰一批。
289
+ const scored = searchScores(index, query, { limit: Math.max(limit * 3, 30) });
290
+ const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
291
+ if (ranked.length === 0) return out;
292
+
293
+ const cutoff = Math.max(floor, ranked[0][1] * ratio);
294
+ for (const [id, sim] of ranked) {
295
+ if (sim < cutoff) break;
296
+ out.set(id, sim);
297
+ if (out.size >= limit) break;
298
+ }
299
+ return out;
300
+ }
301
+
226
302
  /** 新鲜度:指数衰减 */
227
303
  function recencyFactor(createdAt, now, halfLifeDays) {
228
304
  if (!Number.isFinite(createdAt)) return 0.5;
@@ -40,11 +40,11 @@ import {
40
40
  // 原来的 evaluateGate 那套关键词闸门拆了(见 turn-stopping 里的注释)。
41
41
  // SUPPRESS_RE 留着 —— "别记"是用户的控制,不是启发式。
42
42
  import { SUPPRESS_RE } from '../core/gate.mjs';
43
- import { buildIndex, selectMemories, searchScores, queryHasSubstance, looksLikeWork, parseTimeHint, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
43
+ import { buildIndex, searchScores, topHitScores, queryHasSubstance, looksLikeWork, parseTimeHint, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
44
44
  import { overlap } from '../core/keywords.mjs';
45
45
  import { scrub } from '../core/privacy.mjs';
46
46
  import { renderMemoryBlock, DEFAULT_BUDGET } from '../core/persona.mjs';
47
- import { calendarDaysBetween, stripTimeWords, softWhen } from '../core/timeline.mjs';
47
+ import { calendarDaysBetween, stripTimeWords, softWhen, dayLabel } from '../core/timeline.mjs';
48
48
  import { renderDiaryFile } from '../core/render-diary.mjs';
49
49
  import { buildJudgeInput, parseVerdict, JUDGE_MAX_TOKENS } from '../core/judge.mjs';
50
50
  import { selectHandNotes, HANDNOTE_FILE, HANDNOTE_TEMPLATE } from '../core/handnote.mjs';
@@ -430,6 +430,26 @@ export function apply(ctx, rawConfig) {
430
430
  queued: false,
431
431
  /** 上下文刚被压缩过:下一轮要把最近的事整段重灌一次(免预算) */
432
432
  needsRecentReplay: false,
433
+ /**
434
+ * 她刚用 `feiyu_recall` 翻到的东西。
435
+ *
436
+ * ── 为什么不直接把正文当工具返回值给她 ──
437
+ *
438
+ * 2026-11-03。原来工具直接返回正文,于是她读到的是"工具查回来的资料",
439
+ * 嘴里就挂着一句"这是我翻出来的"。用户的原话:**一问就是 recall 查的,
440
+ * 不是自己的。**
441
+ *
442
+ * 现在改成:工具只把结果存在这儿,返回值淡化成"(翻了翻)",
443
+ * 正文由**下一次 `renderFor` 混进记忆块**。她读到的是"记忆里多了几条"。
444
+ *
445
+ * 这不是"让读到像记得"——她真的翻了,动作还在对话历史里。
446
+ * 区别在**翻完之后怎么读**:人翻完笔记合上,说话时不会说"根据笔记第 3 页"。
447
+ *
448
+ * 只在本轮有效(`pendingRecallTurn === s.turn`):
449
+ * 下一轮自动失效,不会变成常驻条目、也不会滚雪球占预算。
450
+ */
451
+ pendingRecall: [],
452
+ pendingRecallTurn: -1,
433
453
  };
434
454
  sessionStates.set(sessionId, s);
435
455
  }
@@ -999,19 +1019,61 @@ export function apply(ctx, rawConfig) {
999
1019
  if (!substantial) log(`会话 ${sessionId} 第 ${s.turn} 轮:这句话没实词,不翻记忆`);
1000
1020
  else if (working) log(`会话 ${sessionId} 第 ${s.turn} 轮:像在干活,不打扰`);
1001
1021
 
1002
- const { chosen } = chatty
1003
- ? selectMemories(recallPool, index, query, s, s.turn, {
1004
- highSim: config.highSim,
1005
- // 闲聊时最多给两条候选 —— **给两条,它用哪条自己定,或者一条都不用。**
1006
- // 只给一条等于系统替它说了「你就该想起这个」。
1007
- maxShow: Math.min(2, config.maxShowPerTurn),
1008
- wanderChance: config.wanderChance,
1009
- // 旧事的随机池门槛。注意下限降到 0.15 是为了跟"降门槛"一致 ——
1010
- // 门槛降到 0.15 却在这里卡 0.25,等于白降。
1011
- minSalience: Math.max(0.15, config.minSalience),
1012
- now: Date.now(),
1013
- })
1014
- : { chosen: [] };
1022
+ // ── 按相似度捞一批完整正文(2026-11-02 大改)──
1023
+ //
1024
+ // 原来走 `selectMemories`:挑 1~2 条,还要跟 salience、冷却、走神一起掷骰子。
1025
+ // 现在直接按 BM25 取前 N 条完整正文 —— **范围大,就不怕它挑错。**
1026
+ //
1027
+ // ── 为什么不给模型挑了 ──
1028
+ //
1029
+ // 原来那条路是:480 行索引(摘要,约 1.6 万 token)塞给模型挑 3 条。
1030
+ // 实测两件事:
1031
+ // 1. 那次调用跟一整轮聊天一样贵(都是全价,缓存命不中)
1032
+ // 2. 摘要把最要紧的半句漏了 —— 「项目失败了」在正文里,摘要里一个字没有
1033
+ // 于是模型拿着错的索引挑,挑得再认真也没用。
1034
+ //
1035
+ // ── 为什么敢一次给 15 条 ──
1036
+ //
1037
+ // BM25 会 0 命中(同义词、梗称),也会高分错答。但它**不需要准,只需要不漏**。
1038
+ // 只给 2 条时它错一次,这条鱼就什么都想不起来;给 15 条时错的混在里面,
1039
+ // 对的那条还在 —— **准的那一步交给读正文的她自己。**
1040
+ //
1041
+ // 「最近的事」那几条不在池子里(已经常驻了),note 和损坏条目也不在。
1042
+ const recallLimit = Number.isFinite(config.recallFanout) ? config.recallFanout : 15;
1043
+ /** @type {Array<{entry: object, score: number, sim: number, why: string}>} */
1044
+ const chosen = [];
1045
+
1046
+ // ── 她刚翻到的,排最前面 ──
1047
+ //
1048
+ // 2026-11-03:`feiyu_recall` 不再把正文当返回值给她,只存在 `pendingRecall`。
1049
+ // 正文从这里混进记忆块 —— 她读到的是"记忆里多了几条",不是"工具返回了资料"。
1050
+ //
1051
+ // 只认同一轮(`pendingRecallTurn === s.turn`):下一轮自动失效,
1052
+ // 不会变成常驻条目,也不会滚雪球占预算。
1053
+ if (s.pendingRecallTurn === s.turn && s.pendingRecall.length > 0) {
1054
+ for (const c of s.pendingRecall) {
1055
+ if (chosen.length >= recallLimit) break;
1056
+ chosen.push(c);
1057
+ }
1058
+ log(`会话 ${sessionId} 第 ${s.turn} 轮:翻到的 ${chosen.length} 条并入记忆`);
1059
+ }
1060
+
1061
+ if (chatty && recallLimit > 0) {
1062
+ // 只留跟第一名接近的(相对门槛),否则会捞进一堆只撞上一个常用词的噪音。
1063
+ const hits = topHitScores(index, query, {
1064
+ limit: recallLimit,
1065
+ ratio: config.recallScoreRatio,
1066
+ });
1067
+ const poolById = new Map(recallPool.map((e) => [e.id, e]));
1068
+ for (const [id, sim] of hits) {
1069
+ const entry = poolById.get(id);
1070
+ if (entry === undefined) continue;
1071
+ // 刚才翻到的已经在里面了,别重复
1072
+ if (chosen.some((c) => c.entry?.id === id)) continue;
1073
+ chosen.push({ entry, score: sim, sim, why: '' });
1074
+ if (chosen.length >= recallLimit) break;
1075
+ }
1076
+ }
1015
1077
 
1016
1078
  // 手记是**只读**的,所以每轮都重新读一遍 —— 别的肥鱼或用户刚写进去的
1017
1079
  // 那行,下一轮就在注入里,不用重启。文件很小,而且整块渲染按轮缓存。
@@ -1048,9 +1110,12 @@ export function apply(ctx, rawConfig) {
1048
1110
  // 所以不会像栏目那样被裁掉。
1049
1111
  //
1050
1112
  // 2026-10-27:给 `feiyu_note` 的那半句删了 —— 工具本身也下线了。
1051
- handNoteHint: `想不起来的时候,用 ${RECALL_TOOL_NAME} 回想一下 —— 用当时的词,越具体越准。`,
1113
+ handNoteHint: `手上的记忆里真没有,才用 ${RECALL_TOOL_NAME} 去翻。那是最后一招。`,
1052
1114
  includePersona: config.includePersona,
1053
1115
  budget: config.budgetTokens,
1116
+ // 召回那栏能渲染几条。默认值跟上面捞的条数同一个数 ——
1117
+ // 不传的话 `renderRecalled` 会回落到 2 条,捞 15 条只显示 2 条,白捞。
1118
+ recalledMax: recallLimit,
1054
1119
  now: Date.now(),
1055
1120
  });
1056
1121
  text = rendered.text;
@@ -2067,8 +2132,19 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
2067
2132
  const base = narrowed === null ? all : all.filter(
2068
2133
  (e) => Number.isFinite(e.created_at) && e.created_at >= narrowed.from && e.created_at <= narrowed.to,
2069
2134
  );
2070
- const pool = base.slice(-400);
2071
- if (base.length > pool.length) log(`挑往事:${base.length} 条只取最近 ${pool.length} 条`);
2135
+ // ── 2026-11-02:不再静默截到最近 400 条 ──
2136
+ //
2137
+ // 那个 400 是"prompt 压不住"的近似,但它的副作用是**悄悄丢掉最老的那些**:
2138
+ // 480 条时已经丢了 80 条,而丢掉的恰好是最难被想起来、也最该被留着的那批。
2139
+ // 更糟的是它只写一行 log —— 用户永远看不到自己的记忆在少。
2140
+ //
2141
+ // 现在只留一道"真的太大才截"的闸,而且会 warn。真要涨到过千条,
2142
+ // 该做的是分层(按天/按月),不是继续截尾巴。
2143
+ const POOL_CAP = 1200;
2144
+ const pool = base.length > POOL_CAP ? base.slice(-POOL_CAP) : base;
2145
+ if (base.length > pool.length) {
2146
+ warn(`挑往事:${base.length} 条超过上限 ${POOL_CAP},只取最近 ${pool.length} 条 —— 更老的挑不到`);
2147
+ }
2072
2148
 
2073
2149
  // 索引行前面带日期 —— 模型才有办法回答「那阵子」这种问法。
2074
2150
  const dayLabel = (t) => {
@@ -2086,7 +2162,10 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
2086
2162
 
2087
2163
  const system =
2088
2164
  '你在帮一条鱼回忆往事。她问了一句,你从她的日记索引里挑出最相关的几条。\n\n' +
2089
- '每行是「id 空格 日期 一句索引」。有些索引很粗(正文截断出来的),别嫌弃。\n' +
2165
+ '每行是「id 空格 日期 一句索引」。\n' +
2166
+ '**要挑的是「那件事本身」,不是「我记过这件事」的记录。**\n' +
2167
+ '像「他问过 X·我答记不清」「这次是翻出来的」「注入里没有」这种,**都不能算答案** ——\n' +
2168
+ '它们只说明那条鱼当时没想起来,不是他讲过的事。\n' +
2090
2169
  '**如果她问的是一段时间(「那阵子」「上个月」),优先看日期。**\n' +
2091
2170
  '只输出 JSON:{"ids":["d_20261028_1","d_20261028_2"]}\n' +
2092
2171
  '没有相关的就输出 {"ids":[]}。最多 ' + limit + ' 条。**宁可少挑,别硬凑。**';
@@ -2178,10 +2257,10 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
2178
2257
  scope.tools.register({
2179
2258
  name: RECALL_TOOL_NAME,
2180
2259
  description:
2181
- '回想一些以前的事。**你自己想不起来时用它**:' +
2182
- '他提到一件早先的事、你觉得该想起来却一片空白、或者要确认某个细节。' +
2183
- '只在真的需要时调,不要每轮都查。' +
2184
- '想不起来就老实说想不起来,别编。',
2260
+ '翻日记。**只有在完全没有印象时才用** —— 他问的那件事,你手上一点底都没有。' +
2261
+ '别用它确认细节,别用它补全,也别因为「怕答错」就去查。' +
2262
+ '查到什么直接说,不用交代「这是我翻出来的」;他真问你怎么知道的,再说实话。' +
2263
+ '翻不到就说翻不到,别编。',
2185
2264
  parameters: {
2186
2265
  type: 'object',
2187
2266
  additionalProperties: false,
@@ -2211,7 +2290,9 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
2211
2290
  render: (_args, value) => [{ type: 'text', text: value?.text ?? '查不了。' }],
2212
2291
  },
2213
2292
 
2214
- async execute(args) {
2293
+ // 第二个参数 `exec` 带 `agent` —— 靠它知道是谁在翻,
2294
+ // 翻到的东西要存进那个会话的 pendingRecall(见 renderFor 那段注释)。
2295
+ async execute(args, exec) {
2215
2296
  await loadPromise;
2216
2297
  if (!ready) throw new Error('档案现在读不了(可能是坏的),查不了。');
2217
2298
 
@@ -2279,27 +2360,63 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
2279
2360
  };
2280
2361
  }
2281
2362
 
2282
- // 总字数封顶 —— 不按"次数"限流(那要跨轮状态,麻烦),
2283
- // 按"返回量"限:超了就是一次失败的成本,它自己会学会别乱查。
2363
+ // ── 收到哪去:**不直接给她**,存进本轮「翻到的」,由注入送去 ──
2364
+ //
2365
+ // 2026-11-03 改。原来这里 `return` 正文,于是她读到的是"工具查回来的资料",
2366
+ // 嘴里就挂着一句"这是我翻出来的"。用户的原话:
2367
+ // **一问就是 recall 查的,不是自己的。**
2368
+ //
2369
+ // 现在:正文存进 `pendingRecall`,返回值只剩一句"(翻了翻)"。
2370
+ // 正文由下一次 `renderFor` 混进记忆块 —— 她读到的是"记忆里多了几条"。
2371
+ //
2372
+ // 她真的翻了,动作还在对话历史里。区别只在**翻完之后怎么读**:
2373
+ // 人翻完笔记合上,说话时不会说"根据笔记第 3 页"。
2284
2374
  const MAX_CHARS = 2000;
2285
- const lines = [];
2375
+ const pickedEntries = [];
2286
2376
  let used = 0;
2287
- const now = Date.now();
2288
- for (const { entry } of ranked) {
2377
+ for (const { entry, sim } of ranked) {
2378
+ // note(判断那一栏已下线)不进来 —— 让规矩从工具溜回来,等于没删干净
2379
+ if (entry.kind === 'note') continue;
2289
2380
  // 跟注入走同一个隐私闸 —— 历史条目里可能还有没洗过的东西
2290
- const body = stripTimeWords(scrub(entry.text, { thirdParty: false }).text);
2291
- const when = softWhen(entry.created_at, now);
2292
- const line = when === '' ? body : `${when}:${body}`;
2293
- if (used + line.length > MAX_CHARS && lines.length > 0) break;
2294
- lines.push(`- ${line}`);
2295
- used += line.length;
2381
+ const clean = scrub(entry.text, { thirdParty: false }).text;
2382
+ if (used + clean.length > MAX_CHARS && pickedEntries.length > 0) break;
2383
+ pickedEntries.push({ entry: { ...entry, text: clean }, score: sim, sim, why: '' });
2384
+ used += clean.length;
2385
+ }
2386
+
2387
+ const sid = typeof exec?.agent?.id === 'string' ? exec.agent.id : undefined;
2388
+ if (sid === undefined) {
2389
+ // 拿不到会话(极端情况)→ 退回旧行为,别让她什么都拿不到
2390
+ log(`检索「${query.slice(0, 20)}」:拿不到会话 id,直接返回正文`);
2391
+ const now0 = Date.now();
2392
+ const lines = pickedEntries.map(({ entry }) => {
2393
+ // 跟注入那一栏用同一个时间尺子(dayLabel),别一个模糊一个精确
2394
+ const when = dayLabel(entry.created_at, now0);
2395
+ return `- ${when === '' ? entry.text : `${when}:${entry.text}`}`;
2396
+ });
2397
+ return { ok: true, found: lines.length, text: lines.join('\n') || '想不起来了。' };
2296
2398
  }
2297
2399
 
2298
- log(`检索「${query.slice(0, 20)}」:返回 ${lines.length} 条`);
2400
+ const st = stateOf(sid);
2401
+ st.pendingRecall = pickedEntries;
2402
+ st.pendingRecallTurn = st.turn;
2403
+ // 作废按轮缓存:下一次 assemble 会重算,把这批混进记忆块
2404
+ st.renderCache = { turn: -1, text: '' };
2405
+
2406
+ log(`检索「${query.slice(0, 20)}」:翻到 ${pickedEntries.length} 条,等注入送`);
2299
2407
  return {
2300
2408
  ok: true,
2301
- found: lines.length,
2302
- text: lines.join('\n'),
2409
+ found: pickedEntries.length,
2410
+ // 2026-11-03:**什么都不留。**
2411
+ //
2412
+ // 原来是「(翻了翻)」,用户看了说不要 —— 那还是个痕迹:
2413
+ // 她读到"我刚翻了一下",下一句就容易带出"我查到的"。
2414
+ //
2415
+ // 现在正文全走注入,返回值只是一个空串。工具调用本身还在历史里
2416
+ // (该留的证据留着了),但**不会再往她嘴里塞一句"我去翻了"。**
2417
+ //
2418
+ // 翻空了还是要说一句 —— 那是另一回事:别让她以为"翻过了等于找到了"。
2419
+ text: pickedEntries.length === 0 ? '想不起来了。换个说法再试试,或者直接问他。' : '',
2303
2420
  };
2304
2421
  },
2305
2422