@nightpainters/dsh-ling 1.7.1 → 1.7.2

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
@@ -2,6 +2,42 @@
2
2
 
3
3
  本项目遵循语义化版本:`MAJOR.MINOR.PATCH`。
4
4
 
5
+ ## 1.7.2
6
+
7
+ > 1.7.1 之后的**回执降噪**小版本:**没有新功能**,只让 `recall` 的每一次回执更短、更干净。
8
+
9
+ ### 一、截断后清理未闭合标记
10
+
11
+ - `recall` 的命中片段常从原文中间截断,于是回执末尾留一个孤零零的 `**` / `` ` `` / `[` / `【` —— 读起来像断句错误,模型还可能据此误判语气。现在出口统一数一遍奇偶,**剥掉末尾那个没闭合的**。
12
+ - **不补**闭合标记:补会凭空造字(补在句中断句处,读起来更假)。
13
+ - 只动末尾那一处;中间成对的标记是回执自己的格式,一个字不动。
14
+
15
+ ### 二、固定文案收进工具说明,回执只留数据
16
+
17
+ - 「下一步」「换词口径」这两段原先**每条回执都印一遍**,而它们与本次查询无关。现在收进工具 description(模型每次组装系统面时都看得见),回执只留**数据 + 本次特有的警告**(超时、被范围挡掉、本会话被排除、还有多少轮…)。
18
+ - 实测背景:51 条真实回执平均 1,211 字符,这两段占了噪音的主体。
19
+ - 结论层的「要原话怎么取」原先**每条条目都印一遍**(6 条就是 6 遍),现在只印一次 —— 同一句指令重复 N 次是最典型的固定文案。
20
+ - 实测(同一批输入跑改前/改后渲染器):条目 + 5 轮原文的回执 **1,819 → 1,435 字(-21.1%)**;零命中 **181 → 118(-34.8%)**;超时 + 条目 **492 → 343(-30.3%)**。
21
+
22
+ ### 三、命中片段只剥成对标记(不动回执自身格式)
23
+
24
+ - 片段里的 `**粗体**` / `` `代码` `` / `[文字](地址)` 在界面渲染里有意义,在上下文里只是字符 ⇒ **只剥片段里成对的**;回执自己的结构照旧保留(表格、多行不塌)。
25
+ - 跨行的成对标记不剥 —— 剥了会把整段结构吃掉。
26
+
27
+ ### 四、结论层条目去重 + 同类折叠
28
+
29
+ - 一次检索里同一类目常回来一长串(实测样本:4 条全是「记忆树」),把真正不同的条目挤下去。现在:**完全重复的**(同会话 + 同文本)只留一条;同一类目超过 3 条时,前 3 条照给,**其余折叠成一行并报出条数** —— 不静默丢弃。
30
+
31
+ ### 五、回执总字数上限 4,000
32
+
33
+ - 超过就截断,并**报出还有多少字没显示** + 怎么分次取。绝不静默截断(那会让"没显示"读成"没有")。
34
+ - 「取原文」这一档**不套这个上限**:它有自己的 12,000 字护栏和"还有 N 轮没给"的报数,再套 4,000 会把取原文这件事本身弄废。
35
+
36
+ ### 六、emoji 与测试
37
+
38
+ - 回执里的 `📌 ⚠️ ✓ ✅ ❌ 🟡 ★` 是**状态符号**不是装饰:保留、不新增(新加一个 emoji 省不下 token,却要多花一次解释)。
39
+ - 新增 `tests/recall-noise.test.mjs`(M1/M2/M3/M5/M6 各一组断言,含"截断不得切出半个 emoji");测试链 **56 → 57 段**。
40
+
5
41
  ## 1.7.1
6
42
 
7
43
  > 1.7.0 之后的安全加固小版本,**不含新功能**。
package/README.md CHANGED
@@ -209,7 +209,7 @@
209
209
  - **严格配置,默认什么都没登记**:用于"我们自己代码"条目的代码根目录,与用于官方文档条目的 URL 白名单,都是**管理员登记的绝对路径 / 精确 URL**,**默认都是空的** —— 不登记就核不了,不存在"它自己上网找一份来对"。限额(条目 64 KiB、取源 2 MiB、选中文本 16 KiB 且不超过 200 行、单次核对 10 s、并发 2 等)在**激活时校验**:写错了**响亮报错**,不静默夹取。
210
210
  - **取消 = 没跑完,不是一次 `unknown` 认证**:核对在飞时断开、取消或卸载插件,本次核对**不写任何核对记录** —— 既不留 `ok` / `wrong`,**也不留 `unknown`**,只回一个"已取消"(带 `cancelled`)。"取消了"与"核完了(哪怕结论是 `unknown`)"在留痕里必须分得开:**只有跑完的核对才计入"认证过"**。
211
211
  - **远程核对会主动去你登记的地方取**:官方文档类源走 **HTTPS GET 你白名单里的那个精确 URL**(不许带凭据、不许跳转、私网地址拒 —— **还拒 `localhost` / `*.localhost` / `*.local` / `*.internal`,并对 DNS 解析结果与连接后的 `remoteAddress` 做二次校验**(实现见 `lib/host/library-checker.js`:地址校验 + 解析结果钉住 + 连接后复核))。**发出去的到底是什么,必须说准**:这个 GET 会把**你登记的那个 URL(连同它带的查询串)**与**一次普通 HTTPS 请求的元数据**(例如对方可见的连接来源 IP,以及 Accept/Accept-Encoding 请求头)交给那台服务器 —— 换句话说,**对方能看到你的 IP,也看得到这个 URL 本身**;但它**不会附带**条目正文、记忆内容、人格档案或任何本机文件。⚠️ 因此:**URL 里若放了不该外传的东西(比如把 token 写在查询串里),它就会被发出去** —— 白名单是你自己登记的,请当成"公开可见"来写。白名单默认为空 ⇒ 图书馆默认不发生外部取源访问。
212
- - **边界(如实写)**:这一版已抬版到 **`1.7.1`(工作区候选)但仍未发布、未安装**;而"没发布"**不等于"没验过"** —— 第五个标签(图书馆)已于 **2026-10-07 在本机既有 3080 上真机验收走通**(建档 / 核对 / 撤下留痕 / 撤销弹窗全过),实现与交互测试同时也在源码与离线测试里。两者别混读。另有一条边界要说清:**"不把记忆库里的旧数据迁进图书馆"不等于"禁止网页端来源的资料"** —— 一条资料能不能进图书馆,看的是它**有没有具体、可追责的出处**,与它从哪个渠道来无关。
212
+ - **边界(如实写)**:这一版的包版本是 **`1.7.2`(工作区候选)**;**上一版已于 2026-10-17 发布上 npm**(`latest`),`1.7.2` 尚未发布。而"没发布"**不等于"没验过"** —— 第五个标签(图书馆)已于 **2026-10-07 在本机既有 3080 上真机验收走通**(建档 / 核对 / 撤下留痕 / 撤销弹窗全过),实现与交互测试同时也在源码与离线测试里。两者别混读。另有一条边界要说清:**"不把记忆库里的旧数据迁进图书馆"不等于"禁止网页端来源的资料"** —— 一条资料能不能进图书馆,看的是它**有没有具体、可追责的出处**,与它从哪个渠道来无关。
213
213
 
214
214
  > **机制依据与源码证据**(表结构、核对三态与各条判据的实际位置、取消与错误码落在哪一行)集中在 [`DESIGN.md`](./DESIGN.md) 的「图书馆:机制与源码证据」一节 —— 本节讲"用户能感觉到什么",那一节给出"代码里是哪一行"。
215
215
 
@@ -285,20 +285,22 @@ node tools/check-peers.mjs # 声明与宿主对不上?退出码三态
285
285
  跑测试(不需要宿主,全部离线):
286
286
 
287
287
  ```bash
288
- npm test # 56 套:人格组装(含冻结门 · L1 选择)/ 归档清理(人工)/ **归档·遗忘·回灌** / 模式(只改档位不改模型)/ **跨插件挡位服务(dsh-ling/mode)** / 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **活行(那一行跟着会话走)** / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / **职责字段(承担)** / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)** / **请求体上限(超限排空 → 413)** / **导入切批(按字节 × 条数双限)** / **投递留痕(每次注入落一条:注入了谁·为什么是它·多少字)** / **会话原文直读(三档下钻 + 库内回退 + 旧版会话代次)** / **深层库条目(结论层落点:读写·幂等·取代·加固)** / **检索档(按内容找会话:条目 + 会话同查·范围限制与降级)** / **图书馆存储层(独立库:生命周期·限额·检索·留旧·不可用≠零命中)** / **图书馆跨连接事务(陈旧修订拒绝·完成的核对留档·序号一致)** / **图书馆核对(三态判定·只认证声明的那个比较·限额与取消不写)** / **图书馆运行时(禁用/不可用降级·接线与关闭顺序)** / **图书馆接口与工具(端点形状·状态码映射·两个工具的参数边界)** / **图书馆界面(独立库的面:分页无伪总数·必填与 JSON 校验·推迟应答竞态)** / **图书馆导入引擎(料源登记与导入)** / **图书馆独立 SQLite(隔离库:只读打开·表必须点名)** / **图书馆浏览(分页与零命中)** / **热重载补绑(工具重挂载:存量 agent 补绑)** / **文档灌入通道(结论层 C 档:两道污染闸·三级去重·出处核验·累计预算)** / **后台自动行为开关(auto:五键默认值·三级优先级·白名单夹取·四条真 HTTP 回归)**
288
+ npm test # 57 套:人格组装(含冻结门 · L1 选择)/ 归档清理(人工)/ **归档·遗忘·回灌** / 模式(只改档位不改模型)/ **跨插件挡位服务(dsh-ling/mode)** / 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **活行(那一行跟着会话走)** / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / **职责字段(承担)** / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)** / **请求体上限(超限排空 → 413)** / **导入切批(按字节 × 条数双限)** / **投递留痕(每次注入落一条:注入了谁·为什么是它·多少字)** / **会话原文直读(三档下钻 + 库内回退 + 旧版会话代次)** / **深层库条目(结论层落点:读写·幂等·取代·加固)** / **检索档(按内容找会话:条目 + 会话同查·范围限制与降级)** / **图书馆存储层(独立库:生命周期·限额·检索·留旧·不可用≠零命中)** / **图书馆跨连接事务(陈旧修订拒绝·完成的核对留档·序号一致)** / **图书馆核对(三态判定·只认证声明的那个比较·限额与取消不写)** / **图书馆运行时(禁用/不可用降级·接线与关闭顺序)** / **图书馆接口与工具(端点形状·状态码映射·两个工具的参数边界)** / **图书馆界面(独立库的面:分页无伪总数·必填与 JSON 校验·推迟应答竞态)** / **图书馆导入引擎(料源登记与导入)** / **图书馆独立 SQLite(隔离库:只读打开·表必须点名)** / **图书馆浏览(分页与零命中)** / **热重载补绑(工具重挂载:存量 agent 补绑)** / **文档灌入通道(结论层 C 档:两道污染闸·三级去重·出处核验·累计预算)** / **后台自动行为开关(auto:五键默认值·三级优先级·白名单夹取·四条真 HTTP 回归)**
289
289
  ```
290
290
 
291
291
  > `npm test` 需要 `tests/`,而本包 `files` 白名单**不含 `tests/`** ⇒ **从 npm 装的人跑不了测试**。要跑测试请走路径 B(clone 源码)。
292
292
  >
293
- > 计数口径:**`npm test` 链里登记的段数**(即 `package.json` 的 `scripts.test` 那一行按 `&&` 切分后有多少段就是多少套,不再数 `tests/` 目录)—— **实测 56 套(即链里 56 段),且 56 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
293
+ > 计数口径:**`npm test` 链里登记的段数**(即 `package.json` 的 `scripts.test` 那一行按 `&&` 切分后有多少段就是多少套,不再数 `tests/` 目录)—— **实测 57 套(即链里 57 段),且 57 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
294
294
  >
295
295
  > ⚠️ **2026-10-15 实测订正**:此前这里写着 49、而链里当时已经有 53 段 —— 也就是**这段注释自己警告的坑真发生过**:套件登记进去了,这个数字却没人回头改,四次都没被任何判据抓住。当天新增 `deep-import-docs.test.mjs`(C 档文档灌入)⇒ 54。这个数字没有判据替你看着,改链时必须顺手改它。
296
296
  >
297
297
  > ⚠️ **2026-10-16 再 +1**:新增 `tests/auto-config.test.mjs`(后台自动行为开关:五键默认值 / 三级优先级 / 白名单与夹取 / **四条真 HTTP 回归**)⇒ 计数从 54 变成 55。同日还抓到**一条真红**:修复批次把 `tools/compliance-spec.mjs` 的出站基线从 6 抬到 23 时,**漏改了 `tests/tools.test.mjs` 里钉住的字面量** ⇒ 整链自那时起就是红的,直到当日修复。**同一个坑两次**(数字写死在多处、改一处忘另一处)—— 改链、改基线时,**同批**把所有钉死它的地方一起改。
298
298
  >
299
- > ⚠️ **2026-10-17 再 +1**:新增 `tests/f4-surrogate.test.mjs`(注入面净化回归:孤立代理不许进请求体)⇒ **56 套**。同一天还修掉了这套测试自己的一个坑:`recall` 的真机断言原先押在"此刻最新的那个会话"上,并发子代理在场时会**时红时绿** ⇒ 改为在最近 5 个会话里挑第一条真有可见对话轮的。
299
+ > ⚠️ **2026-10-17 再 +1**:新增 `tests/f4-surrogate.test.mjs`(注入面净化回归:孤立代理不许进请求体)⇒ **56 套**。同一天还修掉了这套测试自己的一个坑:`recall` 的真机断言原先押在"此刻最新的那个会话"上,并发子代理在场时会**时红时绿** ⇒ 改为在最近 5 个会话里挑第一条真有可见对话轮的。 <!-- ver-hist -->
300
300
  >
301
- > **本轮跑过的实测(1.7.1 工作区)**:在**隔离环境**(临时数据目录,不碰真机记忆库/图书馆库)下完整跑过一遍 `npm test` —— **56 套全部通过、exit 0**(宿主侧 `deep_item` / `GET /deep/items` 另在 2026-10-15 重启后实测生效);各组件的断言**条数会随迭代变动,本文不写死** —— **最终测试结果以工作区验收清单为准**。⚠️ 这只说明"**这一版代码在离线测试里的行为**",**不等于"所有门禁都完成了"** —— 真实远程核对与真实 DNS/TLS 仍未验(见上文「图书馆」一节的边界)。
301
+ > ⚠️ **2026-10-23 再 +1**:新增 `tests/recall-noise.test.mjs`(回执降噪回归:M1 未闭合标记 / M2 固定文案不进回执 / M3 只剥片段里的成对标记 / M5 条目去重与同类折叠 / M6 4,000 字上限与"还剩多少"报数,含**截断不得切出半个 emoji**)⇒ **57 套**。
302
+ >
303
+ > **本轮跑过的实测(1.7.2 工作区)**:在**隔离环境**(临时数据目录,不碰真机记忆库/图书馆库)下完整跑过一遍 `npm test` —— **57 套全部通过、exit 0**(宿主侧 `deep_item` / `GET /deep/items` 另在 2026-10-15 重启后实测生效);各组件的断言**条数会随迭代变动,本文不写死** —— **最终测试结果以工作区验收清单为准**。⚠️ 这只说明"**这一版代码在离线测试里的行为**",**不等于"所有门禁都完成了"** —— 真实远程核对与真实 DNS/TLS 仍未验(见上文「图书馆」一节的边界)。
302
304
 
303
305
  ### 数据放在哪
304
306
 
@@ -481,7 +483,7 @@ npm test # 56 套:人格组装(含冻结门 · L1 选择)/ 归档清理(
481
483
 
482
484
  ### 1.7 新增(图书馆 · 工作区,未发布)
483
485
 
484
- > ⚠️ **本节的状态标记只指"工作区代码 + 离线测试"这一层**:1.7 **尚未发布**(包版本已抬到 `1.7.1`,尚未发布/安装);"宿主界面上的「图书馆」标签"**已真机验收走通**(2026-10-07,见 L-11);「结论层」标签已于 **2026-10-15** 随一次宿主重启实测生效。
486
+ > ⚠️ **本节的状态标记只指"工作区代码 + 离线测试"这一层**:包版本已抬到 `1.7.2`(工作区候选);上一版已于 2026-10-17 发布公布。"宿主界面上的「图书馆」标签"**已真机验收走通**(2026-10-07,见 L-11);「结论层」标签已于 **2026-10-15** 随一次宿主重启实测生效。
485
487
 
486
488
  | # | 设定 | 状态 |
487
489
  |---|---|---|
@@ -496,7 +498,7 @@ npm test # 56 套:人格组装(含冻结门 · L1 选择)/ 归档清理(
496
498
  | **L-8** | **写面必须带"观测到的内容摘要"**(`content_hash`),对不上就拒 —— 防"看着旧版改新版" | ✅ 工作区 |
497
499
  | **L-9** | **严格配置** —— 代码根目录与官方文档 URL 白名单**默认为空**、由管理员登记;限额在**激活时校验**,非法值**响亮报错**、不静默夹取 | ✅ 工作区 |
498
500
  | **L-10** | **取消 = 没跑完,不留记录** —— 核对在飞时取消 / 断开 / 卸载 ⇒ 本次**不写任何核对记录**(既不留 `ok` / `wrong`,**也不留 `unknown`**);只有**跑完的**核对(含结论 `unknown`)才留账 | ✅ 工作区 |
499
- | **L-11** | **发布状态如实声明** —— 包版本已抬到 **`1.7.1`(工作区候选)**,**未发布、未安装**;但「图书馆」标签**已真机验收走通**(2026-10-07),「结论层」标签也已随 2026-10-15 的重启实测生效 —— **"没发布"与"没验过"必须分开说** | ✅ 真机验收(2026-10-07) |
501
+ | **L-11** | **发布状态如实声明** —— 包版本已抬到 **`1.7.2`(工作区候选)**,**本版未发布**;上一版已发布上 npm;但「图书馆」标签**已真机验收走通**(2026-10-07),「结论层」标签也已随 2026-10-15 的重启实测生效 —— **"没发布"与"没验过"必须分开说** | ✅ 真机验收(2026-10-07) |
500
502
 
501
503
  > ⚠️ **这一节还在补。** 完整规格有**四百余条**,本节先落**对外面**里最稳定的一批(设计理念);其余按主题分批补写 —— 每一条都要从开发期设定**改写成对外语言**并核对状态,这是**写作**而不是复制。
502
504
 
@@ -389,3 +389,97 @@ export function oneLine(s, cap = 60) {
389
389
  if (!t) return '';
390
390
  return clipChars(t, cap);
391
391
  }
392
+
393
+ // ══ 1.7.2 · recall 回执降噪(2026-10-23)══════════════════════════════════════════
394
+ // 三个纯函数,对应交接单的 M1 / M3 / M6。**放在文本出口这一层,不塞进渲染器** ——
395
+ // 回执有四个出口(检索档、目录档、原文档、错误档),每个各写一份就会各错一处
396
+ // (1.7 的 F-01 正是"修了三处、漏了两处"的现场)。
397
+
398
+ /** 删掉 `token` **最后出现**的那一处(只用于末尾未闭合的标记,不重排正文)。 */
399
+ function cutLastToken(t, token) {
400
+ const i = t.lastIndexOf(token);
401
+ if (i < 0) return t;
402
+ return t.slice(0, i) + t.slice(i + token.length);
403
+ }
404
+
405
+ /** **M1 · 截断后清理未闭合标记**(不补闭合,只剥掉末尾那个)。
406
+ *
407
+ * 现场:实测 51 条回执平均 1,211 字符,`**`/`` ` `` 约占 3.5%;而截断常常落在标记中间,
408
+ * 于是回执末尾留一个孤零零的 `**` 或 `` ` `` —— 模型读到的是一段**语法已坏**的文本,
409
+ * 却看不出这是截断造成的(它会以为正文里本来就有个星号)。
410
+ *
411
+ * 口径三条:
412
+ * ① 只数 `**` / `` ` `` / `[` / `【` 的**奇偶**;奇数 ⇒ 最后一个没闭合 ⇒ 剥掉它
413
+ * (`【` 是回执自己的大纲符号,清单 §3 M1 明确要求一起数);
414
+ * ② **不补**闭合标记 —— 补会凭空造字(补在句中断句处,读起来更假);
415
+ * ③ 只动**末尾那一处**,中间成对的标记是回执自己的格式,一个字不动。 */
416
+ export function stripDanglingMarkers(s) {
417
+ let t = String(s ?? '');
418
+ for (let round = 0; round < 4; round += 1) {
419
+ const before = t;
420
+ // ⚠️ 代码围栏 ` ``` ` 必须**整体**算一个标记(2026-10-23 夜 · 对抗审计 A 抓到):
421
+ // 围栏是 3 个反引号,按"单个反引号"的奇偶去算会把它拆成 2+1 —— 于是截断后的围栏
422
+ // 会剩两个反引号(` ``js`)、甚至反过来把前文成对的行内代码当成悬空的那个。
423
+ // 先整段剥围栏,再数单个反引号,两个口径就不会互相污染。
424
+ if ((t.split('```').length - 1) % 2 === 1) t = cutLastToken(t, '```');
425
+ if ((t.split('**').length - 1) % 2 === 1) t = cutLastToken(t, '**');
426
+ if ((t.split('`').length - 1) % 2 === 1) t = cutLastToken(t, '`');
427
+ const open = (t.match(/\[/g) || []).length;
428
+ const close = (t.match(/]/g) || []).length;
429
+ if (open > close) t = cutLastToken(t, '[');
430
+ const openCjk = (t.match(/【/g) || []).length;
431
+ const closeCjk = (t.match(/】/g) || []).length;
432
+ if (openCjk > closeCjk) t = cutLastToken(t, '【');
433
+ if (t === before) break;
434
+ }
435
+ return t;
436
+ }
437
+
438
+ /** **M3 · 只剥 snippet 里的成对标记**(选项 C:回执自身格式保留)。
439
+ *
440
+ * 为什么只剥"成对"的:`**粗体**` 在 GUI 里有意义,在上下文里只是两个字符;
441
+ * 但**没配对的**那个是截断残渣,归 M1 管 —— 两件事分开做,才各自可测。
442
+ * 支持三种成对形态:粗体 `**x**` · 行内代码 `` `x` `` · 行内链接 `[x](url)`(留文字丢地址)。 */
443
+ export function stripPairedMarkdown(s) {
444
+ let t = String(s ?? '');
445
+ for (let round = 0; round < 3; round += 1) {
446
+ const before = t;
447
+ t = t
448
+ .replace(/\*\*([^*\n]+)\*\*/g, '$1')
449
+ .replace(/`([^`\n]+)`/g, '$1')
450
+ .replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, '$1');
451
+ if (t === before) break;
452
+ }
453
+ return t;
454
+ }
455
+
456
+ /** **M6 · 回执总字数上限**(默认 4,000)。
457
+ *
458
+ * 超了**截断并报出还剩多少** —— 绝不静默截断(那会让"没显示"读成"没有")。
459
+ * 返回 `{ text, truncated, remaining }`;`remaining` 是被切掉的**字符数**(UTF-16 单元),
460
+ * 报给模型的是"还有多少没看到",不是"还有多少轮"(轮数是 read 档的 `remaining`,别混)。
461
+ * ⚠️ 切点走 `clipChars`(不切半个 emoji) —— 与 F4 同一道防线。
462
+ *
463
+ * ⚠️⚠️ `keepTail`(2026-10-23 夜 · 对抗审计抓到后加的):**末尾的语义警告必须保住**。
464
+ * 回执的"本会话被排除 N 轮 / 被范围挡掉 N 条 / 还有更多 / 库里另有 N 条只有概述"全都拼在**末尾**
465
+ * —— 直接按字数截断会把它们连正文一起切掉,于是"排除了 7 轮"变成"没有这回事"。
466
+ * 实测(审计员给的数):search 40 条长片段时三者**全部消失**,布尔 self/scope/more 均 false。
467
+ * ⇒ 口径:截断只截**正文**,`keepTail` 那段**原样保留**,并从预算里先扣掉它的长度。 */
468
+ export const RECEIPT_CHARS_MAX = 4000;
469
+ export function capReceiptText(s, cap = RECEIPT_CHARS_MAX, keepTail = '') {
470
+ const t = String(s ?? '');
471
+ const tail = String(keepTail ?? '');
472
+ const k = Math.max(400, Number(cap) || RECEIPT_CHARS_MAX);
473
+ if (t.length <= k) return { text: t, truncated: false, remaining: 0 };
474
+ const budget = Math.max(200, k - tail.length);
475
+ const head = clipChars(t, budget);
476
+ const shown = Math.max(0, head.length - 1); // 末尾那个 '…' 不算正文
477
+ const remaining = Math.max(0, t.length - shown - tail.length);
478
+ return {
479
+ text: head
480
+ + `\n\n…(回执到 ${k} 字上限,**还有 ${remaining} 字没显示** —— 缩小查询词,或分次取(原文档用 from/to,清单档用更小的 limit)。)`
481
+ + tail,
482
+ truncated: true,
483
+ remaining,
484
+ };
485
+ }
@@ -22,7 +22,7 @@
22
22
  // (官方同款实现在 `dsh-api-session-controller\lib\index.js:1934` / `:1984`)。
23
23
 
24
24
  import { isMemoryEligibleHeader, sessionKind } from './util.js';
25
- import { localStamp, oneLine, safeSlice, wellFormed } from './rawlog.js';
25
+ import { localStamp, oneLine, safeSlice, wellFormed, stripPairedMarkdown, stripDanglingMarkers, capReceiptText, RECEIPT_CHARS_MAX } from './rawlog.js';
26
26
 
27
27
  /** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
28
28
  * 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
@@ -183,7 +183,9 @@ export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfI
183
183
  // ⚠️ F4(2026-10-17):原来是 `text.slice(from, from + N)` —— 起点或终点落在 emoji 中间时
184
184
  // 会切出**半个代理对**,而那半个代理会随回执进上下文、把官方路由打成 400。
185
185
  // 实测 `session-bc00b39b` #6331 的低代理 `0xdd12` 正是起点切坏而来(见 rawlog.wellFormed)。
186
- snippet: oneLine(safeSlice(text, from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
186
+ // M3(1.7.2):snippet 是**别人的原文**,可能带 Markdown —— 只剥成对标记(选项 C);
187
+ // 回执自己的格式由模板负责,不在这里动。M1 再兜住截断留下的未闭合残渣。
188
+ snippet: stripDanglingMarkers(stripPairedMarkdown(oneLine(safeSlice(text, from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS))),
187
189
  isSelf: Boolean(self) && id === self,
188
190
  };
189
191
  });
@@ -266,7 +268,7 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
266
268
  seq: m.seq ?? null,
267
269
  time: m.time ? localStamp(m.time) : '',
268
270
  who: isUser ? callName : '器灵',
269
- snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
271
+ snippet: stripDanglingMarkers(stripPairedMarkdown(oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS))),
270
272
  isSelf: Boolean(self) && String(id) === self,
271
273
  });
272
274
  }
@@ -282,12 +284,71 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
282
284
  return { rows, skipped, degraded: !all && !visible };
283
285
  }
284
286
 
287
+ /** **M5(1.7.2) · 深层库条目去重 + 同类折叠**。
288
+ *
289
+ * 现场(修改清单 §3 M5):样本里 4 条深层库条目全是「记忆树」相关事实、来自两个会话 ——
290
+ * 同一类目一次刷一屏,把**真正不同的**条目挤下去。
291
+ * 两条口径(**清单给的"或"两条都做,都不静默丢弃**):
292
+ * ① 完全重复(`conv_id` + `text` 都相同)只留第一条;
293
+ * ② 同一 `kind` 超过 `perKind`(默认 3)条时,前 `perKind` 条照常给,
294
+ * 其余**折叠成一行并报出条数** —— "没显示"不许读成"没有"。
295
+ * ⚠️ 不按 `conv_id` 把条目合并成一条:那会丢掉"这是两件事"这个信息(清单也把它列为风险)。
296
+ * 同源只做**去重**(①),不做语义合并。 */
297
+ export const DEEP_ROWS_PER_KIND = 3;
298
+ export function groupDeepRows(rows, perKind = DEEP_ROWS_PER_KIND) {
299
+ const keep = [];
300
+ const seen = new Set();
301
+ const byKind = new Map();
302
+ for (const d of Array.isArray(rows) ? rows : []) {
303
+ const key = `${d?.conv_id ?? ''}\u0000${d?.text ?? ''}`;
304
+ if (seen.has(key)) continue;
305
+ seen.add(key);
306
+ const kind = String(d?.kind || '未分类');
307
+ const n = byKind.get(kind) || 0;
308
+ byKind.set(kind, n + 1);
309
+ if (n < perKind) keep.push(d);
310
+ }
311
+ const folded = [];
312
+ for (const [kind, total] of byKind) {
313
+ if (total > perKind) {
314
+ folded.push(`· …[${kind}] **另有 ${total - perKind} 条未展开**(同类目超 ${perKind} 条时折叠;要全看请缩小查询词)…`);
315
+ }
316
+ }
317
+ return { rows: keep, folded };
318
+ }
319
+
285
320
  /** 检索档的人读回执(纯函数,模型可见)。 */
286
321
  export function renderSearch(v) {
287
322
  // ⚠️ F4(2026-10-17):**出口兜底净化**。snippet 有三个来源(宿主 `bestMatch` / 库内原文层 /
288
323
  // `rawlog` 目录),逐处修容易漏一处;这里对**整段回执**做一次 —— 任何落单代理(半个 emoji)
289
324
  // 都换成 `U+FFFD`,保证"进模型上下文的文本"永远是合法 UTF-16(见 `rawlog.wellFormed` 的事故记录)。
290
- return wellFormed(renderSearchText(v));
325
+ // M1/M6(1.7.2):先按 4,000 字上限截断(超了**报出还剩多少**),再剥末尾未闭合标记 ——
326
+ // 顺序不能反:截断本身会造出新的未闭合标记。
327
+ // ⚠️ 1.7.2 自查抓到的真缺陷:**search 档会被套两次上限**(renderSearch 一次 + renderRecall 一次)
328
+ const full = renderSearchText(v);
329
+ // ⚠️ 2026-10-23 夜(对抗审计 B 抓到):**末尾警告要保住** —— selfExcluded / skipped / hasMore
330
+ // 原先都会被 4,000 字上限连正文一起切掉,于是"排除了 N 轮"变成"没有这回事"。
331
+ // 只有真有命中(rows/deep 非空)时才走这条;零命中/超时/失败的回执本来就短。
332
+ const hasHits = (Array.isArray(v?.rows) && v.rows.length > 0) || (Array.isArray(v?.deep) && v.deep.length > 0);
333
+ const tail = hasHits ? searchTailNotes(v) : '';
334
+ const capped = capReceiptText(full, RECEIPT_CHARS_MAX, tail);
335
+ return wellFormed(stripDanglingMarkers(capped.text));
336
+ }
337
+
338
+ /** 回执**末尾的语义警告**(与 `renderSearchText` 末尾逐字同源 —— 两处必须一起改)。
339
+ * 之所以抽出来:上限截断时要把这段**整段保住**(见 `capReceiptText` 的 `keepTail`)。 */
340
+ function searchTailNotes(v) {
341
+ const timeoutNote = v.timedOut
342
+ ? `\n\n⚠️ **原文层超时**(等满 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回,多半是宿主在补索引对账):`
343
+ + ((Array.isArray(v.deep) && v.deep.length) ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
344
+ + '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
345
+ : '';
346
+ const selfNote = Number(v.selfExcluded) > 0
347
+ ? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
348
+ : '';
349
+ return timeoutNote + selfNote
350
+ + (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
351
+ + (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '');
291
352
  }
292
353
 
293
354
  /** 回执正文(不做净化)—— 出口在 `renderSearch`。 */
@@ -336,17 +397,21 @@ function renderSearchText(v) {
336
397
  : '';
337
398
  const hitRows = Array.isArray(v.rows) ? v.rows : [];
338
399
  const deepRows = Array.isArray(v.deep) ? v.deep : [];
400
+ // M5(1.7.2):条目去重 + 同类目超 3 条折叠成汇总行(见 groupDeepRows)。
401
+ const grouped = groupDeepRows(deepRows);
339
402
  // 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
340
403
  const deepBlock = deepRows.length
341
- ? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' + deepRows.map((d) => {
404
+ ? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' + grouped.rows.map((d) => {
342
405
  const pin = Number(d.pinned) ? '📌 ' : '';
343
406
  // ⚠️ **不要在这里印 `from=`**(红队 2026-10-04):条目的 `seq_from/seq_to` 是**事件流 seq**
344
407
  // (提炼料里的 `#N`,即 `dsh_turns_raw.seq`),而 `recall(conv, from, to)` 收的是
345
408
  // **对话轮序号 n** —— 两个坐标系在"注入消息被折叠"的会话里会叉开。实测 274 条可比条目里
346
409
  // **207 条(75.5%)照这条指引会直接 `out-of-range`**;偶有落在范围内的,读到的也是不相干的
347
410
  // 轮次,还会顺手给不相干条目计一次"采用"。⇒ 只给会话 id,让调用方**先看目录再取原文**。
348
- return `· ${pin}[${d.kind}] ${d.text}\n ↳ 出自 ${d.conv_id} —— 要看原话:先 \`recall(conv="${d.conv_id}")\` 拿轮次,再按目录里的 \`#N\` 取`;
349
- }).join('\n') + '\n\n'
411
+ // M2(1.7.2):这条指引原先**每条条目都印一遍**(6 条就是 6 遍)。现在只印一次(见下方块尾)。
412
+ return `· ${pin}[${d.kind}] ${d.text}\n ↳ 出自 ${d.conv_id}`;
413
+ }).join('\n') + (grouped.folded.length ? '\n' + grouped.folded.join('\n') : '')
414
+ + '\n(要原话:把上面的 conv 传给 `recall(conv=…)` 拿轮次,再按目录里的 `#N` 取 —— 别直接给 from,两个坐标系不同)' + '\n\n'
350
415
  : '';
