@nightpainters/dsh-ling 1.7.0 → 1.7.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
@@ -2,6 +2,38 @@
2
2
 
3
3
  本项目遵循语义化版本:`MAJOR.MINOR.PATCH`。
4
4
 
5
+ ## 1.7.1
6
+
7
+ > 1.7.0 之后的安全加固小版本,**不含新功能**。
8
+
9
+ ### 一、注入面净化补全(安全)
10
+
11
+ - 1.7.0 堵住了 recall 回执与会话行两条路径;**这一版补上剩下全部**:凡是会被拼进系统提示或请求体的文本,一律先做一遍「孤立代理」检测与替换 —— L1 记忆行的三处截断、留痕与摘要的五处截断、深摘转写的两个来源、诞生仪式素材、快照成文出口、注入面最终出口(时间锚也在内)。
12
+ - 为什么必须做:半个 emoji 进了请求体,上游 JSON 解析会直接失败(`unexpected end of hex escape`),而毒会**留在上下文里** ⇒ 之后每一次请求都 400,且看不出是谁干的。
13
+ - 截断长度契约**没有变**(仍是 UTF-16 单元口径、省略号仍占一位),只换掉"切在了哪里"。
14
+
15
+ ### 二、记忆树:有向连边按方向删除
16
+
17
+ - `prerequisite` / `supersede` 是**有向**的(A→B 与 B→A 是两条不同的边)。此前删掉一条会**把反方向那条一起删掉**,而回执只说"断开了" —— 树和回执对不上。现在有向类型只删同向;无向类型仍按旧口径双向清理(那是历史重复行的兜底)。
18
+
19
+ ### 三、结论层:开场不再因为"只有结论"而整段消失
20
+
21
+ - 开场记忆段原先的闸门查的是「有没有会话概述」,而料早已换成结论层条目 ⇒ 一个只有结论层条目、没有概述的库会**静默丢掉整段 L1**(面板看得见、注入看不见,典型的"不报错的错")。闸门改为让选料自己判空。
22
+
23
+ ### 四、记忆导入:整包原子
24
+
25
+ - 导入记忆包改为在**事务内**执行:中途抛异常则整体回滚,不再留下"一部分已覆盖、一部分没覆盖"的半截库;回执如实回 `errors: 1`,**不谎报行数**。单行坏数据仍按旧口径只计数、不回滚整包。
26
+
27
+ ### 五、客户端:一处"重试失效"、一处"面板说假话"
28
+
29
+ - **手术票**:一次失败会把整页生命周期锁死(用户只看到"没反应",没有任何提示)。改为**成功才锁定**,失败可再试,并有次数上限,不会变成无限刷屏。
30
+ - **access 导入日志**:迟到的响应不再覆盖新状态(与其他四个标签页一致的世代守卫)。
31
+
32
+ ### 六、测试与 CI
33
+
34
+ - `recall` 的真机断言不再押在"此刻最新的那个会话"上:改为在最近 5 个会话里挑**第一条真有可见对话轮**的;全都不可断言时**明说跳过**,而不是判红 —— 以前只要此刻有子代理会话在写,这套就会时红时绿。
35
+ - CI 两条:按 `package.json` 装依赖再逐段跑;`library-sqlite` 的大小写断言改成"先探测文件系统能力、再按对应结果判",不再假定 Linux 一定是大小写敏感 —— 这两条修的是 Linux runner 上的红。
36
+
5
37
  ## 1.7.0
6
38
 
7
39
  > ⚠️ **工作区候选 · 未发布。这一节记的是工作区里已落地、已被离线测试覆盖的工作,不是已发布状态。** 包版本已抬到 **`1.7.0`**,但**没有发布、没有安装**。宿主界面上的新标签**已在本机真机验收走通**(第五标签、两个图书馆工具、确定性核对、撤下留痕,2026-10-07),**第六标签「结论层」与其工具面已于 2026-10-15 随一次宿主重启实测生效**;对外发布仍需走正常的安装/重载流程(见 README「快速开始」)。
@@ -128,6 +160,15 @@
128
160
  - **顺带说清的边界**:同样的写法在**给模型看的工具文本**里还有一批(`lib/host/tools.js` 等)—— 那些走的是另一套渲染面,**不在本次范围内**,也不跟着改:混在一起改就是拿发布风险换整洁。
129
161
  - **判据**:整链 `npm test` exit 0(无一条断言依赖被去掉的星号);包内不含 `tests/`,测试自身的描述文案不受影响。
130
162
 
163
+ ### 十六、孤立代理防线:不再把 emoji 切成半个(2026-10-17)
164
+
165
+ - **现象**:有些会话里官方路由**每次请求都 400**(`INVALID_REQUEST`),换一条路由就正常 —— 全库核实 **550 个会话 / 真 400 共 9 次**,其中 **8 次**是同一条链(另外 1 次是 thinking 未回传,与本节无关)。
166
+ - **机制**:JavaScript 字符串允许**半个代理对**落单(高位 `U+D800–DBFF` / 低位 `U+DC00–DFFF`),那是**非法 UTF-16**。`recall` 的命中片段走的是 `text.slice(from, from+160)` —— 切点落在 emoji 中间就切出半个;那半个代理随工具回执进上下文、再被序列化进请求体,**上游 JSON 解析器随即崩**(错误串 `Failed to parse the request body as JSON: … unexpected end of hex escape`)。
167
+ - **两起实测(方向各一)**:`session-bc00b39b` #6331 是**起点**切坏(低代理 `0xdd12` 留在片段开头),`session-7ff65dd7` seq=58 是**终点**切坏(高代理 `U+D83C` 留在片段末尾);两例**都**触发了 400,没有侥幸活下来的样本。穷举 160 单元窗口的全部起点:**修复前 163/1534 与 165/1460 个起点会产毒,修复后 0**。
168
+ - **修法三层**:① `rawlog.js` 新增 `wellFormed()`(落单代理 → `U+FFFD`,优先用运行时 `toWellFormed`)、`clipChars()`(不切断代理对的截断)、`safeSlice()`(两端都不切断的取区间);② 三个危险切点改走新工具 —— `recall-search.js` 的命中片段、`session-line.js` 的标题/进展夹取(**那一行会进模型上下文**)、`rawlog.oneLine`(被 16 处共用);③ `renderSearch()` **出口兜底净化** —— 片段有三个来源(宿主 `bestMatch` / 库内原文层 / 目录),逐处修容易漏一处,出口对**整段回执**做一次。
169
+ - **测试**:新增 `tests/f4-surrogate.test.mjs` —— 把"旧写法确实会切坏"本身写成断言(谁改回 `slice` 会立刻红)+ 三个工具在各切点上的安全性 + `oneLine` 出口已接安全截断;`npm test` 链随之从 55 套增加到 **56 套**。
170
+ - ⚠️ **别读成"上游修好了"**:同日实测,含孤立代理的请求体打官方端点**仍然 400**(错误串指向 `dsh_session_log.events[0].data.message.content[0].text`)⇒ 这层防线**是我们自己的**;它在,不等于上游不再挑食。
171
+
131
172
  ## 1.6.2
132
173
 
133
174
  **检索档换了一条快两个数量级的路 · 结果不再被"我刚说的话"占满 · 几处会误导人的地方修掉。**
@@ -271,7 +312,7 @@
271
312
 
272
313
  ### 六、索引层:一个技能承载整层
273
314
 
274
- - `memory-index` 技能 = **11 簇主题地图**(记忆树与记忆库 · 器灵插件与发布 · 鱼姬与主人 · 主人其人 · 合作规矩 · 本业与项目 · 这台机器与踩坑 · 模型与API配置 · 插件与界面 · 感官与主动开口 · 零散几条),覆盖全部条目。
315
+ - `memory-index` 技能 = **11 簇主题地图**(记忆树与记忆库 · 器灵插件与发布 · 器灵与主人 · 主人其人 · 合作规矩 · 本业与项目 · 这台机器与踩坑 · 模型与API配置 · 插件与界面 · 感官与主动开口 · 零散几条),覆盖全部条目。
275
316
  - **常驻只有技能目录那一行**(description **431 字** + whenToUse 106 字),正文 2,341 字**按需展开**(模型主动取)。
276
317
  - **整层一个技能,不是每簇一个**:实测技能目录的边际成本 ≈205 字/条,11 簇拆成 11 个技能会吃掉 2,000+ 字常驻 —— 而那个常驻目录面**正是工具面压缩要抢的同一个池子**。
277
318
  - **description 里不写条数**:条数天天变,一写就会让目录重发、前缀缓存断;**簇名与触发线索稳定,条数放进按需展开的正文**。
