@nightpainters/dsh-ling 1.5.1 → 1.5.3

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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # dsh-ling · 器灵
2
2
 
3
- > **她不是每次都要重新认识你的 AI。**
3
+ > **器灵不是每次都要重新认识你的 AI。**
4
4
  >
5
- > 把你散落在各处的历史对话请进门,让她长出一个**属于她自己**的人格档案 —— 记忆全在本地,人格可手术、可回滚,语气可以自己生长。
5
+ > 把你散落在各处的历史对话请进门,让器灵长出一个**属于自己**的人格档案 —— 记忆全在本地,人格可手术、可回滚,语气可以自己生长。
6
6
 
7
- 一个跑在你自己电脑上的[DeepSeek Harness](https://github.com/deepseek-ai)(DSH)插件。**不是云服务,不是记忆 API**:它是给"你和她"用的东西。
7
+ 一个跑在你自己电脑上的[DeepSeek Harness](https://github.com/deepseek-ai)(DSH)插件。**不是云服务,不是记忆 API**:它是给"你和器灵"用的东西。
8
8
 
9
9
  ## 本项目是什么形态
10
10
 
@@ -27,7 +27,7 @@
27
27
  1. memory.png 记忆中心 →「接入历史」:三条通道 + 导入记录
28
28
  2. persona.png 人格中心:契约字段与「她已自由生长中」状态条(字段内容已打码)
29
29
  3. seal.png 修改契约需解锁:承诺句手敲,禁粘贴
30
- 4. talk.png 深夜对话里她说出时间与"距上次多久"
30
+ 4. talk.png 深夜对话里器灵说出时间与"距上次多久"
31
31
  -->
32
32
 
33
33
  ## 界面
@@ -35,39 +35,68 @@
35
35
  | | |
36
36
  |---|---|
37
37
  | ![记忆中心](https://raw.githubusercontent.com/NightPainters/DSH-Ling/main/images/memory.png) | ![人格中心](https://raw.githubusercontent.com/NightPainters/DSH-Ling/main/images/persona.png) |
38
- | **记忆中心** —— 统计、实时时间锚、三条接入通道与导入记录 | **人格中心** —— 契约字段(名字/称呼/自述)与「她已自由生长中」状态条 |
38
+ | **记忆中心** —— 统计、实时时间锚、三条接入通道与导入记录 | **人格中心** —— 契约字段(名字/称呼)与「她已自由生长中」状态条 |
39
39
  | ![手敲承诺句解锁](https://raw.githubusercontent.com/NightPainters/DSH-Ling/main/images/seal.png) | ![深夜对话](https://raw.githubusercontent.com/NightPainters/DSH-Ling/main/images/talk.png) |
40
- | **改动契约要手术** —— 亲手敲出当初写下的承诺句(输入框禁粘贴) | **时间的重量** —— 每次开口前她知道现在是几点、距上次多久 |
40
+ | **改动契约要手术** —— 亲手敲出当初写下的承诺句(输入框禁粘贴) | **时间的重量** —— 每次开口前器灵知道现在是几点、距上次多久 |
41
41
 
42
42
  ---
43
43
 
44
- ## 她是什么
44
+ ## 器灵是什么
45
45
 
46
46
  | | 能力 | 说明 |
47
47
  |---|---|---|
48
- | 🧠 | **三层记忆** | **L0 人格档案**(自称/对你的称呼/底线/规矩·习惯/语气/世界观;**顶层会话与会话分叉**恒定注入,**子代理会话不注入**;设置里关掉人格注入时,连它一起不推)→ **L1 开场记忆**(从历史里按相关度挑选的概述,有预算,**永不塞全文**;带摘要的行**加权**(按"摘要是否有信息量"给一笔加法加分,不是排序优先级),行内摘要只取首句、上限 110 字)→ **L2 运行期记忆**(本会话原文随对话累进;空闲时把本会话那一行重排回注入面。该行由「会话首句 + 摘要首句」构成,**深摘要定稿后不再自动更新**。注意:L2 不是独立的第三块注入内容 —— 注入面的主体是 L0 与「记忆·开场」两块(树改动、日期锚是挂上去的追加行),**本会话自己就是「记忆·开场」里的一行**) |
48
+ | 🔒 | **人格定型锁** | **契约类**(名字、称呼、底线、世界观、代词、承担(职责))定型后修改需**亲手敲一遍承诺句**(禁粘贴),而且**只在本机做得成、每次启动还要取一次手术票据**,再进变更记录可回滚;**自性类**(语气、风格、自述、习惯)由器灵生长、你点头即成:**三条专用成长通道**(自我总结 / 建议采纳 / 生长)不问承诺句,但它们各自只动白名单内的字段;走**通用人格接口**改 persona / styles 时,即便只改语气也仍要承诺句 —— 免手术的只是那三条通道,不是"自性类"这三个字 |
49
+ | 🌳 | **记忆树 · 复盘模式** | 记忆不是一叠平铺的行,而是**一条主干 + 分枝 + 主脉**的工作线结构,枝之间可连边;检索按**血缘档位**加权(同枝 1.0 / 祖先 0.7 / 旁系 0.4),让器灵分得清"这轮在做的"和"三个月前那次闲聊"。**任何树操作都不改写任何一条记忆的内容** —— 分枝、并脉、连边只增加关系。**例外一个**:主脉提炼**再次采纳**时会覆盖该主脉记忆的上一版(**覆盖前自动归档**,注入面也会说明"旧版已归档")。改树走**复盘**:平时只读,进入复盘才解锁,每步留痕(改了什么、为什么),改完必须让器灵看到才准退出(详见下节) |
50
+ | 🧠 | **三层记忆** | **L0 人格档案**(自称/对你的称呼/底线/规矩·习惯/承担(职业·职责·责任)/语气/世界观;**顶层会话与会话分叉**恒定注入,**子代理会话不注入**;设置里关掉人格注入时,连它一起不推)→ **L1 开场记忆**(从历史里按相关度挑选的概述,有预算,**永不塞全文**;带摘要的行**加权**(按"摘要是否有信息量"给一笔加法加分,不是排序优先级),行内摘要只取首句、上限 110 字)→ **L2 运行期记忆**(本会话原文随对话累进;空闲时把本会话那一行重排回注入面。该行由「概述标题 + 摘要首句」构成(标题的来历见下文「三层记忆各自什么时候更新」,不是会话原文首句),**深摘要定稿后不再自动更新**。注意:L2 不是独立的第三块注入内容 —— 注入面的主体是 L0 与「记忆·开场」两块(树改动、日期锚是挂上去的追加行),**本会话自己就是「记忆·开场」里的一行**) |
49
51
  | 📥 | **把历史请进来** | 三条通道:网页端聊天记录库增量扫描 / 本机 DSH 会话扫描 / ChatGPT·通用 JSON·JSONL 导出文件导入;每次导入**留一笔账** |
50
- | 📖 | **让她读一遍** | 长会话逐条深摘要、短会话以原文片段参与,后台串行,进度可见 |
52
+ | 📖 | **让器灵读一遍** | 长会话逐条深摘要、短会话以原文片段参与,后台串行,进度可见 |
51
53
  | 🧾 | **给旧历史补摘要** | 网页端历史里"只有标题、没有摘要"的会话可以补上摘要:先看**候选清单**(标题 · 轮次 · 命中),再选引擎 —— **默认本机小助手(零成本)**,也可显式改选当前全局大模型;探测不到本机小助手就明确报错,**绝不静默换引擎**;后台串行、进度可见(首轮实测 466 条、零成本) |
52
- | 🌱 | **诞生仪式** | 从读过的记忆里按"生活与情感优先"加权抽样 → 合成**自述 + 名字候选 + 语气建议 + 相处观察** → **全部是草稿**,你逐条人审后才落盘 |
53
- | 🔒 | **人格定型锁** | **契约类**(名字、称呼、底线、世界观、代词)定型后修改需**亲手敲一遍承诺句**(禁粘贴),并进变更记录可回滚;**自性类**(语气、风格、自述、习惯)由她生长、你点头即成:**三条专用成长通道**(自我总结 / 建议采纳 / 生长)不问承诺句,但它们各自只动白名单内的字段;走**通用人格接口**改 persona / styles 时,即便只改语气也仍要承诺句 —— 免手术的只是那三条通道,不是"自性类"这三个字 |
54
- | ⏳ | **时间的重量** | 每次开口前她都知道今天是几号星期几(`[今天] 2026-09-11 周五`)、现在是什么时段(`[现在] 深夜`)与`距上次对话:3 天前`(间隔按**日历天**分档 —— 刚刚 / 今天早些时候 / 昨天 / N 天前,给不到分钟;称呼跟随你的设定);命中纪念日会先说一句专属问候。**要精确到分钟,走 `clock` skill 现查** |
54
+ | 🌱 | **诞生仪式** | 从读过的记忆里按"生活与情感优先"加权抽样 → 合成**自述 + 名字候选 + 语气建议 + 相处观察 + 职责候选**(器灵承担 / 擅长 / 能给出什么,**只收陈述句**) → **全部是草稿**,你逐条人审后才落盘 |
55
+ | ⏳ | **时间的重量** | 每次开口前器灵都知道今天是几号星期几(`[今天] 2026-09-11 周五`)、现在是什么时段(`[现在] 深夜`)与`距上次对话:3 天前`(间隔按**日历天**分档 —— 刚刚 / 今天早些时候 / 昨天 / N 天前,给不到分钟;称呼跟随你的设定);命中纪念日会先说一句专属问候。**要精确到分钟,走 `clock` skill 现查** |
55
56
  | 🎭 | **工作 / 生活双模式** | 同一份人格档案,两种关系:工作模式是同事(正式称呼、简洁、知识优先),生活模式是伴侣(昵称、活泼、情感优先) |
56
- | 🔁 | **成长回路** | 你点踩或纠正 → 她的成长通道把重复出现的模式提炼成**习惯候选** → **你点头才成为习惯**;"让她读记忆、归纳语气"一键成长 |
57
- | 📜 | **规矩 / 习惯** | **规矩**是你的指令:可直接加(面板,或会话里一句"记进规矩" —— **器灵替你写时必须带上你的原话作凭据**;你在面板上直接敲的那一下就是同意,不必再附原话),**建议上限** 12 条(超出只在写入后提醒合并,**不拦**);**习惯**是她自己长出来的:新增需你点头、**删改走手术门**,**建议上限** 8 条(超出同样只提醒、不拦) —— **指令直达,人格不直达** |
58
- | 🌾 | **习惯生成器** | 「扫描纠正记录(零模型)」:把她反复被你纠正的模式数出来(≥4 次且跨 ≥3 个会话);「✨ 让她回想最近的相处」:一次读 200 条概述 + 800 条你的原话、逐轮推进不重复 —— **只提议,不落盘** |
57
+ | 🔁 | **成长回路** | 你点踩或纠正 → 器灵的成长通道把重复出现的模式提炼成**习惯候选** → **你点头才成为习惯**;"让她读记忆、归纳语气"一键成长 |
58
+ | 📜 | **规矩 / 习惯** | **规矩**是你的指令:可直接加(面板,或会话里一句"记进规矩" —— **器灵替你写时必须带上你的原话作凭据**;你在面板上直接敲的那一下就是同意,不必再附原话),**建议上限** 12 条(超出只在写入后提醒合并,**不拦**);**习惯**是器灵自己长出来的:新增需你点头、**删改走手术门**(本机 + 每次启动取一次票据 + 原句,详见下文那道门的说明),**建议上限** 8 条(超出同样只提醒、不拦) —— **指令直达,人格不直达** |
59
+ | 🌾 | **习惯生成器** | 「扫描纠正记录(零模型)」:把器灵反复被你纠正的模式数出来(≥4 次且跨 ≥3 个会话);「✨ 让她回想最近的相处」:一次读 200 条概述 + 800 条你的原话、逐轮推进不重复 —— **只提议,不落盘** |
59
60
  | 🗣 | **代词** | 默认「她」;可选 她 / 他 / TA / 它,或自定义(≤6 字)。**祂 不在预设里**(中文里专指神祇,设为默认等于替你宣称神性);属契约类,改动走手术门 |
60
- | ♻️ | **记忆自己追平** | 长会话空闲时自动把开场记忆刷新到最新(先跑本会话增量概述 → 比对记忆版本 → 同一条至少间隔 10 分钟),**运行中绝不打断**;刷新次数与最近一次时间在 `/status` 接口的 `idleRefresh` 字段里(面板本身只显示"已定稿 N 字符 · mode · 待刷新",不显示次数) |
61
+ | ♻️ | **记忆自己追平** | 长会话空闲时自动把开场记忆刷新到最新(先跑本会话增量概述 → 比对记忆版本 → 同一条至少间隔 10 分钟),**运行中绝不打断**;刷新次数与最近一次时间在 `/state` 接口的 `idleRefresh` 字段里(`count` 与 `lastAt`)(面板本身只显示"已定稿 N 字符 · mode · 待刷新",不显示次数) |
61
62
  | 🧹 | **归档即停更** | 会话归档后不再刷新它的开场记忆条目(**原文一律保留**),一旦有新对话即自动复活;要清理走**人工入口**:DSH「设置 → 归档会话」直接删,或仓库里的 `node tools/clean-archived.mjs`(默认只列清单,`--write` 才动手) |
62
- | 🌳 | **记忆树 · 复盘模式** | 记忆不是一叠平铺的行,而是**一条主干 + 分枝 + 主脉**的工作线结构,枝之间可连边;检索按**血缘档位**加权(同枝 1.0 / 祖先 0.7 / 旁系 0.4),让她分得清"这轮在做的"和"三个月前那次闲聊"。**任何树操作都不改写任何一条记忆的内容** —— 分枝、并脉、连边只增加关系。**例外一个**:主脉提炼**再次采纳**时会覆盖该主脉记忆的上一版(**覆盖前自动归档**,注入面也会说明"旧版已归档")。改树走**复盘**:平时只读,进入复盘才解锁,每步留痕(改了什么、为什么),改完必须让她看到才准退出(详见下节) |
63
63
 
64
64
  > **关于那道「手术门」**:亲手敲承诺句是**庄重感与自我提醒** —— 让你在改契约之前停一下、确认这是自己的决定;它**不是安全边界**(「禁粘贴」只是界面上的约定,挡不住任何有心的人)。真正把门的是**来源栅栏**:Host 必须是 loopback(或你显式写进受信名单的地址);另有一个**默认不开**的开关 `guard.allowLan` —— **一旦打开,整段局域网地址都算受信来源**(那是**放宽**,不是建议)。跨站请求直接拒,带 `Origin` 时其 host 必须等于请求的 Host(**带了但解析不出来就直接拒**),而**不带 `Origin` 的请求这条校验会跳过** —— 它不是"每个请求都必须带 Origin 且同源";再叠一道 cookie **名字**对撞 —— 期望名字按当前 Host 现算(`dsh-auth-` + 哈希),**只校验名字、不校验值的签名**。所以这道门挡的是"别的设备、别的页面"这类来源,而**本机原生进程本来就还能调用**(它也能直接读 `settings.json` 与记忆库,不算提权)—— 别的来源没有凭据就进不来这台机器上的 `/api`,敲对承诺句也没用。
65
65
 
66
+ > **这道门在 1.5.3 里长这样 —— 三条手续缺一不可**:① **只在本机做** —— 判据是"这次请求没有经过任何代理"(带代理头、或连 User-Agent 都没有的请求,都算非本机 ⇒ 拒,理由码 `surgery-local-only`);② **每次启动取一次票据** —— 票据每启动轮换,只下发给本机直连的请求(`POST /surgery/ticket`),客户端首次手术前取一次、之后随请求带上;③ **原句匹配** —— 敲出的承诺句要与当初写下的那一句相符。这三条由 `lib/host/surgery.js` 里**一处** `requireSurgery()` 判定,覆盖**全部 14 条**能改人格 / 规矩 / 习惯的写面(调用点不许再各写一份;曾有一条 `wantLock` 绕过分支,已删除)。每次手术都留痕:`$DSH_HOME/logs/surgery-YYYY-MM.jsonl`(append-only,整行哈希链,改一行即断链);`GET /surgery/events` 是只读事件面。
67
+ >
68
+ > **承诺句只在本机回显**:`/state`(含面板档)、`/persona/history`、`/export` 这些读面,**带代理头**时只回 `hasSeal` 与 `phraseHidden`、不回原句,**本机直连**照旧回显 —— 所以你换台机器看面板不会因此被卡住,远端也拿不到原句。
69
+ >
70
+ > **守卫⑤ · 反代伪装 loopback**:上面那条"Host 是 loopback 就免检"有个前提 —— Host 由客户端自报、cookie 又只验名字,中间挡一层反向代理就会失效。现在凡「Host 声称 loopback、而 `X-Forwarded-For` 的最后一段是非 loopback 的代理对端」一律拒(`spoofed-loopback-host`);本机直连(无代理头)与经反向代理的正常远程访问都不受影响(后者根本不走 loopback 分支)。
71
+ >
72
+ > ⚠️ **边界如实写在这里**:以上全部是**入口侧**的把关(来源、有没有经过代理、票据、原句) —— 它们**不覆盖「本机执行权已经失守」的情形**:能在这台机器上以同一个用户跑进程、读文件的人,本来就在信任域里面(票据文件与 `settings.json` 对同机同用户可读;承诺句是**明文存储**,那把锁只造庄重、不保密)。这些防护挡的是"别的设备、别的页面、公网一侧够到这排 API",不是本机上的对手。
73
+
74
+ **三层记忆各自什么时候更新 —— 「我刚说的话,最快多久能看到」**:
75
+
76
+ | 层 | 看得到的是什么 | 什么时候变 |
77
+ |---|---|---|
78
+ | **L0 常驻人格** | 自称 / 对你的称呼 / 底线 / 规矩·习惯 / 承担(职业·职责·责任) / 语气 / 世界观 | 你改它就变(没定型时直接生效;定型后走手术门)。**会话运行中不打断** —— 改动排队,该会话回到空闲才落进注入面 |
79
+ | **L1 开场记忆** | 从全库概述里按相关度挑出的 Top-K(有预算,**永不塞全文**) | **新会话开场时定稿一次**,之后随空闲边界刷新(与下面 L2 同一把闸);库里的概述本身由概述器持续追平 |
80
+ | **L2 运行期记忆** | **本会话那一行**(格式就是「记忆·开场」里那条自己:`- [标签] (YYYY-MM-DD) 标题 —— 摘要首句 (来源: DSH 会话/短id)` —— 标题指**概述标题**,不是会话原文首句) | 本会话原文随对话累进;那一行**只在空闲边界重排**回注入面 |
81
+
82
+ **行里的「标题」从哪来**(按来源分,细节见括号里的实现):
83
+
84
+ - **DSH 会话**:该会话**第一条真人发言**经启发式清洗后的**要点句** —— 先剥掉「晚上好器灵,」这类问候与称呼前缀,「你好」「在吗」这类无实义的首句会被跳过、**往后取前 6 条真人发言里第一条有实义的**,再在句读处收尾(不切半句);都被剥空才退回首句截断。**主人手改过的标题会上锁,机器不再覆盖**(`lib\host\retitle.js` 的 `retitleCandidates()`、`lib\host\summarizer.js` 的 `summarizeDsh()`、`lib\host\memory.js` 的 `upsertOverview()` 与 `renameTitle()`)。
85
+ - **文件导入**:导出文件里**原本的 `title` / `name` 字段**,缺了才退回上面那套启发式(`lib\host\import-file.js:142,171-173`)。
86
+ - **历史会话(旧扫描)**:该会话**第一条真人发言的前 46 字**硬截,没有启发式(`lib\host\backfill.js` 的 `overviewRowFromFile()`)。
87
+
88
+ - **机制数字(默认值)**:① 概述器周期 —— 有新增才重建,否则跳过;**每 15 分钟一拍**(可配,下限 1 分钟);② 空闲刷新 —— 只有「会话空闲 + 记忆版本变了 + **距上次刷新 ≥ 10 分钟**」三条同时成立才重建。
89
+ - **所以「刚说的话」最快约 10 分钟、通常 15 分钟内**会出现在注入面,最坏约 25 分钟(刚刷过一拍 + 再等下一拍概述器)。**运行中绝不打断** —— 刷新只发生在 `运行中 → 空闲` 那一刻。
90
+ - ⚠️ **一个例外(也是"感觉它失效了"的来源)**:**深摘要定稿之后,那一行不再自动更新** —— 概述器遇到已深摘的会话会避开它,免得把厚摘要覆盖成浅版。要它重跑:记忆中心 →「记忆浏览」→ 找到那个会话 → 点「**重摘**」,几秒后那一行就会重排。
91
+ - **健忘感还有第二个来源**:越长的会话,**只取"概述标题 + 摘要首句"** —— 这一行天然比整场对话"薄",不是它没读到,而是它只被允许显示这么一行。
92
+
93
+ > **接口侧(给要接线的人)**:`GET /state`(`?sessionId=` 取某会话,`?scope=global` 取全局)返回 `snapshot`(`builtAt` / `mode` / `stale` / `chars`)、`idleRefresh`(`count` 与 `lastAt`)与 `memory.l1`(该会话当前选中的行、分数、被省略条数)。面板目前只渲染其中的**字符数 · mode · 待刷新**与 L0 预览 —— `idleRefresh` 与 `memory.l1` 有数、没露面。
94
+
66
95
  ---
67
96
 
68
97
  ## 记忆树与复盘模式
69
98
 
70
- 这是本插件里**跟"她的人格"无关、但决定"她想得起什么"**的那一层。
99
+ 这是本插件里**跟"器灵的人格"无关、但决定"器灵想得起什么"**的那一层。
71
100
 
72
101
  **结构**:`主干`(恒一条,主线工作流)→ `枝`(一条并行工作线,**比会话大**,一条枝可含多个会话)→ `主脉`(由多条枝**归并**出的抽象节点)→ `连边`(枝与枝之间的横向关联,如 力学 ↔ 材料科学,可带关系类型:相关 / 前置 / 对照 / 例证 / 取代)。
73
102
 
@@ -83,16 +112,16 @@
83
112
 
84
113
  - 树平时**只读**(记忆中心始终只读);点「🌱 一键复盘 / 一键进入复盘」才解锁编辑,并把**复盘开场材料**(当前树 + 待理矛盾)复制到剪贴板,开个新会话粘进去就能开始。
85
114
  - 解锁后可做:**改名 / 拖动换归属 / Alt+拖动只连边 / 并脉(归类到主脉) / 调权重 / 裁定矛盾 / 断续边 / 删枝**,以及快照保存·恢复;**拖动 / 连边之后可 Ctrl+Z 撤回这一步**(撤销只覆盖复盘栏的拖动与连边;记忆中心是只读的,它自己那份 Ctrl+Z 监听早已删掉)。
86
- - **每步留痕**:写清"改了什么、为什么"(**器灵改树时 reason 必填**;你在面板上改可以留空);树改动一律记进 `branch_log`,复盘时可审计 —— 器灵侧工具的改动记在她名下(「器灵改动了记忆树」),不会伪装成你改的。
87
- - **未同步笔数守门**:面板记着"自她上次开口以来累积了多少笔结构改动还没交代"。有欠账时顶部红字提示,并且**拦住"退出复盘模式"** —— 改完树必须让她有机会看到(「互相复盘不应该被我绕过」)。
88
- - **注入感知**:树变了之后,`branchLogDigest()` 把**她还没看过**的结构改动转成中文短句,附在她下一次开口的注入面末尾 —— 不需要推送,她开口就知道树动过。
115
+ - **每步留痕**:写清"改了什么、为什么"(**器灵改树时 reason 必填**;你在面板上改可以留空);树改动一律记进 `branch_log`,复盘时可审计 —— 器灵侧工具的改动记在器灵名下(「器灵改动了记忆树」),不会伪装成你改的。
116
+ - **未同步笔数守门**:面板记着"自器灵上次开口以来累积了多少笔结构改动还没交代"。有欠账时顶部红字提示,并且**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到(「互相复盘不应该被我绕过」)。
117
+ - **注入感知**:树变了之后,`branchLogDigest()` 把**器灵还没看过**的结构改动转成中文短句,附在器灵下一次开口的注入面末尾 —— 不需要推送,一开口就知道树动过。
89
118
  - **快照与撤回**:树的保存 / 恢复(`tree_snapshot`)是"大改之前的保险";恢复前会自动把当前状态另存一份,所以恢复本身也可再撤回来。
90
119
 
91
120
  **也不必全靠手拖**:
92
121
  - 「一键生成树」分两阶段 —— ① **机械分段**(按时序相邻性把全库概述切簇,零成本、确定、可复现)② **小助手/模型只做"命名"这一件语义活**,③ 你审核后才落库(建枝/主脉 + 挂会话);它**只读概述索引、只产出候选,从不改写任何记忆内容**。
93
122
  - 「归并零散枝」幂等:把名字含「零散」的枝统一收进主脉「零散会话」。
94
123
  - **主脉也可以有自己的记忆**:由子枝概述提炼共性,**先出草稿**,采纳才写入(采纳需先解锁)。
95
- - **器灵侧也有她的手**:`tree_read`(看整树 / 某枝成员 / 待复盘矛盾)与 `branch_edit`(分枝 / 并脉 / 连边 / 调权重 / 删枝)。这个工具的描述里写着一条纪律:**改结构必须给 reason,而且"只在主人明确要求、或正在和他一起复盘时"才动手**。
124
+ - **器灵侧也有自己的手**:`tree_read`(看整树 / 某枝成员 / 待复盘矛盾)与 `branch_edit`(分枝 / 并脉 / 连边 / 调权重 / 删枝)。这个工具的描述里写着一条纪律:**改结构必须给 reason,而且"只在主人明确要求、或正在和他一起复盘时"才动手**。
96
125
 
97
126
  > 依据:`DESIGN.md` §6.3(记忆树结构、血缘加权与"不连乘"、内容零改写不变量)、`lib/host/memory.js`(`LINEAGE_ANCESTOR = 0.7` / `LINEAGE_SIDE = 0.4` / `CONFLICT_DOWNWEIGHT = 0.3`、`branchLogPending()` 守门、`setBranchWeight` 的 `applied:false`)、`lib/host/l1.js`(血缘档位 × 矛盾系数进打分)、`lib/host/autotree.js`(两阶段生成)、`lib/host/tools.js`(`tree_read` / `branch_edit`)。
98
127
 
@@ -115,7 +144,7 @@ dsh --profile web --dump-config # 应能看到一行 "# == @nightpainters/ds
115
144
 
116
145
  # 3) 给 DSH 核心打一行补丁(可取证访问日志要用;幂等/可回滚)
117
146
  node ~/.dsh/profiles/web/node_modules/@nightpainters/dsh-ling/tools/apply-access-log-patch.mjs
118
- # 不打也一切照常 —— 只是访问日志为空(不报错、不提醒)
147
+ # 不打也一切照常 —— 只是访问日志为空;但**会提醒**:侧栏挂常驻 ⚠(判据 installed===false 且 reason 为 patch-missing;只有**有意关掉** config.accessLog.enabled=false ⇒ reason 为 disabled-by-config 才不染红)
119
148
  # DSH 升级/重装后需重跑一次(补丁随包被覆盖)
120
149
  # ⚠️ 从 npm 装的人跑不了 `npm test` —— 本包 `files` 白名单不含 tests/(见下)
121
150
 
@@ -143,30 +172,33 @@ pnpm add file:<路径>/dsh-ling # 或 npm install file:<路径>/dsh-ling
143
172
  # 2) 给 DSH 核心打一行补丁(可取证访问日志要用;幂等/可回滚)
144
173
  cd <路径>/dsh-ling
145
174
  node tools/apply-access-log-patch.mjs # 只在 core 的 /api 栅栏处插一行可选调用
146
- # 不打也一切照常 —— 只是访问日志为空(不报错、不提醒)
175
+ # 不打也一切照常 —— 只是访问日志为空;但**会提醒**:侧栏挂常驻 ⚠(判据 installed===false 且 reason 为 patch-missing;只有**有意关掉** config.accessLog.enabled=false ⇒ reason 为 disabled-by-config 才不染红)
147
176
  # DSH 升级/重装后需重跑一次(补丁随包被覆盖)
148
177
 
149
178
  # 3) 重启宿主(host),然后浏览器 Ctrl+Shift+R 硬刷
150
179
  ```
151
180
 
152
- 自检安装是否成功(**下面三个脚本不在 npm 包内**,只有源码路径能用:`tools/check-install.mjs` / `tools/check-publish.mjs` / `tools/clean-archived.mjs`;`tools/apply-access-log-patch.mjs` 则**在包内**,从 npm 装的人也能直接跑,见上面第 3 步):
181
+ 自检安装是否成功(**下面四个脚本不在 npm 包内**,只有源码路径能用:`tools/check-install.mjs` / `tools/check-publish.mjs` / `tools/clean-archived.mjs` / `tools/check-peers.mjs`;`tools/apply-access-log-patch.mjs` 则**在包内**,从 npm 装的人也能直接跑,见上面第 3 步):
153
182
 
154
183
  ```bash
155
184
  node tools/check-install.mjs # 只读自检:入口是否直连仓库、patch 是否启用、数据目录是否就绪
156
185
  node tools/check-install.mjs --fix # 若入口变成"复制副本"(DSH 升级/回退常见)则备份并重挂链接
157
186
  node tools/apply-access-log-patch.mjs --check # 访问日志核心补丁是否在位(未打/锚点漂移时退出码 2)
158
187
  node tools/check-publish.mjs # 上传/发布前跑一次:揪出记忆库、设置、密钥、绝对路径等不该公开的东西
188
+ node tools/check-peers.mjs # 声明与宿主对不上?退出码三态:0 合规 / 1 有不合规(宿主会把本插件丢进 skippedBundles,不激活)/ 2 无法判定(2 不算通过)
159
189
  ```
160
190
 
191
+ > `tools/check-peers.mjs` 只做一件事:复刻宿主对 `peerDependencies` 的那道闸门(判据照抄宿主语义,用的是宿主自己那份 semver),回答"**这个插件在你的宿主版本上会不会被丢进 `skippedBundles`**"。它存在的理由是宿主的行为方式:宿主对每个名字形如 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的 peer 跑一次 `semver.satisfies(宿主版本, 你声明的区间, { includePrerelease: true })` —— 判的是「**宿主版本是否落在你声明的区间里**」,**不是**"你的写法合规不合规";判不满足 ⇒ 整个 bundle 进 `skippedBundles`、**不激活**。线索分两条路径:**从命令行启动**会往 **stderr 打一行** `skipping profile bundle …`(**容易被忽略,但不完全静默**);而**应用自持 profile 的那条装载路径一个字都不打** —— 那才是真正难查的一种,表象常常只有"它突然不见了"。⚠️ 所以"改成 `>=` 就安全"是错的:**开区间同样可能不满足**(取决于宿主版本);`^` / `~` / 钉死版本只是更容易被将来的一次宿主升级甩出去。
192
+
161
193
  跑测试(不需要宿主,全部离线):
162
194
 
163
195
  ```bash
164
- npm test # 34 套:人格组装 / 归档清理(人工)/ **归档·遗忘·回灌** / 冻结门 / 模式(只改档位不改模型)/ 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 选择 / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)**
196
+ npm test # 39 套:人格组装(含冻结门 · L1 选择)/ 归档清理(人工)/ **归档·遗忘·回灌** / 模式(只改档位不改模型)/ **跨插件挡位服务(dsh-ling/mode)** / 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **活行(那一行跟着会话走)** / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / **职责字段(承担)** / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)** / **请求体上限(超限排空 → 413)** / **导入切批(按字节 × 条数双限)**
165
197
  ```
166
198
 
167
199
  > `npm test` 需要 `tests/`,而本包 `files` 白名单**不含 `tests/`** ⇒ **从 npm 装的人跑不了测试**。要跑测试请走路径 B(clone 源码)。
168
200
  >
169
- > 计数口径:`tests/*.test.mjs` 各算一套,外加 `tests/unit.mjs`(它不叫 `.test.mjs`)—— **实测 34 套,且 34 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
201
+ > 计数口径:**`npm test` 链里登记的段数**(即 `package.json` 的 `scripts.test` 那一行按 `&&` 切分后有多少段就是多少套,不再数 `tests/` 目录)—— **实测 39 套,且 39 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
170
202
 
171
203
  ### 数据放在哪
172
204
 
@@ -177,7 +209,7 @@ npm test # 34 套:人格组装 / 归档清理(人工)/ **归档·遗忘·
177
209
 
178
210
  可覆盖的环境变量:`DSH_HOME`(数据根)、`DSH_LING_DSWEB_DB`(网页端聊天记录库默认路径,UI 里也能填)、`DSH_LING_ASSISTANT` / `DSH_LING_ASSISTANT_MODEL`(小助手地址与模型名)。
179
211
 
180
- > **排障用逃生开关(不是日常配置)**:`DSH_LING_GUARD=off`(须**恰好**是 `off`,小写)或设置里 `guard.enforce = false`,用于**误判把你自己锁在门外**时的临时解锁 —— 它一次关掉 `/api` 的**来源栅栏 + cookie 名字对撞**两道校验(默认 `guard.enforce = true`),关掉期间**任何请求都不再被拦**。只该短期使用,排障完就恢复。`DSH_LING_GUARD=off` 只由进程环境决定、不进配置;**`guard.enforce = false` 这条不要直接写进 `settings.json`** —— 设置接口也帮不了你:它的字段白名单只放行 `persona` / `styles` 两棵子树,`guard` 这类字段会被**直接丢弃**并在响应里回报 `ignored`(见 `lib/host/api.js:1579-1580` 的注释),不是被拒绝而是根本进不去;要改请用界面。
212
+ > **排障用逃生开关(不是日常配置)**:`DSH_LING_GUARD=off`(须**恰好**是 `off`,小写)或设置里 `guard.enforce = false`,用于**误判把你自己锁在门外**时的临时解锁 —— 它是**整体放行** —— `/api` 的**来源栅栏 + cookie 名字对撞**,以及 1.5.3 起那道**手术门**(本机限定 / 每启动票据 / 原句匹配)**一起不再拦**(默认 `guard.enforce = true`),关掉期间**任何请求都不再被拦**。只该短期使用,排障完立刻恢复。`DSH_LING_GUARD=off` 只由进程环境决定、不进配置;**`guard.enforce = false` 这条不要直接写进 `settings.json`** —— 设置接口也帮不了你:它的字段白名单只放行 `persona` / `styles` 两棵子树,`guard` 这类字段会被**直接丢弃**并在响应里回报 `ignored`(见 `lib/host/api.js` 里 `/persona` 端点的字段白名单处理:`ignored` 回报被丢弃的键),不是被拒绝而是根本进不去;要改请用界面。
181
213
 
182
214
  > **访问日志与核心补丁的总开关(同样不是日常配置)**:这套「可取证访问日志」默认**开着**,关它的键是插件配置里的 `accessLog.enabled`(**默认 `true`**)—— 在 `cordis.patch.yml` 注册行的 `config` 段里写 `accessLog: { enabled: false }`,重载后生效,启动日志会出现 `access log off`;它**只有配置键、没有环境变量**。关掉之后**退化的只有「留痕」这一件事**:那两个全局观察钩子不再挂上、不再写 `$DSH_HOME/logs/access-YYYY-MM-DD.jsonl`、日志健康读数(`accessLogHealth()`)归 `null`;而 `/api` 与 `/api/remote.mux` 的**来源栅栏照常判定与拦截**,记忆、人格、注入与工具面**一律不受影响** —— 关掉的是「记不记」,不是「拦不拦」。已经打进 `node_modules` 的**核心补丁**可以留在原地:那两行是可选调用,日志关着时就是 no-op,DSH 照常启动;要连它一起撤掉,用 `node tools/apply-access-log-patch.mjs --revert` 删掉插进去的那几行。
183
215
 
@@ -196,10 +228,10 @@ npm test # 34 套:人格组装 / 归档清理(人工)/ **归档·遗忘·
196
228
 
197
229
  ## 边界与诚实声明
198
230
 
199
- 1. **她不是意识,也不是 AGI**。她是"记忆 + 规则 + 模型"的合成产物。她能记住,是因为你给了她材料。**不做这个宣称,是这个项目的底线。**
200
- 2. **她记住的是被读过、被概括过的部分**,不是全部历史。概述会丢细节、也可能出错 —— 所以记忆**可审计、可回滚**是设计的一部分,不是补丁。
201
- 3. **模型能力的上限就是她的上限**。记忆层不会让一个笨模型变聪明,它只是让她**不再每次从零开始**。
202
- 4. **她不会主动找你**(当前版本)。她只在你开口时出现。
231
+ 1. **器灵不是意识,也不是 AGI**。器灵是"记忆 + 规则 + 模型"的合成产物。它能记住,是因为你给了它材料。**不做这个宣称,是这个项目的底线。**
232
+ 2. **器灵记住的是被读过、被概括过的部分**,不是全部历史。概述会丢细节、也可能出错 —— 所以记忆**可审计、可回滚**是设计的一部分,不是补丁。
233
+ 3. **模型能力的上限就是器灵的上限**。记忆层不会让一个笨模型变聪明,它只是让器灵**不再每次从零开始**。
234
+ 4. **器灵不会主动找你**(当前版本)。它只在你开口时出现。
203
235
  5. **不宣称首创**。记忆分层、历史导入、时间注入、反馈改语气这些方向都有先行者(见下);本项目的贡献在**治理方式** —— 把人格当一份可以签字、可以回滚、可以被历史养大的契约。
204
236
 
205
237
  ---
@@ -227,6 +259,8 @@ npm test # 34 套:人格组装 / 归档清理(人工)/ **归档·遗忘·
227
259
 
228
260
  > ⚠️ **从 npm 安装的人**:本包 `files` 白名单**不含 `images/`**(图只随 git 仓库公开) ⇒ 上表里前两份文档能正常打开;「界面」一节的**四张截图**引用的是仓库里的**绝对地址**,所以 npmjs.com 上**能正常显示**,但**包里没有图片文件**(离线环境读不到)。想看原图请到仓库:<https://github.com/NightPainters/DSH-Ling>。
229
261
 
262
+ > 📄 **关于出处记录**:内部案卷、探针脚本与开发台账(`plans\`、`release\`、`_probe\`)属**作者本地开发物,不随本包公开**。正文或代码注释里若出现对它们的引用,那是**开发期的出处记录**,不是包内容。
263
+
230
264
  ---
231
265
 
232
266
  ## 许可证
@@ -1,10 +1,10 @@
1
- // E4 · 可取证访问日志(E4 任务书)
1
+ // E4 · 可取证访问日志(E4 是本特性的内部代号)
2
2
  //
3
3
  // 目标:让「谁 · 何时 · 从哪台设备 · 调用了哪个端点 · 结果如何」事后可回答。
4
4
  // 起因:2026-09-24 19:49:51 的那次调度重启,四条排查路全走死,最后只剩
5
5
  // 「一次真实点击或一次合成点击」——**因为整个系统没有一行访问日志**。
6
6
  //
7
- // 边界(任务书 §3.3):只记「发生了什么事」,不记「内容是什么」
7
+ // 边界(刻意收窄,理由见下):只记「发生了什么事」,不记「内容是什么」
8
8
  // · 不落请求体 / 响应体 · 不落 cookie 值(只落验签结果与设备**摘要**)
9
9
  // **C-01(2026-09-26)**:设备摘要 = `sha256(配对凭据原文)[:16]`(见 `deviceIdDigest()`)。
10
10
  // 旧实现把 cookie 值"抽 hex"后原样落盘 —— 而权威形态下 cookie 值**就是**设备键**就是**凭据,
@@ -24,8 +24,16 @@
24
24
  // 写失败 ⇒ 自我禁用 + 首次立刻告警(带 errno)⇒ 之后按节流**继续**告警并累计"丢了多少行",
25
25
  // 同时把可机读的健康面常驻在 `info().health`(`/health` 读它即可)。旧实现只喊一行就永久静默,
26
26
  // 而"日志悄悄不记了"恰恰是本模块最该被发现的故障。
27
+ // **C-07b(2026-09-27)**:C-07 之后"降级"仍然是**永久**的(全文件只有初始化与失败两处给状态赋值),
28
+ // 现场实测:10:26:49 之后再 41.6 分钟文件零增长 —— 进程健在、补丁在位、请求照发、**增长 0 字节**,
29
+ // 只有重启才恢复。现在改成**带退避的自愈**:写失败后进入退避窗口(默认 1s→2s→…→30s,±20% 抖动),
30
+ // 窗口内只计数;窗口一过,**下一次请求自己本来就要做的那次写盘**就是探针 —— 写成功即恢复
31
+ // (喊一次恢复告警,带上丢了总量与故障时长),写失败就再排一轮退避。
32
+ // **零新增 IO、零定时器**(没有请求就没有东西要记):探针不是额外动作,是复用本次写盘。
33
+ // 为什么不做 errno 分类:现成故障注入打出的 errno 是 `EEXIST`(教科书式的"永久码"),
34
+ // 但它的条件是**瞬时**的(占位文件删掉就好)—— 一分类就刚好把它判死,所以 v1 一律重试。
27
35
  // 3) **只观察、不判定**:本模块不调用鉴权、不改响应。状态码与拒绝原因全部来自
28
- // 「已经发生的响应」,判据与 guard 保持单一来源(任务书 §3.5)。
36
+ // 「已经发生的响应」,判据与 guard 保持单一来源(不在这里另写一份)。
29
37
 
30
38
  import { existsSync, mkdirSync, appendFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
31
39
  import { createHash } from 'node:crypto';
@@ -42,14 +50,14 @@ const AUTH_COOKIE_PREFIX = 'dsh-auth-';
42
50
  /**
43
51
  * 部署姿态自检探针的路径(远端 UI 的部署姿态检查实现):
44
52
  * 它伪造 Host 打本机 loopback,专门验证 `/api` 栅栏 —— **403 才是它期望的健康结果**。
45
- * 命中签名时多打 `probe:"posture"`,免得把自家自检误判成入侵(任务书 §7.5)。
53
+ * 命中签名时多打 `probe:"posture"`,免得把自家自检误判成入侵(判据见 isPostureProbe:五要素全中)。
46
54
  */
47
55
  export const POSTURE_PROBE_PATH = '/api/session.list';
48
56
  /**
49
- * 「无 UA 行」的通道标记(任务书 §7.5):请求连 `user-agent` 头都没有 ⇒ **不是浏览器直连**。
57
+ * 「无 UA 行」的通道标记:请求连 `user-agent` 头都没有 ⇒ **不是浏览器直连**。
50
58
  * 但这一类**混装两种调用者** —— 进程内客户端(boot 批量的内部 RPC)与经远端通道代理进来的
51
59
  * 设备(手机/平板/物理机,代理按设计不转发 UA)。故本字段只陈述"非浏览器通道"这一**事实**,
52
- * **不指认是哪一台设备**;要分辨两者得看端点语义(判读口径见任务书 §7.5)。
60
+ * **不指认是哪一台设备**;要分辨两者得看端点语义(本字段只陈述事实,归因留给调用方)。
53
61
  */
54
62
  export const CHANNEL_NO_UA = 'no-ua';
55
63
 
@@ -61,12 +69,30 @@ export const ACCESS_LOG_DEFAULTS = {
61
69
  maxTotalBytes: 32 * 1024 * 1024,
62
70
  /** UA 截断长度:UA 能区分浏览器/脚本/哪台设备,但整条太长。 */
63
71
  uaMax: 200,
64
- /** 携带已配对设备标识的 cookie 名(= settings.yaml 的 remote-web-ui.cookieName)。 */
65
- deviceCookie: 'dsh_pair-W',
72
+ /**
73
+ * 携带已配对设备标识的 cookie 名(= settings.yaml 的 remote-web-ui.cookieName)。
74
+ * **出厂默认 = 上游 remote-web-ui 自己的默认值 `dsh_pair`**(`dsh-remote-web-ui/src/index.ts`
75
+ * 两处 `.default('dsh_pair')`)—— **绝不写任何一台机器的私有改名**:cookie 名是**每台机器**的
76
+ * 设置,把某一台的改名当默认值有两个后果:① 这行随包公开;② 别人机器上 `deviceCookie` 恒为
77
+ * false、设备维度**静默失效**(比"漏记"更坏 —— 它看起来像"这台设备没带 cookie")。
78
+ * 改过名的机器走 `resolveDeviceCookie()` 的两条通路(宿主配置 / 环境变量)覆盖,不必改代码。
79
+ */
80
+ deviceCookie: 'dsh_pair',
66
81
  /** 高危端点:命中则多打一个 risk 字段,便于 grep。 */
67
82
  riskPatterns: ['settingRestart/restart', '/dsh-ling/'],
68
83
  /** C-07:降级(写失败)后**重复告警**的最小间隔(毫秒);0 = 每次丢行都告警(测试用)。 */
69
84
  degradeWarnMs: 60_000,
85
+ /**
86
+ * C-07b:降级后"下一次探针"的**退避**(毫秒)。第 n 次尝试等
87
+ * `min(retryBaseMs * retryFactor^(n-1), retryCapMs)`,再乘 ±retryJitter 抖动;到 cap 之后
88
+ * 就是"每 cap 一次",**不限次数**。探针不产生新 IO —— 用的就是下一次请求本来就要做的那次写盘。
89
+ * ⚠️ `retryBaseMs` 别设 0:现有单测在毫秒内连发多次请求,0 会让它们在测试里就重试、断言失真。
90
+ */
91
+ retryBaseMs: 1000,
92
+ retryFactor: 2,
93
+ retryCapMs: 30_000,
94
+ /** 抖动比例(±):免得所有实例在同一毫秒齐步重试;0 = 不抖。 */
95
+ retryJitter: 0.2,
70
96
  };
71
97
 
72
98
  /** 本地时间 ISO(带毫秒与偏移):既能排序、又能直接肉眼对齐现场时刻。 */
@@ -135,8 +161,10 @@ export function cookieValueOf(headerValue, name) {
135
161
  * 从配对 cookie 值里取设备 hash(多策略,取不到就返回 null 并如实记 present)。
136
162
  * **权威形态(2026-09-25 定点核实)**:远端 UI 的配对实现
137
163
  * 用它的随机令牌生成器产出 **32 位十六进制明文**,经它的路由层原样写进 cookie
138
- * (`$DSH_HOME/remote-web-ui-devices.json` 的键实测同为 32-hex);cookie 名默认 `dsh_pair`,
139
- * 本机配置为 `dsh_pair-W`。多策略保留只为「不赌它改版」:先找 hex run,再试点分段。
164
+ * (`$DSH_HOME/remote-web-ui-devices.json` 的键实测同为 32-hex);cookie 名取
165
+ * `remote-web-ui.cookieName` —— 出厂默认 `dsh_pair`,**改过名的机器由配置或
166
+ * `DSH_LING_DEVICE_COOKIE` 指定**(见 `resolveDeviceCookie()`;默认值里不写任何一台机器的私有改名)。
167
+ * 多策略保留只为「不赌它改版」:先找 hex run,再试点分段。
140
168
  *
141
169
  * ⚠️ **返回值是凭据原文**(权威形态下即设备键本身,见 C-01):**只能喂给 `deviceIdDigest()`**,
142
170
  * 绝不直接落盘、回显或进错误信息。
@@ -218,6 +246,31 @@ export function rejectionReason(status, headers, req) {
218
246
  return null;
219
247
  }
220
248
 
249
+ /**
250
+ * 设备 cookie 名的**三级解析**:显式选项 → 环境变量 `DSH_LING_DEVICE_COOKIE` → 出厂默认。
251
+ *
252
+ * 为什么要有这个函数(2026-10-01 修红队 CR-1):本模块曾把**某一台机器改过名的值**当成出厂
253
+ * 默认值,而 cookie 名是 `remote-web-ui.cookieName`(**每台机器**各自的设置)—— 于是那个默认值
254
+ * 在别人的机器上恒不匹配(`deviceCookie` 恒 false、设备维度静默失效),并且随包公开。
255
+ * 只把默认值中性化**还不够**:改过名的那台机器(含作者本机)会因此被弄坏 ⇒ 必须同时提供
256
+ * 两条"不改代码就能恢复"的通路:
257
+ * · `options.deviceCookie`(宿主配置 `accessLog.deviceCookie`)—— 显式配置最优先;
258
+ * · `DSH_LING_DEVICE_COOKIE` 环境变量 —— 不想动配置文件时的单行开关;
259
+ * · 出厂默认(上游 remote-web-ui 的默认名)—— 别人机器开箱即对。
260
+ * 空串 / 纯空白 / 非字符串一律按"没设"处理(与旧写法 `options.deviceCookie || 默认` 一致)。
261
+ *
262
+ * @param {object} [options] 见 {@link ACCESS_LOG_DEFAULTS}
263
+ * @param {object} [env] 环境变量来源(测试可注入;默认 `process.env`)
264
+ */
265
+ export function resolveDeviceCookie(options = {}, env = process.env) {
266
+ const pick = (v) => (v === undefined || v === null ? '' : String(v).trim());
267
+ return (
268
+ pick(options.deviceCookie) ||
269
+ pick(env && env.DSH_LING_DEVICE_COOKIE) ||
270
+ ACCESS_LOG_DEFAULTS.deviceCookie
271
+ );
272
+ }
273
+
221
274
  /**
222
275
  * 建一个访问日志器。所有方法都不抛(内部全兜)。
223
276
  * @param {object} options 见 {@link ACCESS_LOG_DEFAULTS}(dir 必填)
@@ -228,16 +281,36 @@ export function createAccessLog(options = {}) {
228
281
  ? Number(options.maxTotalBytes)
229
282
  : ACCESS_LOG_DEFAULTS.maxTotalBytes;
230
283
  const uaMax = Number.isFinite(Number(options.uaMax)) ? Number(options.uaMax) : ACCESS_LOG_DEFAULTS.uaMax;
231
- const deviceCookie = String(options.deviceCookie || ACCESS_LOG_DEFAULTS.deviceCookie);
284
+ const deviceCookie = resolveDeviceCookie(options);
232
285
  const riskPatterns = (Array.isArray(options.riskPatterns) ? options.riskPatterns : ACCESS_LOG_DEFAULTS.riskPatterns)
233
286
  .filter((p) => typeof p === 'string' && p);
234
287
  const degradeWarnMs = Number.isFinite(Number(options.degradeWarnMs))
235
288
  ? Math.max(0, Number(options.degradeWarnMs))
236
289
  : ACCESS_LOG_DEFAULTS.degradeWarnMs;
290
+ // C-07b 的退避参数(照旧逐个解析 + 夹取,缺省取 ACCESS_LOG_DEFAULTS)。
291
+ const retryBaseMs = Number.isFinite(Number(options.retryBaseMs))
292
+ ? Math.max(0, Number(options.retryBaseMs))
293
+ : ACCESS_LOG_DEFAULTS.retryBaseMs;
294
+ const retryFactor = Number.isFinite(Number(options.retryFactor))
295
+ ? Math.max(0, Number(options.retryFactor))
296
+ : ACCESS_LOG_DEFAULTS.retryFactor;
297
+ const retryCapMs = Number.isFinite(Number(options.retryCapMs))
298
+ ? Math.max(0, Number(options.retryCapMs))
299
+ : ACCESS_LOG_DEFAULTS.retryCapMs;
300
+ // 抖动夹到 [0,1]:大于 1 会让等待时间变负(等于"永远立刻重试",正好破坏时间闸门)。
301
+ const retryJitter = Number.isFinite(Number(options.retryJitter))
302
+ ? Math.min(1, Math.max(0, Number(options.retryJitter)))
303
+ : ACCESS_LOG_DEFAULTS.retryJitter;
237
304
 
238
305
  let day = null;
239
306
  let writes = 0;
240
- let disabled = options.enabled === false || !dir;
307
+ // C-07b:把"为什么写不了"拆成两种**互不混淆**的状态。旧的单个 `disabled` 把"配置关闭"与
308
+ // "写失败"混成一个布尔,而后者必须能自己好起来 —— 这正是 C-07b 的成因。
309
+ // configOff —— 配置关闭(enabled:false 或无 dir):**永久**,行为与旧实现完全一致
310
+ // degraded —— 写失败降级:**可自愈**(旧实现一旦置位就再没有清除点)
311
+ // `info().disabled` 仍按 `configOff || degraded` 返回,向后兼容不变。
312
+ const configOff = options.enabled === false || !dir;
313
+ let degraded = false;
241
314
  // C-07 的观测面(原实现只有一个 warned 布尔 ⇒ 喊一行之后永久静默):
242
315
  // failures —— 写调用抛异常的次数(一次性的 vs 持续坏掉)
243
316
  // lost —— 因此**丢掉的行数**:降级后唯一还能增长的量,告警节流就挂在它上面
@@ -247,6 +320,17 @@ export function createAccessLog(options = {}) {
247
320
  let lost = 0;
248
321
  let lastError = null;
249
322
  let warnAt = 0;
323
+ // C-07b 的重试面:
324
+ // lostSinceAt —— **本次**故障的起点(epoch 毫秒;从未失败过 = 0 ⇒ health.lostSince 为 null)
325
+ // retryAttempts —— 连续失败次数(自愈时归零):退避的指数就挂在它上面
326
+ // nextRetryAt —— 下次允许**碰磁盘**的时刻;它之前只计数不写盘 —— 这就是"不每请求重试"的闸门
327
+ // recoveredAt —— 最近一次自愈的时刻(本地 ISO)
328
+ // retrySucceeded —— 自愈成功过几次(0 = 没坏过,或坏了还没好)
329
+ let lostSinceAt = 0;
330
+ let retryAttempts = 0;
331
+ let nextRetryAt = 0;
332
+ let recoveredAt = null;
333
+ let retrySucceeded = 0;
250
334
 
251
335
  /**
252
336
  * 该不该喊:首次必喊;之后**只按时间节流**(默认 60s 一次)。
@@ -254,7 +338,9 @@ export function createAccessLog(options = {}) {
254
338
  * 而"到底丢了多少"正是这条告警唯一的硬信息 —— 时间节流既不刷屏、计数又永远是最新的。
255
339
  */
256
340
  const shouldWarn = () => {
257
- if (lastError === null) return false;
341
+ // C-07b:判据从"有 lastError"换成**当前是否降级** —— lastError 现在会作为历史证据保留到恢复之后,
342
+ // 拿它兼作状态会让"已经好了"的姿态也照喊。
343
+ if (!degraded) return false;
258
344
  if (warnAt === 0) return true;
259
345
  return Date.now() - warnAt >= degradeWarnMs;
260
346
  };
@@ -274,28 +360,70 @@ export function createAccessLog(options = {}) {
274
360
  };
275
361
 
276
362
  /**
277
- * 记一次"丢了行"。**降级(lastError 存在)时**才计数并考虑告警 ——
363
+ * 记一次"丢了行"。**降级时**才计数并考虑告警 ——
278
364
  * `enabled:false` 的"配置关闭"是有意为之,不是故障,既不计丢失也不喊。
365
+ * C-07b:守卫从 `!lastError` 换成 `!degraded` —— lastError 恢复后仍留着(历史证据),
366
+ * 继续拿它当判据会让"配置关闭"这种场景错误计数。
279
367
  */
280
368
  function noteLost() {
281
- if (!lastError) return;
369
+ if (!degraded) return;
282
370
  lost += 1;
283
371
  warn();
284
372
  }
285
373
 
286
- /** 写失败 ⇒ 降级:自我禁用 + 记下证据(stderr、info().health 两处同时可见)。 */
374
+ /** 第 n 次重试要等多久(毫秒):指数退避到 cap,再乘 ±jitter。 */
375
+ function backoffOf(attempt) {
376
+ const n = Math.max(1, attempt);
377
+ const grow = retryFactor > 0 ? retryFactor ** (n - 1) : 1;
378
+ const raw = retryBaseMs * grow;
379
+ const capped = Math.min(Number.isFinite(raw) ? raw : retryCapMs, retryCapMs);
380
+ if (!(retryJitter > 0)) return Math.max(0, capped);
381
+ return Math.max(0, capped * (1 + (Math.random() * 2 - 1) * retryJitter));
382
+ }
383
+
384
+ /** 写失败 ⇒ 降级:记下证据 + 排定下一次探针时刻(stderr、info().health 两处同时可见)。 */
287
385
  function degrade(err) {
288
386
  failures += 1;
289
387
  lost += 1; // 触发失败的那一行本身也丢了
388
+ const now = Date.now();
389
+ // 本次故障的起点:只在"从正常跌进降级"那一刻记一次(同一次故障里的后续失败不覆盖它)——
390
+ // 恢复告警要拿它算故障时长;恢复后也**不清空**,它是"上一次丢行始于何时"的证据。
391
+ if (!degraded) lostSinceAt = now;
290
392
  lastError = {
291
393
  code: err && err.code ? String(err.code) : null,
292
394
  message: (err && err.message) || String(err),
293
395
  at: localIso(),
294
396
  };
295
- disabled = true;
397
+ degraded = true;
398
+ retryAttempts += 1;
399
+ // **只在状态里排时刻,不做任何 IO**(本函数跑在请求热路径上、且是同步的)。
400
+ nextRetryAt = now + backoffOf(retryAttempts);
296
401
  warn();
297
402
  }
298
403
 
404
+ /**
405
+ * 写成功 ⇒ 自愈(C-07b)。**只有真的写进去了才会走到这里** —— 探针就是"这一次本来就要做的写盘"。
406
+ * 四件事,一件都不能少:清降级 / 退避归零 / 留下恢复痕迹 / **重置告警节流**。
407
+ * 最后一件最容易漏:不重置的话,恢复后紧接着再坏一轮,那一次的首次告警会被上一轮的 `warnAt`
408
+ * 节流吃掉 —— 而"刚坏"恰恰是最该立刻喊的那一次。
409
+ * `lastError` 故意**不清空**:它是历史证据,恢复不抹掉"曾经坏过、坏在哪"这一事实。
410
+ */
411
+ function recover() {
412
+ const downMs = lostSinceAt ? Date.now() - lostSinceAt : 0;
413
+ degraded = false;
414
+ retryAttempts = 0;
415
+ nextRetryAt = 0;
416
+ recoveredAt = localIso();
417
+ retrySucceeded += 1;
418
+ warnAt = 0;
419
+ try {
420
+ // "它什么时候好的"必须留痕,否则只有"坏"的痕迹、没有"好"的痕迹。
421
+ console.error(
422
+ `[dsh-ling] access log recovered: write ok again after ${downMs}ms down, ${lost} line(s) lost so far; dir=${dir}`,
423
+ );
424
+ } catch {}
425
+ }
426
+
299
427
  const fileNameOf = (d) => `${FILE_PREFIX}${d}${FILE_SUFFIX}`;
300
428
 
301
429
  /** 超限删最旧(按文件名=时间序),当天文件永不删。 */
@@ -340,8 +468,14 @@ export function createAccessLog(options = {}) {
340
468
  }
341
469
 
342
470
  function append(entry) {
343
- if (disabled) {
344
- // C-07:降级后每来一行都如实计入"丢掉的行数",并按节流继续告警 —— 不再永久静默。
471
+ // C-07:降级后每来一行都如实计入"丢掉的行数",并按节流继续告警 —— 不再永久静默。
472
+ // C-07b:改成两段判定 —— ① 配置关闭是**永久**的(有意为之,noteLost 会直接返回);
473
+ // ② 降级只在**退避窗口内**才放弃写盘,窗口一过就走正常路径(写成功即自愈)。
474
+ if (configOff) {
475
+ noteLost();
476
+ return;
477
+ }
478
+ if (degraded && Date.now() < nextRetryAt) {
345
479
  noteLost();
346
480
  return;
347
481
  }
@@ -354,6 +488,9 @@ export function createAccessLog(options = {}) {
354
488
  try {
355
489
  appendFileSync(join(dir, ensureDay()), line);
356
490
  if (++writes % 256 === 0) enforceCap();
491
+ // C-07b:写成功就是探针的回执 —— 降级态下的这一次成功即自愈(appends 是唯一的落盘点,
492
+ // 所以恢复判定放在这里就足够,不必再问一次磁盘)。
493
+ if (degraded) recover();
357
494
  } catch (err) {
358
495
  degrade(err);
359
496
  }
@@ -390,7 +527,7 @@ export function createAccessLog(options = {}) {
390
527
  }
391
528
 
392
529
  /**
393
- * 姿态自检探针的**五要素签名**(任务书 §7.5):POST + 路径 + loopback + 无 UA + 被 403 拦下。
530
+ * 姿态自检探针的**五要素签名**:POST + 路径 + 本机回环来源 + 无 UA + 被 403 拦下。
394
531
  * 五条同时命中才标注 —— 放宽任何一条都会把真攻击错标成"自检"(内部踩坑记录 B3:判据过宽)。
395
532
  * 标注**不代表放行**:被标注的行仍然是"被栅栏拦下"的记录,status 依旧是 403。
396
533
  */
@@ -443,8 +580,13 @@ export function createAccessLog(options = {}) {
443
580
  */
444
581
  function observe(req, res) {
445
582
  if (!req || !res) return;
446
- if (disabled) {
447
- // C-07:降级后不再试着写盘,但请求还在来 —— 每来一次都是一次**可见的丢失**(计数 + 节流告警)。
583
+ // C-07:降级后不再试着写盘,但请求还在来 —— 每来一次都是一次**可见的丢失**(计数 + 节流告警)。
584
+ // C-07b:只有在**退避窗口内**才这么做;窗口一过,这次请求就当成探针走完整路径。
585
+ if (configOff) {
586
+ noteLost();
587
+ return;
588
+ }
589
+ if (degraded && Date.now() < nextRetryAt) {
448
590
  noteLost();
449
591
  return;
450
592
  }
@@ -494,7 +636,12 @@ export function createAccessLog(options = {}) {
494
636
  */
495
637
  function observeUpgrade(req, rejection) {
496
638
  if (!req) return;
497
- if (disabled) {
639
+ // C-07b:与 observe/append 同一套两段判定 —— 三个闸门漏改任何一个,那条通道就还是"永久死"。
640
+ if (configOff) {
641
+ noteLost();
642
+ return;
643
+ }
644
+ if (degraded && Date.now() < nextRetryAt) {
498
645
  noteLost();
499
646
  return;
500
647
  }
@@ -518,15 +665,24 @@ export function createAccessLog(options = {}) {
518
665
  maxTotalBytes,
519
666
  deviceCookie,
520
667
  riskPatterns,
521
- disabled,
668
+ // C-07b:向后兼容字段,语义不变(旧消费者只问"还能不能写")—— 现在它由两个状态合成。
669
+ disabled: configOff || degraded,
522
670
  // C-07:可机读的健康面 —— `/health`(或界面)读这一个字段即可,不必去猜 stderr 那一行。
523
671
  health: {
524
- state: lastError ? 'degraded' : disabled ? 'off' : 'ok',
672
+ // 顺序不能反:先判 degraded(可自愈的故障),再判 configOff(有意关闭)。
673
+ state: degraded ? 'degraded' : configOff ? 'off' : 'ok',
525
674
  writes,
526
675
  failures,
527
676
  lost,
677
+ // 本次故障的起点;从未失败过就是 null(lastError 保留为历史证据,恢复后不清空)。
678
+ lostSince: lostSinceAt ? localIso(new Date(lostSinceAt)) : null,
528
679
  lastError,
529
680
  warnedAt: warnAt ? new Date(warnAt).toISOString() : null,
681
+ // C-07b 的重试面:自愈的痕迹(恢复时刻 / 已连续失败几次 / 下次探针时刻 / 自愈成功次数)。
682
+ recoveredAt,
683
+ retryAttempts,
684
+ nextRetryAt,
685
+ retrySucceeded,
530
686
  },
531
687
  }),
532
688
  };