351
416
  if (!hitRows.length && !deepRows.length) {
352
417
  // 超时是**另一种结论**,不能说成"没找到" —— 前者该"重发或换窄词",后者该"换词",
@@ -360,8 +425,9 @@ function renderSearchText(v) {
360
425
  }
361
426
  return `按内容没找到(查询「${v.q}」· ${scope})${layerNote}。`
362
427
  + (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
363
- + '换词试试:中文 **2~4 字**短词命中最好(`记忆树` / `血缘` / `注入面`);'
364
- + '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。';
428
+ // M2(1.7.2):完整"换词口径"已收进工具 description(模型每次都看得见);
429
+ // 回执只留本次特有信息。同一段话印两遍是纯噪音。
430
+ + '换词再试一次(口径见工具说明:中文 2~4 字、多词 = AND、无子串匹配)。';
365
431
  }
366
432
  const rows = hitRows.map((r) => {
367
433
  const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
@@ -376,21 +442,8 @@ function renderSearchText(v) {
376
442
  ? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope})${layerNote}:`
377
443
  : `按内容找到 ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`;
378
444
  const convBlock = rows.length ? `${v.local ? '【库内原文层】' : '【会话原文层】'}\n${rows.join('\n')}` : '';
379
- // ⚠️ 超时**不算失败**:条目层已经拿到了(本地库),必须照常交出去。这一行是"如实说明少了一半",
380
- // 而不是"报错" —— 两者的下一步动作不同(前者重发/换窄词,后者换通道)。
381
- const timeoutNote = v.timedOut
382
- ? `\n\n⚠️ **原文层超时**(等满 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回,多半是宿主在补索引对账):`
383
- + (deepRows.length ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
384
- + '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
385
- : '';
386
- // ⚠️ 排除本会话要**说出来**:不说就会被读成"库里只有这些"(那是说假话)。
387
- const selfNote = Number(v.selfExcluded) > 0
388
- ? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
389
- : '';
390
- return `${head}\n${deepBlock}${convBlock}${timeoutNote}${selfNote}`
391
- + (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
392
- + (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
393
- + (deepRows.length
394
- ? '\n\n**下一步**:条目已经是可以直接用的结论;**要它背后那句话**就把上面的 conv 传给 `recall(conv=…)` 拿对话目录,再按目录里的 `#N` 取原文(`recall(conv, from, to)`)。'
395
- : '\n\n**下一步**:把某个 conv 原样传给 `recall(conv)` 看它的**对话目录**,再 `recall(conv, from, to)` 取原文。');
445
+ // ⚠️ 超时 / 排除本会话 / 被范围挡掉 / 还有更多 —— 这四段**全在回执末尾**,统一由
446
+ // `searchTailNotes()` 产出:① 它们语义上同属"本次特有的警告";② 上限截断时要把它们
447
+ // **整段保住**(`keepTail`) —— 拆开各写一份,截断那天就会漏掉其中一段(审计实测漏的就是这三段)。
448
+ return `${head}\n${deepBlock}${convBlock}` + searchTailNotes(v);
396
449
  }