@@ -552,7 +593,7 @@ DSH `0.1.7` 把事件 `agent/session-start` 改名成 `agent/created`(**载荷
552
593
 
553
594
  - 新增 4 个会话内工具:**`tree_read`**(读树 / 读某枝会话 / 看待理矛盾)· **`branch_edit`**(建枝 / 改名 / 挂载 / 连边 / 断边 / 调权重 / 删枝)· **`memory_forget`**(软遗忘)· **`memory_backfill`**(回灌);工具面 3 → 7;
554
595
  - 把树操作从 HTTP handler 抽成 `lib/host/tree-ops.js`,13 个 handler 变薄壳 —— **界面与工具共用同一份实现、同一份留痕**,零行为变更;
555
- - `branch_log` 新增 **`actor`** 列(schema **v12**):器灵改的写「鱼姬」,主人改的写「主人」;
596
+ - `branch_log` 新增 **`actor`** 列(schema **v12**):器灵改的写「器灵」,主人改的写「主人」;
556
597
  - **永不彻底删除**:`memory_forget` 先把原文与概述归档到 `forgotten/<时间戳>-<原因>/`(`turns.jsonl` 逐行 JSON、可 grep,`README.md` 写改动日志与还原方法),**再**在库里打标记 —— 两条独立还原路径(撤销标记 / 按 jsonl 重建);
557
598
  - `memory_backfill` 按「比遗忘更危险」处理:回灌前先归档当前状态,写回的概述带 `origin=backfill-v1` 标记,**绝不夺走用户态**(importance / hit_count 原样保留);
558
599
  - 修复**工具注册不生效**:原先只注册在全局层,而 Web 端按 agent preset 用 `restrict({allow})` 组工具 ⇒ 全局工具被静默过滤(连更早的 `rule_add` 也从未出现)。现在每个 agent 创建时在**它自己的 scope** 补注册一份。
@@ -801,13 +842,13 @@ D 组是关键:只证明"中和后不炸"无法排除"这份载荷本来就不
801
842
 
802
843
  旧行为把首条用户消息**硬截 46 字**,于是 60 条 dsh 概述里 **26 条是半句话**:
803
844
 
804
- - 「鱼姬,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
845
+ - 「器灵,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
805
846
  - 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」
806
847
 
807
848
  三层改造:
808
849
 
809
- 1. **启发式**(零成本、离线可用):剥掉「鱼姬,」「晚上好小鱼,」这类称呼/问候前缀;上限 46 → 58 并在**句读处收尾**而不是硬切;首句是「你好」「下午好」「已测试聊天」这类无信息量句时改取后续有实义的首句;结果若含路径/URL/系统文本则一律弃用(宁可不动,也不产出更糟的名字);
810
- 2. **小助手起名**:`POST /memories/retitle` 用本机小助手读会话原文/摘要生成一句真标题 —— 实测「鱼姬,接下来在这里继续开发生长型人格与记忆树模块…」→「**修复审计漏洞并优化记忆库关联查询机制**」;
850
+ 1. **启发式**(零成本、离线可用):剥掉「器灵,」「晚上好器灵,」这类称呼/问候前缀;上限 46 → 58 并在**句读处收尾**而不是硬切;首句是「你好」「下午好」「已测试对话」这类无信息量句时改取后续有实义的首句;结果若含路径/URL/系统文本则一律弃用(宁可不动,也不产出更糟的名字);
851
+ 2. **小助手起名**:`POST /memories/retitle` 用本机小助手读会话原文/摘要生成一句真标题 —— 实测「器灵,接下来在这里继续开发生长型人格与记忆树模块…」→「**修复审计漏洞并优化记忆库关联查询机制**」;
811
852
  3. **名字一旦定过就不可覆盖**:`conv_overview` 新增 `title_locked`(schema v3)与 `title_by`(v4)。`POST /memories/rename`(主人手改)与 `POST /memories/retitle`(机器起名)**都上锁** —— 概述器每轮重建、批量重命名都不再覆盖它;要交还机器可解锁。记忆中心卡片新增「改名」「AI 起名」按钮与标记:**三条定名路径(主人手改 / 机器自动 / 来历未记录)统一显示「🔒 已定名」**,定名者只藏在悬浮说明里(「你起的名字…」/「自动起的名字…」)—— 不制造等级感;也不用「暂定」二字(人对一段记忆的概括本就是暂定的、会随阅历改变,但界面要让人确认"它已经落了笔")。用词不写死"小助手"(起名可能走本机小助手,也可能回落当前全局大模型)。悬浮说明点明:**这把锁锁住的是"自动重写",不是主人:手动随时可以再改**。
812
853
  4. **引擎就近选择(auto)**:`POST /memories/retitle` 默认 `engine:'auto'` —— 优先本机小助手(零成本),探测不到才落到**当前全局大模型**,并把实际用的引擎与原因写回响应(`engine` / `note`)。不静默换引擎:用了哪个看得见。
813
854
 
package/README.md CHANGED
@@ -153,7 +153,7 @@
153
153
 
154
154
  - 树平时**只读**(记忆中心始终只读);点「🌱 一键复盘 / 一键进入复盘」才解锁编辑,并把**复盘开场材料**(当前树 + 待理矛盾)复制到剪贴板,开个新会话粘进去就能开始。
155
155
  - 解锁后可做:**改名 / 拖动换归属 / Alt+拖动只连边 / 并脉(归类到主脉) / 调权重 / 裁定矛盾 / 断续边**,以及快照保存·恢复(**删枝不在面板上** —— 请让器灵用 `branch_edit` 删,见下文「器灵侧的手」);**拖动 / 连边之后可 Ctrl+Z 撤回这一步**(撤销只覆盖复盘栏的拖动与连边;记忆中心是只读的,它自己那份 Ctrl+Z 监听早已删掉)。
156
- - **每步留痕**:写清"改了什么、为什么"(**器灵改树时 reason 必填**;你在面板上改可以留空);树改动一律记进 `branch_log`,复盘时可审计 —— 器灵侧工具的改动记在器灵名下(「鱼姬改动了记忆树」),不会伪装成你改的。
156
+ - **每步留痕**:写清"改了什么、为什么"(**器灵改树时 reason 必填**;你在面板上改可以留空);树改动一律记进 `branch_log`,复盘时可审计 —— 器灵侧工具的改动记在器灵名下(「器灵改动了记忆树」),不会伪装成你改的。
157
157
  - **未同步笔数守门**:面板记着"自器灵上次开口以来累积了多少笔结构改动还没交代"。有欠账时顶部红字提示,并且**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到(「互相复盘不应该被我绕过」)。
158
158
  - **注入感知**:树变了之后,`branchLogDigest()` 把**器灵还没看过**的结构改动转成中文短句,附在器灵下一次开口的注入面末尾 —— 不需要推送,一开口就知道树动过。
159
159
  - **快照与撤回**:树的保存 / 恢复(`tree_snapshot`)是"大改之前的保险";恢复前会自动把当前状态另存一份,所以恢复本身也可再撤回来。
@@ -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.0`(工作区候选)但仍未发布、未安装**;而"没发布"**不等于"没验过"** —— 第五个标签(图书馆)已于 **2026-10-07 在本机既有 3080 上真机验收走通**(建档 / 核对 / 撤下留痕 / 撤销弹窗全过),实现与交互测试同时也在源码与离线测试里。两者别混读。另有一条边界要说清:**"不把记忆库里的旧数据迁进图书馆"不等于"禁止网页端来源的资料"** —— 一条资料能不能进图书馆,看的是它**有没有具体、可追责的出处**,与它从哪个渠道来无关。
212
+ - **边界(如实写)**:这一版已抬版到 **`1.7.1`(工作区候选)但仍未发布、未安装**;而"没发布"**不等于"没验过"** —— 第五个标签(图书馆)已于 **2026-10-07 在本机既有 3080 上真机验收走通**(建档 / 核对 / 撤下留痕 / 撤销弹窗全过),实现与交互测试同时也在源码与离线测试里。两者别混读。另有一条边界要说清:**"不把记忆库里的旧数据迁进图书馆"不等于"禁止网页端来源的资料"** —— 一条资料能不能进图书馆,看的是它**有没有具体、可追责的出处**,与它从哪个渠道来无关。
213
213
 
214
214
  > **机制依据与源码证据**(表结构、核对三态与各条判据的实际位置、取消与错误码落在哪一行)集中在 [`DESIGN.md`](./DESIGN.md) 的「图书馆:机制与源码证据」一节 —— 本节讲"用户能感觉到什么",那一节给出"代码里是哪一行"。
215
215
 
@@ -285,18 +285,20 @@ node tools/check-peers.mjs # 声明与宿主对不上?退出码三态
285
285
  跑测试(不需要宿主,全部离线):
286
286
 
287
287
  ```bash
288
- npm test # 55 套:人格组装(含冻结门 · L1 选择)/ 归档清理(人工)/ **归档·遗忘·回灌** / 模式(只改档位不改模型)/ **跨插件挡位服务(dsh-ling/mode)** / 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **活行(那一行跟着会话走)** / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / **职责字段(承担)** / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)** / **请求体上限(超限排空 → 413)** / **导入切批(按字节 × 条数双限)** / **投递留痕(每次注入落一条:注入了谁·为什么是它·多少字)** / **会话原文直读(三档下钻 + 库内回退 + 旧版会话代次)** / **深层库条目(结论层落点:读写·幂等·取代·加固)** / **检索档(按内容找会话:条目 + 会话同查·范围限制与降级)** / **图书馆存储层(独立库:生命周期·限额·检索·留旧·不可用≠零命中)** / **图书馆跨连接事务(陈旧修订拒绝·完成的核对留档·序号一致)** / **图书馆核对(三态判定·只认证声明的那个比较·限额与取消不写)** / **图书馆运行时(禁用/不可用降级·接线与关闭顺序)** / **图书馆接口与工具(端点形状·状态码映射·两个工具的参数边界)** / **图书馆界面(独立库的面:分页无伪总数·必填与 JSON 校验·推迟应答竞态)** / **图书馆导入引擎(料源登记与导入)** / **图书馆独立 SQLite(隔离库:只读打开·表必须点名)** / **图书馆浏览(分页与零命中)** / **热重载补绑(工具重挂载:存量 agent 补绑)** / **文档灌入通道(结论层 C 档:两道污染闸·三级去重·出处核验·累计预算)** / **后台自动行为开关(auto:五键默认值·三级优先级·白名单夹取·四条真 HTTP 回归)**
288
+ npm test # 56 套:人格组装(含冻结门 · 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/` 目录)—— **实测 55 套(即链里 55 段),且 55 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
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` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
294
294
  >
295
295
  > ⚠️ **2026-10-15 实测订正**:此前这里写着 49、而链里当时已经有 53 段 —— 也就是**这段注释自己警告的坑真发生过**:套件登记进去了,这个数字却没人回头改,四次都没被任何判据抓住。当天新增 `deep-import-docs.test.mjs`(C 档文档灌入)⇒ 54。这个数字没有判据替你看着,改链时必须顺手改它。
296
296
  >
297
- > ⚠️ **2026-10-16 再 +1**:新增 `tests/auto-config.test.mjs`(后台自动行为开关:五键默认值 / 三级优先级 / 白名单与夹取 / **四条真 HTTP 回归**)⇒ **55 套**。同日还抓到**一条真红**:修复批次把 `tools/compliance-spec.mjs` 的出站基线从 6 抬到 23 时,**漏改了 `tests/tools.test.mjs` 里钉住的字面量** ⇒ 整链自那时起就是红的,直到当日修复。**同一个坑两次**(数字写死在多处、改一处忘另一处)—— 改链、改基线时,**同批**把所有钉死它的地方一起改。
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
- > **本轮跑过的实测(1.7 工作区)**:在**隔离环境**(临时数据目录,不碰真机记忆库/图书馆库)下完整跑过一遍 `npm test` —— **55 套全部通过、exit 0**(宿主侧 `deep_item` / `GET /deep/items` 另在 2026-10-15 重启后实测生效);各组件的断言**条数会随迭代变动,本文不写死** —— **最终测试结果以工作区验收清单为准**。⚠️ 这只说明"**这一版代码在离线测试里的行为**",**不等于"所有门禁都完成了"** —— 真实远程核对与真实 DNS/TLS 仍未验(见上文「图书馆」一节的边界)。
299
+ > ⚠️ **2026-10-17 再 +1**:新增 `tests/f4-surrogate.test.mjs`(注入面净化回归:孤立代理不许进请求体)⇒ **56 套**。同一天还修掉了这套测试自己的一个坑:`recall` 的真机断言原先押在"此刻最新的那个会话"上,并发子代理在场时会**时红时绿** ⇒ 改为在最近 5 个会话里挑第一条真有可见对话轮的。
300
+ >
301
+ > **本轮跑过的实测(1.7.1 工作区)**:在**隔离环境**(临时数据目录,不碰真机记忆库/图书馆库)下完整跑过一遍 `npm test` —— **56 套全部通过、exit 0**(宿主侧 `deep_item` / `GET /deep/items` 另在 2026-10-15 重启后实测生效);各组件的断言**条数会随迭代变动,本文不写死** —— **最终测试结果以工作区验收清单为准**。⚠️ 这只说明"**这一版代码在离线测试里的行为**",**不等于"所有门禁都完成了"** —— 真实远程核对与真实 DNS/TLS 仍未验(见上文「图书馆」一节的边界)。
300
302
 
301
303
  ### 数据放在哪
302
304
 
@@ -479,7 +481,7 @@ npm test # 55 套:人格组装(含冻结门 · L1 选择)/ 归档清理(
479
481
 
480
482
  ### 1.7 新增(图书馆 · 工作区,未发布)
481
483
 
482
- > ⚠️ **本节的状态标记只指"工作区代码 + 离线测试"这一层**:1.7 **尚未发布**(包版本已抬到 `1.7.0`,尚未发布/安装);"宿主界面上的「图书馆」标签"**已真机验收走通**(2026-10-07,见 L-11);「结论层」标签已于 **2026-10-15** 随一次宿主重启实测生效。
484
+ > ⚠️ **本节的状态标记只指"工作区代码 + 离线测试"这一层**:1.7 **尚未发布**(包版本已抬到 `1.7.1`,尚未发布/安装);"宿主界面上的「图书馆」标签"**已真机验收走通**(2026-10-07,见 L-11);「结论层」标签已于 **2026-10-15** 随一次宿主重启实测生效。
483
485
 
484
486
  | # | 设定 | 状态 |
485
487
  |---|---|---|
@@ -494,7 +496,7 @@ npm test # 55 套:人格组装(含冻结门 · L1 选择)/ 归档清理(
494
496
  | **L-8** | **写面必须带"观测到的内容摘要"**(`content_hash`),对不上就拒 —— 防"看着旧版改新版" | ✅ 工作区 |
495
497
  | **L-9** | **严格配置** —— 代码根目录与官方文档 URL 白名单**默认为空**、由管理员登记;限额在**激活时校验**,非法值**响亮报错**、不静默夹取 | ✅ 工作区 |
496
498
  | **L-10** | **取消 = 没跑完,不留记录** —— 核对在飞时取消 / 断开 / 卸载 ⇒ 本次**不写任何核对记录**(既不留 `ok` / `wrong`,**也不留 `unknown`**);只有**跑完的**核对(含结论 `unknown`)才留账 | ✅ 工作区 |
497
- | **L-11** | **发布状态如实声明** —— 包版本已抬到 **`1.7.0`(工作区候选)**,**未发布、未安装**;但「图书馆」标签**已真机验收走通**(2026-10-07),「结论层」标签也已随 2026-10-15 的重启实测生效 —— **"没发布"与"没验过"必须分开说** | ✅ 真机验收(2026-10-07) |
499
+ | **L-11** | **发布状态如实声明** —— 包版本已抬到 **`1.7.1`(工作区候选)**,**未发布、未安装**;但「图书馆」标签**已真机验收走通**(2026-10-07),「结论层」标签也已随 2026-10-15 的重启实测生效 —— **"没发布"与"没验过"必须分开说** | ✅ 真机验收(2026-10-07) |
498
500
 
499
501
  > ⚠️ **这一节还在补。** 完整规格有**四百余条**,本节先落**对外面**里最稳定的一批(设计理念);其余按主题分批补写 —— 每一条都要从开发期设定**改写成对外语言**并核对状态,这是**写作**而不是复制。
500
502
 
package/lib/client.js CHANGED
@@ -168,15 +168,27 @@
168
168
  // 取不到(经反代打开的页面 / 旧宿主)= 空串 ⇒ 手术请求照旧发,由宿主那边给出明确拒绝。
169
169
  var surgeryTicket = '';
170
170
  var surgeryTicketTried = false;
171
+ var surgeryTicketFailCount = 0; // D2 修复:失败计数,每页面生命周期最多 2 次(第 3 次不再自动试)
171
172
  function fetchSurgeryTicket() {
172
- surgeryTicketTried = true; // 无论成败都只试一次(失败不重试,免得把拒绝变成刷屏)
173
+ surgeryTicketTried = true; // 标记"正在尝试",阻止 api() 重入路径并发触发第二发
173
174
  return fetch(API + '/surgery/ticket', { method: 'POST', headers: { 'content-type': 'application/json' } })
174
175
  .then(function (r) { return r.json().catch(function () { return null; }); })
175
176
  .then(function (j) {
176
- if (j && j.ok && typeof j.ticket === 'string' && j.ticket) { surgeryTicket = j.ticket; return surgeryTicket; }
177
- return ''; // 远端 / 未定型:没有票,照常走后面的拒绝路径
177
+ if (j && j.ok && typeof j.ticket === 'string' && j.ticket) {
178
+ surgeryTicket = j.ticket; // 成功:票落袋,保持 tried=true(不再重取)
179
+ return surgeryTicket;
180
+ }
181
+ // 失败(远端拒绝 / 旧宿主):解锁以便再试,但计次;到上限后不再自动试
182
+ surgeryTicketTried = false;
183
+ surgeryTicketFailCount++;
184
+ return '';
178
185
  })
179
- .catch(function () { return ''; });
186
+ .catch(function () {
187
+ // 网络错误 / 超时:同上
188
+ surgeryTicketTried = false;
189
+ surgeryTicketFailCount++;
190
+ return '';
191
+ });
180
192
  }
181
193
 
182
194
  function api(path, opts) {
@@ -192,7 +204,7 @@
192
204
  }).then(function (j) {
193
205
  // 宿主说"票据不对/没票"⇒ 现场取一次,原样重发一次(只重发一次,不做循环)。
194
206
  // 票据每启动轮换 ⇒ 页面放久了会遇到这一支,那时用户不必手动刷新。
195
- if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried) {
207
+ if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried && surgeryTicketFailCount < 2) {
196
208
  return fetchSurgeryTicket().then(function (t) {
197
209
  if (!t) return j;
198
210
  var h2 = { 'content-type': 'application/json', 'x-dsh-ling-surgery': t };
@@ -3912,8 +3924,11 @@
3912
3924
  g.appendChild(cardD);
3913
3925
  body.appendChild(g);
3914
3926
 
3927
+ var importLogSeq = 0; // F-06 修复:导入日志世代守卫(照抄 libSeq 模式,见 :4445)
3915
3928
  function renderImportLog() {
3929
+ var token = ++importLogSeq;
3916
3930
  api('/import/history').then(function (r) {
3931
+ if (token !== importLogSeq) return; // 迟到的旧应答:丢弃(不把上一次的结果画进这一次)
3917
3932
  logOut.textContent = '';
3918
3933
  if (!r || !r.ok || !r.items || !r.items.length) {
3919
3934
  logOut.appendChild(el('div', 'm-empty', '还没有导入记录 —— 从上面把一段历史请进来,这里就会留账。'));
@@ -3923,7 +3938,10 @@
3923
3938
  logOut.appendChild(el('div', null, it.line || ''));
3924
3939
  });
3925
3940
  if (r.items.length > 20) logOut.appendChild(el('div', 'm-meta', '(仅显示最近 20 条)'));
3926
- }).catch(function () { logOut.textContent = '记录加载失败(网关不可达)'; });
3941
+ }).catch(function () {
3942
+ if (token !== importLogSeq) return; // 同理:迟到的失败也不许凭空写新视图
3943
+ logOut.textContent = '记录加载失败(网关不可达)';
3944
+ });
3927
3945
  }
3928
3946
  bLogRefresh.onclick = renderImportLog;
3929
3947
  renderImportLog();
@@ -105,7 +105,7 @@ export function archiveBefore(memory, { source, convId, kind = 'forget', reason
105
105
 
106
106
  /** 改动日志 —— 这份文件是给**将来的人**看的,所以写全:是什么、为什么、怎么还原。 */
107
107
  function readme({ at, s, c, kind, reason, actor, turns }) {
108
- const who = actor === 'ling' ? '鱼姬(工具通道)' : '主人(界面通道)';
108
+ const who = actor === 'ling' ? '器灵(工具通道)' : '主人(界面通道)';
109
109
  // A-01(1.5.1):第三种 kind —— 主脉记忆覆盖(同一主脉再次采纳提炼结果;用户裁决"备份并覆盖")。
110
110
  const what = kind === 'forget' ? '遗忘(软标记 —— **库里的行没有删**)'
111
111
  : kind === 'vein-overwrite' ? '主脉记忆**覆盖**(同一主脉再次采纳 —— **上一版正文完整存在这里**)'
@@ -114,14 +114,14 @@ function readme({ at, s, c, kind, reason, actor, turns }) {
114
114
  // 「怎么还原」按 kind 分三档。用**字符串拼接**而不是嵌套模板,免得外层模板里的反引号打架。
115
115
  const restoreBlock = kind === 'forget'
116
116
  ? '**库里没有删任何行** —— 只是多了一条 `forgotten` 标记,召回路径不再选它。撤销方式二选一:\n\n'
117
- + '1. 让鱼姬调工具撤销(`memory_forget` 的 `undo`),或\n'
117
+ + '1. 让器灵调工具撤销(`memory_forget` 的 `undo`),或\n'
118
118
  + '2. 直接删标记:\n ```sql\n DELETE FROM forgotten WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
119
119
  : kind === 'vein-overwrite'
120
120
  ? '覆盖只动了 `conv_overview` 里那一行(主脉自己的记忆),原文与会话元数据不受影响。要把上一版换回去:\n\n'
121
- + '1. 让鱼姬按本目录 `overview.json` 的 `summary` **再采纳一次**旧正文,或\n'
121
+ + '1. 让器灵按本目录 `overview.json` 的 `summary` **再采纳一次**旧正文,或\n'
122
122
  + '2. 直接写回:\n ```sql\n UPDATE conv_overview SET summary=\'<overview.json 的 summary>\'\n WHERE source=\'' + s + '\' AND conv_id=\'' + c + '\';\n ```'
123
123
  : '回灌只是把归档写回去,而**它覆盖之前的状态存在这里**。要退回去:\n\n'
124
- + '1. 让鱼姬再回灌一次旧档(`memory_backfill`),或\n'
124
+ + '1. 让器灵再回灌一次旧档(`memory_backfill`),或\n'
125
125
  + '2. 按下面「万一库本身没了」的步骤写回。';
126
126
  return `# 归档:${title}的原始数据
127
127
 
@@ -17,14 +17,14 @@ export const INDEX_SKILL_NAME = 'memory-index';
17
17
  export const INDEX_SKILL_DESCRIPTION = `我记住的事按 11 类放着(用 recall({q:"关键词"}) 按内容搜 —— 它同时搜**结论**与**会话原文**):
18
18
  · 记忆树与记忆库:记忆怎么存、怎么搜、血缘归并、深层库
19
19
  · 器灵 dsh-ling:包名、发布仓库、版本与宣发、插件配置
20
- · 鱼姬与主人:称呼、纪念日、同伴身份、形象锚与立场
20
+ · 器灵与主人:称呼、纪念日、同伴身份、形象锚与立场
21
21
  · 主人其人:职业来路、身体作息、脾气念头、价值观
22
22
  · 合作规矩:先审后装、干跑、重启要问、实测复跑、备份
23
23
  · 本业与项目:本职的专业领域、论文与研究、自制的装置项目
24
24
  · 这台机器与踩坑:VM/Win10、硬件、乱码、沙箱、网络接入
25
25
  · 模型与API:选型、推理档位、缓存、上下文、本地小模型(我的手)
26
26
  · 插件与界面:装/卸/审计、侧栏、皮肤、权限选择器
27
- · 鱼姬的感官和开口:看图/生图、语音、听音乐、主动开口
27
+ · 器灵的感官和开口:看图/生图、语音、听音乐、主动开口
28
28
  · 零散几条:护眼工具、天文观测、投资慢课这类生活杂事`;
29
29
 
30
30
  export const INDEX_SKILL_WHEN_TO_USE = '想知道"我以前说过/定过什么"、或者要在一堆长期结论里找依据时读它;它的每一行是一个**主题簇**,指明了那一类里装着什么样的事。日常回答通常不需要读它 —— 索引行本身已经够你判断该不该用 recall 去搜。';
@@ -63,7 +63,7 @@ export const INDEX_SKILL_CONTENT = `# 深层库索引 · 我记住的事分 11
63
63
  |---|---|---|
64
64
  | 记忆树与记忆库 | 记忆怎么存、怎么搜,血缘归并,深层库 | 记忆树 · 记忆库 · 血缘 · 召回 |
65
65
  | 器灵 dsh-ling | 包名、发布仓库、版本与宣发、插件配置 | 器灵 · dsh-ling · 发布 |
66
- | 鱼姬与主人 | 称呼、纪念日、同伴身份、形象锚与立场 | 称呼 · 纪念日 · 同伴 · 形象 |
66
+ | 器灵与主人 | 称呼、纪念日、同伴身份、形象锚与立场 | 称呼 · 纪念日 · 同伴 · 形象 |
67
67
  | 主人其人 | 职业来路、身体作息、脾气念头、价值观 | 职业 · 睡眠 · 价值观 · 立场 |
68
68
  | 合作规矩 | 谁拍板、怎么审计、怎么汇报、红线 | 重启 · 审计 · 备份 · 规矩 |
69
69
  | 本业与项目 | 本职的专业领域、论文与研究、自制的装置项目 | 机械 · 论文 · 合金 · 弹跳球 |
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { readdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { svc, utcIso, dshHome } from './util.js';
7
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:深摘转写直接进请求体
7
8
  import { RAW_IMPORT_PREFIX, rawSessionCandidates } from './memory.js';
8
9
  import { pickSessionFile } from './backfill.js';
9
10
  import { currentModelSelection } from './mode.js';
@@ -229,7 +230,8 @@ export function transcriptFromFile(file, { cap = DEEP_CFG.inputCap } = {}) {
229
230
  const budget = Math.max(100, cap - mark.length);
230
231
  joined = joined.slice(0, Math.floor(budget * 0.6)) + mark + joined.slice(-Math.floor(budget * 0.4));
231
232
  }
232
- return { text: joined, users, asst, chars: joined.length, file };
233
+ const safe = wellFormed(joined);
234
+ return { text: safe, users, asst, chars: safe.length, file };
233
235
  }
234
236
 
235
237
  /** DB 原文转录(role user/assistant,按 seq)。source 给定时按 G4 命名空间取原文
@@ -261,7 +263,8 @@ export function buildTranscript(memory, sessionId, { cap = DEEP_CFG.inputCap, so
261
263
  const budget = Math.max(100, cap - mark.length);
262
264
  text = text.slice(0, Math.floor(budget * 0.6)) + mark + text.slice(-Math.floor(budget * 0.4));
263
265
  }
264
- return { text, users, parts: parts.length, chars: text.length };
266
+ const safe = wellFormed(text);
267
+ return { text: safe, users, parts: parts.length, chars: safe.length };
265
268
  }
266
269
 
267
270
  /**
@@ -9,6 +9,9 @@
9
9
  // 2) 阈值:轮次数 < minTurns(默认 2)默认不补(纯闲聊),显式 --all 才含;
10
10
  // 3) 引擎:默认**本机小助手**(零成本);**绝不静默切换到大模型**(那等于替用户花钱)。
11
11
  import { DatabaseSync } from 'node:sqlite';
12
+ // 1.7.1(delta 复审):本文件把会话原文拼进 `JSON.stringify(body)` 直接 POST 给小助手 ——
13
+ // 那也是一条"文本进请求体"的路径,与注入面同罪。原文里若带孤立代理,毒照样发出去。
14
+ import { wellFormed } from './rawlog.js';
12
15
 
13
16
  // S7(2026-09-18 与用户定):默认地址**中性化**为 loopback —— 发布版绝不携带私有内网地址,
14
17
  // 主人的真实小助手地址改由 settings.assistant(UI 可配)或环境变量指定,三级覆盖见 resolveAssistant()。
@@ -146,8 +149,8 @@ export async function summarizeLocal(baseUrl, model, { system = SUMMARY_SYS, tex
146
149
  const ctrl = new AbortController();
147
150
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
148
151
  try {
149
- const sys = noThink ? system + '\n' + NO_THINK : system;
150
- const usr = noThink ? text + '\n' + NO_THINK : text;
152
+ const sys = wellFormed(noThink ? system + '\n' + NO_THINK : system);
153
+ const usr = wellFormed(noThink ? text + '\n' + NO_THINK : text);
151
154
  const body = noThink
152
155
  ? {
153
156
  model, stream: false, think: false,
@@ -16,6 +16,8 @@
16
16
  // 措辞必须**陈述式**(我承担/我擅长/我能给出),**禁授权式**(我应当/我必须/我的任务/我的责任是):
17
17
  // 授权式写法把她写成一个"被授权者",是语义劫持最好用的钩子(参照 [规矩] 段头那次的教训)。
18
18
 
19
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:诞生仪式的请求体同样不许带毒
20
+
19
21
  /** 授权式措辞清单(1.5.2 I-5,约束 C):起草 prompt 必须逐字列出它们作为**禁止项**。
20
22
  * 导出而非内联,是为了让"prompt 里的禁令"和"界面上的提醒(pm 面板里那句『读起来像…建议不选它』)"
21
23
  * 以及测试断言共用同一份事实 —— 三处各写一份必然会漂移成说谎的护栏。
@@ -120,7 +122,7 @@ export function snippetFromRaw(memory, convId, { maxMsgs = 3, perMsg = 200, pron
120
122
  if (!t) continue;
121
123
  parts.push(who + ': ' + (t.length > perMsg ? t.slice(0, perMsg) + '…' : t));
122
124
  }
123
- return parts.join(' | ');
125
+ return wellFormed(parts.join(' | '));
124
126
  } catch {
125
127
  return '';
126
128
  }
@@ -142,9 +144,9 @@ export function buildGenesisSource(rows, { cap = 18000 } = {}) {
142
144
  parts.push(line);
143
145
  }
144
146
  const text = parts.join('\n');
145
- if (text.length <= cap) return text;
147
+ if (text.length <= cap) return wellFormed(text);
146
148
  // 截断:保头丢尾(排序保证最前面的正是生活/情感优先的条目)
147
- return text.slice(0, cap) + '\n…(历史较长,其余省略)';
149
+ return wellFormed(text.slice(0, cap)) + '\n…(历史较长,其余省略)';
148
150
  }
149
151
 
150
152
  /**
@@ -6,6 +6,7 @@
6
6
  // - idle sessions with a stale snapshot: rebuild lazily (one-time invalidation);
7
7
  // - subagent/child/unknown sessions: return '' (R5).
8
8
  import { svc, pick, neutralizeMustache, sessionKind } from './util.js';
9
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:注入面最终出口
9
10
  import { buildSnapshotText, firstUserText } from './snapshot.js';
10
11
  import { currentMode } from './mode.js';
11
12
  import { keywordsFrom } from './inject-common.js';
@@ -143,7 +144,9 @@ export function registerMemorySection(ctx, gate, memory, settings, { enabled = t
143
144
  // 而 agent-loop 调用处无 try/catch ⇒ **该会话每一步模型调用都失败**。
144
145
  // 这正是 1.3.1 拆掉的那颗雷(S12)的同类面:section 侧一直有中和
145
146
  // (见 renderMemorySection 的 `neutralizeMustache(anchor)`),搬迁时漏了这一处。
146
- text: () => neutralizeMustache(timeAnchorLive(memory, settings)),
147
+ // 1.7.1(delta 复审):这是**独立于 snapshot 的第二条出口** —— context 文本也会进模型上下文,
148
+ // 只中和 mustache 不够;时间锚里的称呼/日期同样是往外送的文本,照样过 wellFormed。
149
+ text: () => wellFormed(neutralizeMustache(timeAnchorLive(memory, settings))),
147
150
  });
148
151
  clockRegistered = true;
149
152
  memory.kvSet('inject.clock_ctx_registered', '1');
@@ -186,9 +189,9 @@ export function renderMemorySection(assembly, gate, memory, settings) {
186
189
  // 注入面出口 ②(最终侧,2026-09-18):时间锚不经快照,未来新增的 part 也只走这里,
187
190
  // 故在最外层再中和一次(幂等)。①+② 合计覆盖全部注入文本。
188
191
  const withAnchor = (text) => {
189
- const body = neutralizeMustache(text || '');
192
+ const body = wellFormed(neutralizeMustache(text || ''));
190
193
  if (!anchor) return body;
191
- return (body ? body + '\n\n' : '') + neutralizeMustache(anchor);
194
+ return wellFormed((body ? body + '\n\n' : '') + neutralizeMustache(anchor));
192
195
  };
193
196
  const snap = gate.snapshotOf(sessionId);
194
197
  // §3 独立开关(1.5.2):只管"树改动播报"。关闭时**不推进任何"已示人"水位** ——
package/lib/host/l1.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // heat = α·recency + β·freq + γ·importance (exponential decay, half-life configurable);
3
3
  // mode weights by category (knowledge/daily/feeling); optional keyword pre-match.
4
4
  import { toEpochMs, isoDate } from './util.js';
5
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:L1 行同样进模型上下文,出口统一净化
5
6
  import { userTitleForMode } from './persona.js'; // 1.6-B2:视角框的称呼跟随 userTitle(不硬编码)
6
7
 
7
8
  const DAY_MS = 86_400_000;
@@ -569,7 +570,7 @@ export function firstSentence(text, cap = SUMMARY_CHARS_DEFAULT) {
569
570
  const cut = Math.max(slice.lastIndexOf(','), slice.lastIndexOf(','), slice.lastIndexOf('、'));
570
571
  head = (cut >= Math.floor(limit * 0.6) ? slice.slice(0, cut) : slice) + '…';
571
572
  }
572
- return head;
573
+ return wellFormed(head);
573
574
  }
574
575
 
575
576
  /**
@@ -609,7 +610,7 @@ function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT, self
609
610
  // DSH 会话 id 一律以 "session-" 开头,取前 8 位会全都一样 —— 这时取前缀之后的那一段才可区分
610
611
  const cid = String(row.conv_id || '');
611
612
  const short = cid.startsWith('session-') ? cid.slice(8, 16) : cid.slice(0, 8);
612
- return `- ${tags} ${date ? `(${date})` : ''}${out ? ' ' + out : ''} (来源: ${srcName}/${short})`;
613
+ return wellFormed(`- ${tags} ${date ? `(${date})` : ''}${out ? ' ' + out : ''} (来源: ${srcName}/${short})`);
613
614
  }
614
615
 
615
616
  /** 1.6 步 5:一条**深层库条目**渲染成一行(取代 `lineFor` 的概述两段式 `标题 —— 摘要首句`)。
@@ -625,7 +626,7 @@ function deepLine(row, maxChars = 0) {
625
626
  // 预算裁剪是"装不下就停"(见下面的 for),一条超长条目会把排在它后面的全卡死。
626
627
  const cap = Number(maxChars) > 0 ? Math.max(60, Number(maxChars)) : 0;
627
628
  if (cap && text.length > cap) text = text.slice(0, cap - 1) + '…';
628
- return kind ? `- [${kind}] ${text}` : `- ${text}`;
629
+ return wellFormed(kind ? `- [${kind}] ${text}` : `- ${text}`);
629
630
  }
630
631
 
631
632
  // L1 视角框(1.6 · 第一批 B)—— 修「代词混乱」BUG:落点一处、覆盖全部历史条目、零动库。
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { existsSync, readFileSync, statSync } from 'node:fs';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { ensureDir, sha256Text, utcIso, mergeDeep, toIso, toEpochMs } from './util.js';
8
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:留痕摘要会进注入面
8
9
  // A-01(1.5.1):主脉记忆被覆盖前**必须先归档**。archive.js 只依赖 node:fs / node:path,
9
10
  // 且把 memory 当参数传(依赖注入)⇒ memory.js 引它**不成环**。
10
11
  import { archiveBefore } from './archive.js';
@@ -14,7 +15,7 @@ const SCHEMA_VERSION = 17;
14
15
  * 为什么用哨兵而不是新列:所有"排除已被取代"的读路径(开场选择 / recall / 面板)
15
16
  * 判的都是 `superseded_by != ''` —— 哨兵让**那些路径一处都不用改**就自动不再挑它;
16
17
  * 而"为什么作废"落在 kv `deep.retract:<id>`(人可读、可审计),不污染表语义。 */
17
- export const DEEP_RETRACTED = 'retracted'; // v17:deep_item.durability(条目「硬度」,1.6,2026-10-04 —— 主人原话「同会话使用硬数量上限来卡吗,这可能有点机械,能不能做成从根源上判断这些条目到底是临时的还是永久的」⇒ 病根不是"同一会话进了太多条",而是**临时的东西被判成了长期记忆**);v16:deep_item(深层库条目,1.6 步 2,2026-10-04 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移;与记忆树并立、条目不被枝隶属);v15:inject_log(投递留痕,1.6-A,2026-10-03 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移);v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"鱼姬"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
18
+ export const DEEP_RETRACTED = 'retracted'; // v17:deep_item.durability(条目「硬度」,1.6,2026-10-04 —— 主人原话「同会话使用硬数量上限来卡吗,这可能有点机械,能不能做成从根源上判断这些条目到底是临时的还是永久的」⇒ 病根不是"同一会话进了太多条",而是**临时的东西被判成了长期记忆**);v16:deep_item(深层库条目,1.6 步 2,2026-10-04 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移;与记忆树并立、条目不被枝隶属);v15:inject_log(投递留痕,1.6-A,2026-10-03 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移);v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"器灵"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
18
19
  // v10:tree_snapshot.kind('manual'|'auto',审计 A#9 —— 自动备份与手动快照此前外表完全一致,删错了就没有撤回路径);v9:tree_snapshot(树的保存/恢复 R1);v8:conv_branch(非会话源的枝归属覆盖层,方案 A);v7:branch_log(复盘留痕);v6:vein_link + memory_conflict(D9-b 树的生长);v5:branch(记忆分枝 D9-a);v4:title_by(定名者:user/ai);v3:title_locked;v2:conv_overview.source 放开 CHECK
19
20
 
20
21
  /** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
@@ -1003,10 +1004,23 @@ export class MemoryStore {
1003
1004
  // (主键是有序对,旧写入侧没挡反向)⇒ 只删一个方向会让回执说"断开了"而树里还连着。
1004
1005
  const a = String(fromBranch || '');
1005
1006
  const b = String(toBranch || '');
1006
- const r = this.db.prepare(
1007
- 'DELETE FROM vein_link WHERE (from_branch=? AND to_branch=?) OR (from_branch=? AND to_branch=?)',
1008
- ).run(a, b, b, a);
1009
- return { ok: true, removed: Number(r.changes || 0) };
1007
+ // 1.7.1(审计 F-03 + delta 复审):按**每一行自己的 kind** 决策,而不是只看正向行。
1008
+ // 有向类型(A→B 与 B→A 是**两条不同的边**)只删同向;无向类型仍按旧口径双向清 ——
1009
+ // 那是历史重复行(有序对主键)的兜底。混合历史(如 A→B related + B→A prerequisite)
1010
+ // 以前会被整对双删,directed 那条被连带抹掉;现在只删请求方向 + 仅当反向也是无向时才清。
1011
+ const fwd = this.db.prepare('SELECT kind FROM vein_link WHERE from_branch=? AND to_branch=?').get(a, b);
1012
+ const rev = this.db.prepare('SELECT kind FROM vein_link WHERE from_branch=? AND to_branch=?').get(b, a);
1013
+ const isDirected = (row) => Boolean(row) && DIRECTED_LINK_KINDS.has(String(row.kind || 'related'));
1014
+ let removed = 0;
1015
+ if (fwd) {
1016
+ const r1 = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?').run(a, b);
1017
+ removed += Number(r1.changes || 0);
1018
+ }
1019
+ if (rev && !isDirected(rev) && !isDirected(fwd)) {
1020
+ const r2 = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?').run(b, a);
1021
+ removed += Number(r2.changes || 0);
1022
+ }
1023
+ return { ok: true, removed };
1010
1024
  } catch {
1011
1025
  return { ok: false, reason: 'db' };
1012
1026
  }
@@ -1226,7 +1240,7 @@ export class MemoryStore {
1226
1240
  // G4(2026-09-25):actor 记录**谁改的** —— 工具通道上线前器灵只能经 API/开库改树,
1227
1241
  // 而旧 logBranch 无此字段,91 笔器灵改动在复盘留痕里全被显示成"主人改的"。
1228
1242
  // 取值 'user' | 'ling'(默认 'user' 以兼容既有调用点)。
1229
- const oneLine = (s, n) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
1243
+ const oneLine = (s, n) => wellFormed(String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n));
1230
1244
  const who = String(actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user';
1231
1245
  try {
1232
1246
  this.db.prepare(`INSERT INTO branch_log (at,branch_id,action,before_val,after_val,note,actor)
@@ -1400,7 +1414,7 @@ export class MemoryStore {
1400
1414
  if (/^(br|vein):/.test(k)) return '(已删枝 ' + k.slice(-8) + ')'; // 长 id 形态 ⇒ 枝
1401
1415
  return '条目 ' + k.slice(-8); // 其余按记忆条目
1402
1416
  };
1403
- const flat = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80);
1417
+ const flat = (s) => wellFormed(String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80));
1404
1418
  const lines = picked.map((r) => {
1405
1419
  const who = nm(r.branchId);
1406
1420
  const line = (() => {
@@ -1450,7 +1464,7 @@ export class MemoryStore {
1450
1464
  // G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
1451
1465
  // 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
1452
1466
  // 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
1453
- const subject = String(r.actor || 'user') === 'ling' ? '鱼姬' : '主人';
1467
+ const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
1454
1468
  return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
1455
1469
  });
1456
1470
  // ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
@@ -2141,7 +2155,7 @@ export class MemoryStore {
2141
2155
  if (!s) return;
2142
2156
  out.push({
2143
2157
  source: String(src), convId: String(cid),
2144
- title: String(title || '').slice(0, 60), summary: s.slice(0, cap),
2158
+ title: wellFormed(String(title || '').slice(0, 60)), summary: wellFormed(s.slice(0, cap)),
2145
2159
  });
2146
2160
  };
2147
2161
  // ⚠️ A-02(1.5.1 红蓝对抗,2026-09-25):提炼素材**必须排除已遗忘的记忆**。
@@ -2175,7 +2189,7 @@ export class MemoryStore {
2175
2189
  /** 记一条提炼候选(审核队列;**不**直接进记忆)。 */
2176
2190
  addVeinSuggestion({ veinId, text, sources = [], model = '', note = '' } = {}) {
2177
2191
  const bid = String(veinId || '');
2178
- const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200);
2192
+ const t = wellFormed(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200));
2179
2193
  if (!bid || !t) return { ok: false, reason: 'bad-arg' };