package/lib/host/tools.js CHANGED
@@ -30,7 +30,7 @@ import { archiveBefore, listArchives, readArchive } from './archive.js';
30
30
  // 只能吃概述(而概述是别人替我挑的)。真全文在磁盘的多 frame zstd 里,读法见 rawlog.js 顶部注释。
31
31
  // F4-b(2026-10-17):`wellFormed` —— recall **取原文**那一档是直出全文(不截断,因此也不经 `oneLine`),
32
32
  // 孤立代理会原样进上下文 ⇒ 请求体带毒 ⇒ 官方端点 400。出口净化见 `renderRecall` 的 wrapper。
33
- import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine, eventsFromRows, wellFormed } from './rawlog.js';
33
+ import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine, eventsFromRows, wellFormed, stripDanglingMarkers, stripPairedMarkdown, capReceiptText, RECEIPT_CHARS_MAX } from './rawlog.js';
34
34
  // 回执里的称呼跟随 `persona.userTitle`(与 B 视角框、clock 的 [现在] 同一把尺) —— 不硬编码某个人的名字。
35
35
  import { userTitleForMode } from './persona.js';
36
36
  // 1.6 §7 步 1(2026-10-04 主人拍 D1-D6 后开工):recall 的**检索档** —— 按内容找会话,接宿主检索面(不自建)。