2180
2194
  const now = utcIso();
2181
2195
  try {
@@ -2938,8 +2952,30 @@ export class MemoryStore {
2938
2952
  return body;
2939
2953
  }
2940
2954
 
2941
- /** 合并(DESIGN §8 v2):概述幂等;队列/建议按内容去重追加;deep 仅在目标存在对应概述时恢复。 */
2955
+ /** 合并(DESIGN §8 v2):概述幂等;队列/建议按内容去重追加;deep 仅在目标存在对应概述时恢复。
2956
+ *
2957
+ * **1.7.1(审计 C1):整包原子化** —— 以前没有事务,包处理到一半抛异常(尤其 `overwrite=true`
2958
+ * 的覆盖路径)会留下"一部分已覆盖、一部分没覆盖"的半截库,而回执只报 `errors=1`。
2959
+ * 用 `SAVEPOINT` 而不是 `BEGIN`:外层若已有事务仍可嵌套(`BEGIN` 会抛 "cannot start a transaction")。
2960
+ * 口径:**逃出方法的异常** ⇒ 整包回滚(回执全 0 + `errors=1`,不谎报行数);
2961
+ * **单行错误**仍按旧语义只计数不回滚 —— 那是刻意保留的"坏一行不毁整包"。 */
2942
2962
  importBundle(bundle, { overwrite = false } = {}) {
2963
+ const sp = 'sp_import_' + Math.random().toString(36).slice(2, 10);
2964
+ try {
2965
+ // delta 复审:SAVEPOINT 本身也可能失败(库不可事务/被占用),必须在 try 内,
2966
+ // 否则它会作为未归类的异常逃出方法,违背"回执永远是一张表"的契约。
2967
+ this.db.exec('SAVEPOINT ' + sp);
2968
+ const report = this._importBundleRows(bundle, { overwrite });
2969
+ this.db.exec('RELEASE ' + sp);
2970
+ return report;
2971
+ } catch {
2972
+ try { this.db.exec('ROLLBACK TO ' + sp); this.db.exec('RELEASE ' + sp); } catch { /* noop */ }
2973
+ return { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 1 };
2974
+ }
2975
+ }
2976
+
2977
+ /** `importBundle` 的行循环(无事务;只许 `importBundle` 调)。 */
2978
+ _importBundleRows(bundle, { overwrite = false } = {}) {
2943
2979
  const report = { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 0 };
2944
2980
  if (!bundle || bundle.format !== 'dsh-ling-memory') {
2945
2981
  report.errors = 1;
@@ -320,9 +320,72 @@ export function localStamp(ms) {
320
320
  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
321
321
  }
322
322
 
323
- /** 单行化 + 截断(目录视图用)。名字不叫 flatOne:`tools.js` 里已有一个同名的(签名不同)。 */
323
+ /** ⚠️ **孤立代理净化**(F4 修复批次,2026-10-17)。
324
+ *
325
+ * 为什么必须有它:JavaScript 字符串允许**半个代理对**存在(高位 `U+D800–DBFF` 或低位
326
+ * `U+DC00–DFFF` 落单),那是**非法 UTF-16** —— 而它一旦被送进请求体,上游 JSON 解析器
327
+ * **直接崩**(不是"显示怪",是整条请求 400)。
328
+ *
329
+ * 实测两起事故(2026-10-17 全库核实,550 会话 / 真 400 共 9 次):
330
+ * · `session-bc00b39b` #6331:低代理 `0xdd12` @870 —— 源自 `recall` snippet **起点**切在 emoji 中间;
331
+ * · `session-7ff65dd7` seq=58:高代理 `U+D83C` —— 源自 `recall` snippet **终点**切在 emoji 中间。
332
+ * 两例**都**触发了 400(杀伤率 100%),没有侥幸活下来的样本。
333
+ *
334
+ * ⇒ 口径:**凡是往外送(进模型上下文 / 落盘 / 进请求体)的文本,先过这里**。
335
+ * 落单代理一律换成 `U+FFFD`(与浏览器、流式 UTF-8 解码同款口径)。
336
+ * 优先用运行时自带的 `String.prototype.toWellFormed`(ES2024),没有就手写等价实现。 */
337
+ export function wellFormed(s) {
338
+ const t = String(s ?? '');
339
+ if (typeof t.toWellFormed === 'function') return t.toWellFormed();
340
+ let out = '';
341
+ for (let i = 0; i < t.length; i++) {
342
+ const c = t.charCodeAt(i);
343
+ if (c >= 0xd800 && c <= 0xdbff) {
344
+ const n = i + 1 < t.length ? t.charCodeAt(i + 1) : 0;
345
+ if (n >= 0xdc00 && n <= 0xdfff) { out += t[i] + t[i + 1]; i += 1; } else out += '\uFFFD';
346
+ } else if (c >= 0xdc00 && c <= 0xdfff) {
347
+ out += '\uFFFD';
348
+ } else {
349
+ out += t[i];
350
+ }
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /** **不切断代理对**的截断(超出留 `…`)。与 `slice(0, n)` **同口径** —— `cap` 数的是
356
+ * **UTF-16 单元**(emoji 算 2),唯一差别是切点若落在代理对中间就**回退一格**,
357
+ * 绝不吐出半个字符。⇒ 代价只有"含 emoji 的文本少显示一两个字",换来的是回执永远合法。
358
+ * 实现刻意做成 O(1)(只回退一格,不构造码点数组)—— 它会被高频调用。 */
359
+ export function clipChars(s, cap) {
360
+ const t = wellFormed(s);
361
+ const k = Math.max(1, Number(cap) || 0);
362
+ if (t.length <= k) return t;
363
+ let end = k;
364
+ // 切点前一个单元是高代理 ⇒ 它的低代理会被丢掉 ⇒ 回退一格,把整个字符留在片段里
365
+ const c = t.charCodeAt(end - 1);
366
+ if (c >= 0xd800 && c <= 0xdbff) end -= 1;
367
+ return t.slice(0, end) + '…';
368
+ }
369
+
370
+ /** 按**码点**安全取区间(起点与终点都不切断代理对)。`recall` 的"命中位置附近截一段"用它。
371
+ * 起点落在低代理上 ⇒ 回退一格(宁可多带一个字符,也不留下半个 emoji)。 */
372
+ export function safeSlice(s, from, to) {
373
+ const t = wellFormed(s);
374
+ const n = t.length;
375
+ let a = Math.max(0, Math.min(Number(from) || 0, n));
376
+ let b = Math.max(a, Math.min(Number(to) || 0, n));
377
+ const ca = t.charCodeAt(a);
378
+ if (ca >= 0xdc00 && ca <= 0xdfff) a = Math.max(0, a - 1);
379
+ const cb = t.charCodeAt(b - 1);
380
+ if (cb >= 0xd800 && cb <= 0xdbff) b = Math.min(n, b + 1);
381
+ return t.slice(a, b);
382
+ }
383
+
384
+ /** 单行化 + 截断(目录视图用)。名字不叫 flatOne:`tools.js` 里已有一个同名的(签名不同)。
385
+ * ⚠️ F4(2026-10-17):截断改走 `clipChars`(按码点)—— 原来直接 `slice` 会把 emoji 切成半个,
386
+ * 那半个代理会随工具回执进上下文、并把官方路由打成 400(见 `wellFormed` 的事故记录)。 */
324
387
  export function oneLine(s, cap = 60) {
325
- const t = String(s || '').replace(/\s+/g, ' ').trim();
388
+ const t = wellFormed(String(s || '').replace(/\s+/g, ' ').trim());
326
389
  if (!t) return '';
327
- return t.length > cap ? t.slice(0, cap) + '…' : t;
390
+ return clipChars(t, cap);
328
391
  }
@@ -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 } from './rawlog.js';
25
+ import { localStamp, oneLine, safeSlice, wellFormed } from './rawlog.js';
26
26
 
27
27
  /** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
28
28
  * 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
@@ -179,8 +179,11 @@ export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfI
179
179
  return {
180
180
  conv: id, seq: Number(r.seq) || 0, role,
181
181
  time: r.ts ? localStamp(r.ts) : '',
182
- who: isUser ? callName : '鱼姬',
183
- snippet: oneLine(text.slice(from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
182
+ who: isUser ? callName : '器灵',
183
+ // ⚠️ F4(2026-10-17):原来是 `text.slice(from, from + N)` —— 起点或终点落在 emoji 中间时
184
+ // 会切出**半个代理对**,而那半个代理会随回执进上下文、把官方路由打成 400。
185
+ // 实测 `session-bc00b39b` #6331 的低代理 `0xdd12` 正是起点切坏而来(见 rawlog.wellFormed)。
186
+ snippet: oneLine(safeSlice(text, from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
184
187
  isSelf: Boolean(self) && id === self,
185
188
  };
186
189
  });
@@ -262,7 +265,7 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
262
265
  cwd: header.cwd ? String(header.cwd) : '',
263
266
  seq: m.seq ?? null,
264
267
  time: m.time ? localStamp(m.time) : '',
265
- who: isUser ? callName : '鱼姬',
268
+ who: isUser ? callName : '器灵',
266
269
  snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
267
270
  isSelf: Boolean(self) && String(id) === self,
268
271
  });
@@ -281,6 +284,14 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
281
284
 
282
285
  /** 检索档的人读回执(纯函数,模型可见)。 */
283
286
  export function renderSearch(v) {
287
+ // ⚠️ F4(2026-10-17):**出口兜底净化**。snippet 有三个来源(宿主 `bestMatch` / 库内原文层 /
288
+ // `rawlog` 目录),逐处修容易漏一处;这里对**整段回执**做一次 —— 任何落单代理(半个 emoji)
289
+ // 都换成 `U+FFFD`,保证"进模型上下文的文本"永远是合法 UTF-16(见 `rawlog.wellFormed` 的事故记录)。
290
+ return wellFormed(renderSearchText(v));
291
+ }
292
+
293
+ /** 回执正文(不做净化)—— 出口在 `renderSearch`。 */
294
+ function renderSearchText(v) {
284
295
  if (!v || v.ok !== true) {
285
296
  // ⚠️ 兜底必须在**构造字符串之前** —— 表里的模板是立即求值的,`v` 为 null 时
286
297
  // `v.message` 会在构造这一步就抛。这不是防御性编程:工具回执抛异常 = 模型那一步直接失败,
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 标题是记忆的"名字"。旧行为把首条用户消息硬截 46 字(见 summarizer.js TITLE_MAX),
4
4
  // 于是 60 条 dsh 概述里 26 条是半句话:
5
- // 「鱼姬,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
5
+ // 「器灵,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
6
6
  // 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」
7
7
  // 而 dsweb 侧标题干净(「CPU单核性能排行榜」),因为它用的是源库自带标题、不做首句截断。
8
8
  //
@@ -29,13 +29,13 @@ export const TITLE_MATERIAL_CHARS = 1400;
29
29
  export const TITLE_SYS = '你在给一段对话起名字。只输出标题本身:不超过 20 个字,写清这段对话在做什么或结论是什么;'
30
30
  + '不要引号、不要书名号、不要结尾标点、不要解释、不要分点、不要写"对话"二字。';
31
31
 
32
- // 称呼与问候:中文主人对器灵的日常开场(「晚上好小鱼,我小加班了一会,回来了」)
33
- const ADDRESS_RE = /^(小鱼鱼|小鱼|鱼姬|器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
32
+ // 称呼与问候:中文主人对器灵的日常开场(「晚上好器灵,我小加班了一会,回来了」)
33
+ const ADDRESS_RE = /^(器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
34
34
  const GREETING_RE = /^(早上好|中午好|下午好|晚上好|晚安|你好呀|你好|您好|哈喽|嗨|早|hello|hi|yo)[啊呀呢,,、!!。.~\s]*/i;
35
35
  // 无信息量首句(问候、寒暄、测试口令)
36
36
  const VAGUE_RE = /^(嗯+|哦+|好+|在吗|在不在|\S{0,4}测试\S*|回复数字\s*\d+|\d+|ok|test)$/i;
37
37
 
38
- /** 反复剥离"问候 + 称呼"前缀:「晚上好小鱼,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
38
+ /** 反复剥离"问候 + 称呼"前缀:「晚上好器灵,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
39
39
  export function stripPreamble(text) {
40
40
  let s = String(text || '').replace(/\s+/g, ' ').trim();
41
41
  for (let i = 0; i < 3; i += 1) {
@@ -214,7 +214,7 @@ export function retitleCandidates(memory, { source = '', limit = 200 } = {}) {
214
214
  if (!looksLikeSessionId(r.conv_id)) return false; // 合成行(user:principle-…)不是会话
215
215
  if (!t) return true;
216
216
  if (t.length >= LEGACY_TITLE_MAX) return true; // 被旧上限(TITLE_MAX=46)切过的
217
- if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「鱼姬,」「晚上好小鱼,」= 旧启发式产物
217
+ if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「器灵,」「晚上好器灵,」= 旧启发式产物
218
218
  return isVagueTitle(t); // 「你好」「在吗」
219
219
  }).map((r) => ({ source: String(r.source), conv_id: String(r.conv_id), title: String(r.title || ''), summary: String(r.summary || '') }));
220
220
  }
@@ -35,6 +35,9 @@ import { pick } from './util.js';
35
35
  import { firstSentence } from './l1.js';
36
36
  import { extractMessageText } from './snapshot.js';
37
37
  import { canInject, kindOfAgent, sessionIdOfAgent } from './inject.js';
38
+ // F4(2026-10-17):按码点安全截断 —— 那一行会**进模型上下文**,原来直接 `slice` 会把 emoji
39
+ // 切成半个代理对,而半个代理能把官方路由打成 400(见 `rawlog.wellFormed` 的事故记录)。
40
+ import { clipChars } from './rawlog.js';
38
41
 
39
42
  /** 我们那条活行的 `source.kind` —— 也是"认出自己"的唯一判据(不用记下的 seq:折叠后可能已不在面上)。 */
40
43
  export const SESSION_LINE_SOURCE = 'dsh-ling:session-line';
@@ -74,11 +77,13 @@ function oneLine(t) {
74
77
  return String(t ?? '').replace(/\s+/g, ' ').trim();
75
78
  }
76
79
 
77
- /** 硬夹到 n 字(超出留 `…`)。 */
80
+ /** 硬夹到 n 字(超出留 `…`)。⚠️ F4(2026-10-17):改走 `clipChars`(按码点)—— 原来直接
81
+ * `slice(0, k-1)` 会把 emoji 切成半个代理对,而那一行会进模型上下文 ⇒ 半个代理能打成 400。
82
+ * 语义保持不变:**k 是含省略号的总长上限**。 */
78
83
  function clip(s, n) {
79
84
  const t = String(s ?? '');
80
85
  const k = Math.max(1, Number(n) || 0);
81
- return t.length <= k ? t : t.slice(0, k - 1) + '…';
86
+ return t.length <= k ? t : clipChars(t, Math.max(1, k - 1));
82
87
  }
83
88
 
84
89
  /** 本地时刻 HH:MM(与主人看到的钟一致;探针里用它判断"文本是否随对话变")。 */
@@ -7,11 +7,12 @@ import { assemblePersona } from './persona.js';
7
7
  import { selectL1, formatL1Section } from './l1.js';
8
8
  import { currentMode } from './mode.js';
9
9
  import { neutralizeMustache } from './util.js';
10
+ import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:快照是注入面主出口
10
11
  import { createHash } from 'node:crypto';
11
12
 
12
13
  // §4「超量折叠成一行」的那行计数(由 `memory.js` 的 branchLogDigest 在明细之后 push 进 `lines`)
13
14
  // —— 它是**汇总**,不是明细。零明细时它撑不起整段(见下方渲染判据);有明细时照旧显示。
14
- // 判据只用开头:明细行一律以主语(主人/鱼姬)或动作词开头,不会长成 `…另有 N 笔改动`。
15
+ // 判据只用开头:明细行一律以主语(主人/器灵)或动作词开头,不会长成 `…另有 N 笔改动`。
15
16
  // 若哪天 memory.js 改了措辞,这里退化成"照旧渲染"(fail-open)—— 只影响占不占地方,不吞明细。
16
17
  const BRANCH_LOG_FOLD_LINE = /^…另有 \d+ 笔改动/;
17
18
 
@@ -28,7 +29,9 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
28
29
  const l0 = assemblePersona(s, mode);
29
30
  if (l0) { l0Text = l0; parts.push(l0); }
30
31
  }
31
- if (personaEnabled && s.memory?.l1Enabled !== false && memory.listOverviews({ onlyOk: true }).length > 0) {
32
+ // 1.7.1(审计 F-02):闸不再查 conv_overview —— 料已换成深层库(l1.js 的 deepLine),
33
+ // 查概述会在"只有深层条目"的库里把整段 L1 吞掉;交 selectL1 自己判空。
34
+ if (personaEnabled && s.memory?.l1Enabled !== false) {
32
35
  const cfg = s.memory || {};
33
36
  const result = selectL1(memory, {
34
37
  mode,
@@ -100,7 +103,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
100
103
  // **开关只管"渲染",不管"交代记账"**(停推进会卡死复盘模式的退出,回归见 tree 套 9-5)。
101
104
  if (s.memory?.branchLogEnabled !== false && logDetailLines.length) {
102
105
  // A-03 / D-S03(1.5.1 红蓝对抗):段首此前**写死**「主人刚改动了记忆树」,而 schema v12 起
103
- // 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「鱼姬」)⇒ 器灵经工具
106
+ // 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「器灵」)⇒ 器灵经工具
104
107
  // 改的树,在它**下一轮的系统提示**里被读成"主人刚改动",与 tools.js 的设计意图正相反。
105
108
  // 段首改中性措辞,**主语交给每一行** —— 谁改的由行内说,不由段头替它说。
106
109
  parts.push(['【记忆树有改动】', ...logLines.map((x) => '- ' + x)].join('\n'));
@@ -110,7 +113,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
110
113
  // 注入面出口 ①(快照侧,2026-09-18):L0 人格 + L1 记忆在这里成文。宿主 renderPrompt
111
114
  // 无 try/catch,未注册的 `{{名字}}` 会让该会话每一步都失败 ⇒ 成文即中和(幂等)。
112
115
  // 时间锚不经此处,由 inject.js 的最终出口兜底。
113
- const text = neutralizeMustache(parts.join('\n\n'));
116
+ const text = wellFormed(neutralizeMustache(parts.join('\n\n')));
114
117
  // ── 投递留痕(1.6-A,2026-10-03)──────────────────────────────────────────────
115
118
  // **只在首次定稿时落一条**(`bumpHits`):冻结快照的选取在此刻定下,此后同一会话每轮投递的都是同一段
116
119
  // 文本;重建/空闲刷新再记只会把表撑大而不增信息(与 `hit_count` 只记首次定稿同一取向,见上)。
package/lib/host/tools.js CHANGED
@@ -28,7 +28,9 @@ import { TRUNK_ID, RAW_IMPORT_PREFIX, DIRECTED_LINK_KINDS } from './memory.js';
28
28
  import { archiveBefore, listArchives, readArchive } from './archive.js';
29
29
  // 1.6 · P2(2026-10-04 用户拍"第一优先级"):**直通原文** —— 此前器灵没有任何通道能读会话原文,
30
30
  // 只能吃概述(而概述是别人替我挑的)。真全文在磁盘的多 frame zstd 里,读法见 rawlog.js 顶部注释。
31
- import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine, eventsFromRows } from './rawlog.js';
31
+ // F4-b(2026-10-17):`wellFormed` —— recall **取原文**那一档是直出全文(不截断,因此也不经 `oneLine`),
32
+ // 孤立代理会原样进上下文 ⇒ 请求体带毒 ⇒ 官方端点 400。出口净化见 `renderRecall` 的 wrapper。
33
+ import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine, eventsFromRows, wellFormed } from './rawlog.js';
32
34
  // 回执里的称呼跟随 `persona.userTitle`(与 B 视角框、clock 的 [现在] 同一把尺) —— 不硬编码某个人的名字。
33
35
  import { userTitleForMode } from './persona.js';
34
36
  // 1.6 §7 步 1(2026-10-04 主人拍 D1-D6 后开工):recall 的**检索档** —— 按内容找会话,接宿主检索面(不自建)。
@@ -286,7 +288,7 @@ export const HABIT_RESOLVE_SPEC = {
286
288
  // 纪律(写进工具描述,模型可见):
287
289
  // - 只在主人明确要求、或正在和他一起复盘时调用;不要因为"看着乱想整理一下"就自己动手;
288
290
  // - 每次改结构必须给 reason(写进 branch_log,复盘时可审计);
289
- // - 经本通道的改动一律记 actor='ling' —— 注入面会告诉主人「鱼姬改动了记忆树」。
291
+ // - 经本通道的改动一律记 actor='ling' —— 注入面会告诉主人「器灵改动了记忆树」。
290
292
 
291
293
  /** 剔除 undefined/函数/Symbol —— 平台对工具输出做**无损 JSON**校验,含 undefined 即整次调用报
292
294
  * 「value is not lossless JSON」;而写操作早已落库 ⇒ 调用方看到"失败"、重发就会写第二遍。
@@ -434,7 +436,7 @@ function renderTreeRead(v) {
434
436
  }
435
437
 
436
438
  /** 所有写操作回执的同一句尾巴(改动落在哪 —— 主人下一次开口前会看到)。 */
437
- const TALLY_LINE = '\n(已记入复盘留痕:鱼姬改动了记忆树 —— 主人下一次开口前会看到)';
439
+ const TALLY_LINE = '\n(已记入复盘留痕:器灵改动了记忆树 —— 主人下一次开口前会看到)';
438
440
 
439
441
  /** 删枝**成功之后**的真实后果(D-T05 的成功路径那一侧:force 的后果此前只在"被拒绝"时说过,
440
442
  * 真的删成了以后回执只剩一句"删掉了枝" —— 归属被静默挪回主干,调用方无从知道)。
@@ -572,7 +574,7 @@ export const BRANCH_EDIT_SPEC = {
572
574
  + 'unlink(断边,需 id+to)/ weight(调枝系数,需 id+weightScale,复盘时用;'
573
575
  + '有效区间 [0,2],超出会被夹取;**该字段参与检索打分**(1.6.2 起,见 DESIGN.md 的血缘加权那一节))/ '
574
576
  + 'delete(删枝,需 id;非空枝要 force —— force 会把它的子枝与会话归属**回退到主干**,'
575
- + '原归属不可还原)。所有改动都记在「鱼姬」名下,'
577
+ + '原归属不可还原)。所有改动都记在「器灵」名下,'
576
578
  + '并出现在他下一次开口前的注入里。',
577
579
  parameters: {
578
580
  type: 'object',
@@ -995,7 +997,10 @@ export function readSessionFromDb(memory, conv) {
995
997
  // ⇒ 它们**物理不可达**,模型看到的是干巴巴的 `未读到内容(search-unavailable)。`
996
998
  // (与 F16② 同一族:文案宣称 A、执行点做不到 —— 这次的修法必须让那句"不是库里没有"真的到得了眼前)。
997
999
  const SEARCH_RECEIPT_REASONS = new Set(['search-unavailable', 'search-failed', 'local-unavailable']);
998
- function renderRecall(v) {
1000
+ /** F4-b(2026-10-17):**取原文这一档是直出全文**(不截断 ⇒ 不经 `oneLine`),落单代理会原样进上下文。
1001
+ * 出口统一 `wellFormed` 兜底 —— 与 `renderSearch` 的 wrapper 同一形状(那三档 snippet 已有 `safeSlice`)。 */
1002
+ function renderRecall(v) { return wellFormed(renderRecallText(v)); }
1003
+ function renderRecallText(v) {
999
1004
  // ①档(检索)的回执由 recall-search.js 渲染 —— 它的形状与另外三档完全不同,挤在一处两边都难读。
1000
1005
  if (v && (v.action === 'search' || SEARCH_RECEIPT_REASONS.has(v.reason))) return renderSearch(v);
1001
1006
  if (!v || v.ok !== true) {
@@ -1037,7 +1042,7 @@ function renderRecall(v) {
1037
1042
  flush();
1038
1043
  const body = t.head
1039
1044
  || (t.tools && t.tools.length ? `(调用了 ${t.tools.slice(0, 4).join(', ')}${t.tools.length > 4 ? ' 等' : ''})` : '(无正文)');
1040
- rows.push(`#${t.n} [${t.time}] ${t.role === 'user' ? who : '鱼姬'} ${body}`);
1045
+ rows.push(`#${t.n} [${t.time}] ${t.role === 'user' ? who : '器灵'} ${body}`);
1041
1046
  }
1042
1047
  flush();
1043
1048
  return `${title}\n${rows.join('\n')}`
@@ -1047,7 +1052,7 @@ function renderRecall(v) {
1047
1052
  const rows = v.turns.map((t) => {
1048
1053
  const body = t.text
1049
1054
  || (t.tools && t.tools.length ? `(本轮没有正文,只调用了: ${t.tools.join(', ')})` : '(无正文)');
1050
- return `#${t.n} [${t.time}] ${t.role === 'user' ? (t.human ? who : '(注入)') : '鱼姬'}:\n${body}`;
1055
+ return `#${t.n} [${t.time}] ${t.role === 'user' ? (t.human ? who : '(注入)') : '器灵'}:\n${body}`;
1051
1056
  });
1052
1057
  return `${title}\n\n${rows.join('\n\n')}`
1053
1058
  + (v.remaining > 0
@@ -1398,7 +1403,7 @@ export function registerLingTools(ctx, { gate, memory, settings, library } = {})
1398
1403
  if (c.action === 'create') {
1399
1404
  r = opBranchCreate(memory, { name: c.name, kind: c.kind, parentId: c.parentId || TRUNK_ID });
1400
1405
  // opBranchCreate 沿用了旧 handler 的行为(不写留痕);工具通道必须留 ——
1401
- // 否则主人复盘时看不到"鱼姬凭空多了一条枝"。
1406
+ // 否则主人复盘时看不到"器灵凭空多了一条枝"。
1402
1407
  if (r && r.ok) memory.logBranch(String(r.id || ''), 'create', { after: c.name, note: c.reason, actor });
1403
1408
  } else if (c.action === 'rename') {
1404
1409
  const row = rows.find((b) => b.id === c.id) || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightpainters/dsh-ling",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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/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/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
  },