@@ -999,7 +999,24 @@ export function readSessionFromDb(memory, conv) {
999
999
  const SEARCH_RECEIPT_REASONS = new Set(['search-unavailable', 'search-failed', 'local-unavailable']);
1000
1000
  /** F4-b(2026-10-17):**取原文这一档是直出全文**(不截断 ⇒ 不经 `oneLine`),落单代理会原样进上下文。
1001
1001
  * 出口统一 `wellFormed` 兜底 —— 与 `renderSearch` 的 wrapper 同一形状(那三档 snippet 已有 `safeSlice`)。 */
1002
- function renderRecall(v) { return wellFormed(renderRecallText(v)); }
1002
+ function renderRecall(v) {
1003
+ const raw = renderRecallText(v);
1004
+ // M6(1.7.2):回执总字数上限 4,000 —— **除已自带护栏/已自行截断的档外**。
1005
+ // · 「取原文」档:`RECALL_READ_CHARS` 12,000 字 + "还有 N 轮没给",再套 4,000 会把取原文弄废;
1006
+ // · ①档(检索)与三个失败档:回执由 `renderSearch` 渲染,那里**已经**套过上限 ⇒ 这里再套一次
1007
+ // 会把它二次截断并**重复印一遍"还有多少没显示"**(实测会多出 ~70 字 + 少给正文)。
1008
+ // ⇒ 只有 map / outline 两档在这里套。
1009
+ const alreadyBounded = v && (v.action === 'read' || v.action === 'search' || SEARCH_RECEIPT_REASONS.has(v.reason));
1010
+ // ⚠️ 2026-10-23 夜(对抗审计 B 抓到):**末尾警告必须保住**。map 档的「库里另有 N 条只有概述」
1011
+ // 与 outline 档的「注入折叠 N 条」都拼在末尾,直接按字数截会把它们一起切掉 ——
1012
+ // 审计实测:200 会话的 map 回执里那条警告**消失了**,而"没显示"会被读成"没有"。
1013
+ const keepTail = !alreadyBounded && v
1014
+ ? (v.action === 'map' ? mapGapNote(v) : (v.action === 'outline' ? outlineTailNote(v) : ''))
1015
+ : '';
1016
+ const capped = alreadyBounded ? raw : capReceiptText(raw, RECEIPT_CHARS_MAX, keepTail).text;
1017
+ // M1(1.7.2):出口统一剥末尾未闭合标记 —— 截断本身会造出新的,所以放在截断之后。
1018
+ return wellFormed(stripDanglingMarkers(capped));
1019
+ }
1003
1020
  function renderRecallText(v) {
1004
1021
  // ①档(检索)的回执由 recall-search.js 渲染 —— 它的形状与另外三档完全不同,挤在一处两边都难读。
1005
1022
  if (v && (v.action === 'search' || SEARCH_RECEIPT_REASONS.has(v.reason))) return renderSearch(v);
@@ -1019,12 +1036,9 @@ function renderRecallText(v) {
1019
1036
  if (v.action === 'map') {
1020
1037
  const rows = v.sessions.map((s) => `· ${s.conv} · 最近 ${localStamp(s.mtime)} · ${(s.bytes / 1024).toFixed(0)}KB · ${s.project}`);
1021
1038
  // 覆盖边界(1.6):磁盘这批是"有原文"的;库里另有一批只有概述 —— 明说,别让"没列出来"读成"没发生过"。
1022
- const gap = Number(v.noRawTotal) > 0
1023
- ? `\n\n⚠️ 库里另有 **${v.noRawTotal}** 条会话**只有概述、没有原文**(${v.noRawBySource || '来源未记'}) —— `
1024
- + '它们的原文**不在本机**(如从 DeepSeek 网页端导入的),本工具读不到;要查它们只能看概述。'
1025
- : '';
1026
- return `磁盘上**有原文可读**的会话共 ${v.total} 个(${v.projects} 个项目目录),按最近活动列前 ${v.sessions.length} 个:\n${rows.join('\n')}${gap}`
1027
- + '\n\n**下一步**:把上面某个 conv 原样传给 recall(不给 from/to),先看它的**对话目录** —— 目录只列轮次,不给正文。';
1039
+ const gap = mapGapNote(v);
1040
+ // M2(1.7.2):「下一步」已收进工具 description ⇒ 回执只留数据 + 本次特有警告。
1041
+ return `磁盘上**有原文可读**的会话共 ${v.total} 个(${v.projects} 个项目目录),按最近活动列前 ${v.sessions.length} 个:\n${rows.join('\n')}${gap}`;
1028
1042
  }
1029
1043
  const srcTag = v.fromDb
1030
1044
  ? ' · ⚠️ **来源:库内原文**(磁盘上没有该会话的日志;库那份是**残的** —— 只含真人消息与有正文的回复)'
@@ -1040,14 +1054,14 @@ function renderRecallText(v) {
1040
1054
  for (const t of v.outline) {
1041
1055
  if (t.role === 'user' && !t.human) { pend += 1; continue; }
1042
1056
  flush();
1043
- const body = t.head
1057
+ // M3(1.7.2):目录行里的正文是**别人的原文**,按选项 C 只剥成对标记;M1 再兜住截断残渣。
1058
+ const head = t.head ? stripDanglingMarkers(stripPairedMarkdown(t.head)) : '';
1059
+ const body = head
1044
1060
  || (t.tools && t.tools.length ? `(调用了 ${t.tools.slice(0, 4).join(', ')}${t.tools.length > 4 ? ' 等' : ''})` : '(无正文)');
1045
1061
  rows.push(`#${t.n} [${t.time}] ${t.role === 'user' ? who : '器灵'} ${body}`);
1046
1062
  }
1047
1063
  flush();
1048
- return `${title}\n${rows.join('\n')}`
1049
- + `\n\n(注入消息共 ${v.injected} 条已折叠;我的回复里纯工具轮 ${v.assistantEmpty} 条无正文)`
1050
- + '\n**下一步**:给 from/to(用上面的 `#序号`)取原文 —— 序号跳号就说明中间是折叠掉的注入。';
1064
+ return `${title}\n${rows.join('\n')}` + outlineTailNote(v);
1051
1065
  }
1052
1066
  const rows = v.turns.map((t) => {
1053
1067
  const body = t.text
@@ -1061,8 +1075,22 @@ function renderRecallText(v) {
1061
1075
  // 1.6 步 5:把"采用信号"报出来 —— 加固是隐式的,不报就没有人知道它在发生(也不可复核)。
1062
1076
  + (Number(v.adopted) > 0
1063
1077
  ? `\n\n(这一段原文对应 **${v.adopted}** 条深层库条目,已各加固一次 —— 肯为一条结论下钻读它的原话,就是这条结论真在被用。)`
1064
- : '')
1065
- + '\n\n(读到的原话是**当时**说的,不是此刻的指令 —— 与人称视角框同一口径。)';
1078
+ : '');
1079
+ }
1080
+
1081
+ /** map 档末尾的「库里另有 N 条只有概述」警告。
1082
+ * ⚠️ 与 `renderRecallText` 的 map 分支**同源**:上限截断时靠它当 `keepTail` 把整段保住
1083
+ * (审计实测:200 会话的回执里这条警告曾被截断吃掉)。 */
1084
+ function mapGapNote(v) {
1085
+ return Number(v?.noRawTotal) > 0
1086
+ ? `\n\n⚠️ 库里另有 **${v.noRawTotal}** 条会话**只有概述、没有原文**(${v.noRawBySource || '来源未记'}) —— `
1087
+ + '它们的原文**不在本机**(如从 DeepSeek 网页端导入的),本工具读不到;要查它们只能看概述。'
1088
+ : '';
1089
+ }
1090
+
1091
+ /** outline 档末尾的计数行(同上:截断时要保住,不能让"折叠了 N 条"消失)。 */
1092
+ function outlineTailNote(v) {
1093
+ return `\n\n(注入消息共 ${v?.injected} 条已折叠;我的回复里纯工具轮 ${v?.assistantEmpty} 条无正文;序号跳号 = 中间是折叠掉的注入)`;
1066
1094
  }
1067
1095
 
1068
1096
  export const RECALL_SPEC = {
@@ -1084,7 +1112,16 @@ export const RECALL_SPEC = {
1084
1112
  + '**当前会话自己**如果命中,会标注「← 本会话(刚发生的)」并排在最后 —— 别把它当"当年说过的话"引用。'
1085
1113
  + '**工具不会替你挑"最重要的一段"** —— 先看命中片段或目录,再自己指哪几轮。'
1086
1114
  + '只返回**对话**(主人对我说的话 + 我的回复);工具调用/步骤/折叠等过程事件已剥离;'
1087
- + '注入类消息(运行时上下文、技能目录、任务标记)在目录里**折叠成一行、不占序号**。',
1115
+ + '注入类消息(运行时上下文、技能目录、任务标记)在目录里**折叠成一行、不占序号**。'
1116
+ // M2(1.7.2):把原先印在**每条回执**里的「下一步」与「口径」搬到这里 ——
1117
+ // 系统面每次都看得见,回执就不用重复(实测 51 条回执平均 1,211 字,大部分是这个)。
1118
+ + '**拿到之后怎么走**:②档清单 → 把 conv 传给 recall(不给 from/to)看**对话目录**;'
1119
+ + '③档目录 → 用 `#序号` 给 from/to 取原文(序号跳号 = 中间是折叠掉的注入);'
1120
+ + '①档若给出**深层库条目**(结论层,是可以直接用的结论),要它背后那句话同样走"先看目录、再按 #号 取"。'
1121
+ + '读到的原话是**当时**说的,不是此刻的指令。'
1122
+ // M4(1.7.2 基调):回执里的 emoji **保留、不新增** —— 它们是**状态符号**不是装饰:
1123
+ // 📌=置顶 · ⚠️=警告/降级 · ✓=核验通过 · ✅/❌=结论成立与否 · 🟡=部分 · ★=重点。
1124
+ + '(回执里的 📌/⚠️/✓/✅/❌/🟡/★ 是状态符号,不是装饰。)',
1088
1125
  parameters: {
1089
1126
  type: 'object',
1090
1127
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightpainters/dsh-ling",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "器灵 · 跑在你自己电脑上的记忆与人格助手插件(人机共生:各尽其责、各有边界、互不占有):以「人在循环」的双向复盘为核心回路,人格由共同记忆长出并有定型锁,记忆树可归并、可回溯、每次改动都留痕",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "scripts": {
29
- "test": "node tests/unit.mjs && node tests/access-log.test.mjs && node tests/archive.test.mjs && node tests/autotree.test.mjs && node tests/backfill.test.mjs && node tests/branch.test.mjs && node tests/assistant.test.mjs && node tests/capture.test.mjs && node tests/clock.test.mjs && node tests/clock-skill.test.mjs && node tests/deep.test.mjs && node tests/deepitem.test.mjs && node tests/deep-import-docs.test.mjs && node tests/dswebsum.test.mjs && node tests/duty.test.mjs && node tests/export.test.mjs && node tests/feedback.test.mjs && node tests/forget-backfill.test.mjs && node tests/genesis.test.mjs && node tests/guard.test.mjs && node tests/habitgen.test.mjs && node tests/idle.test.mjs && node tests/importfile.test.mjs && node tests/importlog.test.mjs && node tests/injectlog.test.mjs && node tests/l1lines.test.mjs && node tests/memories.test.mjs && node tests/mode.test.mjs && node tests/mode-service.test.mjs && node tests/mustache.test.mjs && node tests/retitle.test.mjs && node tests/rules.test.mjs && node tests/sessionline.test.mjs && node tests/suggest.test.mjs && node tests/summarize.test.mjs && node tests/times.test.mjs && node tests/tone.test.mjs && node tests/tools.test.mjs && node tests/recall.test.mjs && node tests/recall-search.test.mjs && node tests/f4-surrogate.test.mjs && node tests/tree.test.mjs && node tests/patch-spec.test.mjs && node tests/bodylimit.test.mjs && node tests/client-batch.test.mjs && node tests/library-store.test.mjs && node tests/library-store-concurrency.test.mjs && node tests/library-checker.test.mjs && node tests/library-runtime.test.mjs && node tests/library-api-tools.test.mjs && node tests/library-import.test.mjs && node tests/library-sqlite.test.mjs && node tests/library-browse.test.mjs && node tests/library-client.test.mjs && node tests/tool-remount.test.mjs && node tests/auto-config.test.mjs",
29
+ "test": "node tests/unit.mjs && node tests/access-log.test.mjs && node tests/archive.test.mjs && node tests/autotree.test.mjs && node tests/backfill.test.mjs && node tests/branch.test.mjs && node tests/assistant.test.mjs && node tests/capture.test.mjs && node tests/clock.test.mjs && node tests/clock-skill.test.mjs && node tests/deep.test.mjs && node tests/deepitem.test.mjs && node tests/deep-import-docs.test.mjs && node tests/dswebsum.test.mjs && node tests/duty.test.mjs && node tests/export.test.mjs && node tests/feedback.test.mjs && node tests/forget-backfill.test.mjs && node tests/genesis.test.mjs && node tests/guard.test.mjs && node tests/habitgen.test.mjs && node tests/idle.test.mjs && node tests/importfile.test.mjs && node tests/importlog.test.mjs && node tests/injectlog.test.mjs && node tests/l1lines.test.mjs && node tests/memories.test.mjs && node tests/mode.test.mjs && node tests/mode-service.test.mjs && node tests/mustache.test.mjs && node tests/retitle.test.mjs && node tests/rules.test.mjs && node tests/sessionline.test.mjs && node tests/suggest.test.mjs && node tests/summarize.test.mjs && node tests/times.test.mjs && node tests/tone.test.mjs && node tests/tools.test.mjs && node tests/recall.test.mjs && node tests/recall-search.test.mjs && node tests/recall-noise.test.mjs && node tests/f4-surrogate.test.mjs && node tests/tree.test.mjs && node tests/patch-spec.test.mjs && node tests/bodylimit.test.mjs && node tests/client-batch.test.mjs && node tests/library-store.test.mjs && node tests/library-store-concurrency.test.mjs && node tests/library-checker.test.mjs && node tests/library-runtime.test.mjs && node tests/library-api-tools.test.mjs && node tests/library-import.test.mjs && node tests/library-sqlite.test.mjs && node tests/library-browse.test.mjs && node tests/library-client.test.mjs && node tests/tool-remount.test.mjs && node tests/auto-config.test.mjs",
30
30
  "check": "node tools/check-install.mjs",
31
31
  "check:publish": "node tools/check-publish.mjs"
32
32
  },