# 更新记录 本项目遵循语义化版本:`MAJOR.MINOR.PATCH`。 ## 1.6.2 **检索档换了一条快两个数量级的路 · 结果不再被"我刚说的话"占满 · 几处会误导人的地方修掉。** > 一句话:1.6.1 之后继续修。最值得说的三件:① **按内容找原文从"每次 20 秒以上"变成"20~50 毫秒"**(改走本机库,不再依赖宿主那条例外慢的检索通道);② **搜索结果默认不再被"当前会话自己"占满** —— 此前搜什么都会先命中你几分钟前刚说的话,把真正要回溯的历史挤下去;③ **枝系数(复盘期给某个主题枝调的权重)真的开始生效了**,此前它只存不用。 > ✅ `package.json` 版本号为 **`1.6.2`**。**无 schema 变化**(仍是 v17);⚠️ 多数改动在 **host 侧** ⇒ **需重启宿主**才生效。新增一份对外文档 [`PHILOSOPHY.md`](./PHILOSOPHY.md)(理念:为什么值得做、凭什么这样做)。 ### 一、按内容找原文:换了一条快两个数量级的路 - **宿主那条检索通道每次都要做一遍全量对账** ⇒ 实测**每次都超过 20 秒**(重启后第一次也一样,并发还会互相排队)。现在 ① 档**默认搜本机库内原文层**(实测 **20~50 毫秒**);要看全量(覆盖全部事件、含子代理会话)显式传 `hostSearch=true`。 - **如实标注覆盖范围**:本机库那份原文只含"真人消息 + 有正文的回复"(我自己的长回复**中段搜不到**)⇒ 回执里明写"原文来源:**本机库内原文层**",不说就会被读成"库里只有这些"。 - **原文层默认 5 轮**(此前 10 轮):条目层是结论、原文层是下钻材料,后者一多就把前者挤出视野。 - **超时不再是挂死**:宿主路有 20 秒上限,超时**照样把条目层交出去**并说明"没跑完" —— 此前实测等过 **30 分钟**,且当时没有任何中断入口。 ### 二、搜索结果默认排除"当前会话自己" - 症结:当前会话也在库里、而它的话最新 ⇒ **任何词都能命中它**(实测搜"检索"时 10 条命中里 **9 条是本会话**),要回溯的历史被整页挤掉。而"我刚说过什么"**根本不需要搜** —— 它就在上下文里。 - 现在:**默认整条排除**,并如实报出"另有 N 轮命中属于当前会话自己,已排除";要查自己刚说的传 `includeSelf=true`(那时它排到最后并标注「← 本会话」)。 ### 三、记忆树:枝系数开始参与打分 - **`branch.weight_scale` 从"只存与显示"变成真的参与检索排序**:`分数 = 基础分 × 血缘 × 矛盾 × 枝系数`。一条结论的枝 = **它来源会话所在的枝**。`1.0` = 零影响,`0` = 排到最后(**不是禁用**)。 - 于是"复盘期给某个主题枝调权重"**真的有行为后果**了;此前它是面板上的装饰(README 的 U-10 正是针对这类"看起来能改、实际不做"的交互)。 - 实测现状:全库 20 条枝的系数都还是 `1.0` ⇒ 这次上线**不改变任何现有排序**,变化只在你开始调之后发生。 ### 四、写面与设置 - **两条写面补上手术门**:`/persona/habits/scan` 与 `/persona/habits/reflect` 会写人格面(习惯候选),此前**不受手术门管辖**,而它们的确认侧 `/persona/habit` 是过门的 ⇒ 一条通路的两半不一致。现在四条形状一致(先解析请求体、再判门)。 - **删掉一个死开关**:`updates.applyWhileRunning` 只有声明、没有消费者(而且它**不在可写入白名单里**,接上也开不了)⇒ 直接删,免得读的人以为改它能改行为。 - **`DSH_HOME` 支持收尾**:`rawlog.js` 是最后一处仍走系统主目录的地方 ⇒ 现在与其它模块同源,自定义 home(隔离测试)下不会再读到真机目录。 ### 五、文档与治理 - **新增 [`PHILOSOPHY.md`](./PHILOSOPHY.md)**:对外理念文档(目标是一种循环 · 复盘与"人在循环里" · 上下文是工作台、记忆库才是家 · 底层模型 ≠ 器灵 · 凭什么不越界)。随包发出,README 顶部与「文档」一节都有引用。 - **发布前检查加一条**:对外文档(`README` / `CHANGELOG` / `DESIGN` / `ACCESS-DESIGN` / `package.json`)里出现内部流程术语时会**列出来提醒**(警告级、不阻断发布)。 - **八项文档订正**逐条落地:README 与代码打架的那句(段头要求 1.5.1 起已废弃)、写面回执形态、设置卡口径、两处数字(`daily` **0.3** / `l1MaxItems` **10**)、手术门覆盖条数统一为 **14**、安装四步的承诺口径、包名口径(`@nightpainters/dsh-ling`)。 ### 六、结论层新增一类:`纪律` - 条目现在有**六类**:承诺 / 决定 / 事实 / 偏好 / 关系 / **纪律**。 - **纪律**的判据三条(全中才进库):① 来源是主人授权的一句指令;② 作用对象是**行为**;③ **违反时能被某条判据红出来**。⇒ 答不出"哪条判据会红"的只是一句话,退回收在别处。 - 它在记忆开场里的权重是 **0.35** —— 高于"关系"、略低于"承诺":与承诺同族(都是说过的话),但承诺是对某件事的,纪律是对**行为**的。 ## 1.6.1 **修掉 14 处会影响实际使用的问题 —— 没有新功能,不改库结构。** > 一句话:本版是 1.6.0 的修复版。最值得装的三处:① **钉住的记忆不再被条数上限挤掉**;② **"只有摘要、没有原文"的记忆现在能忘了**;③ **库被别的进程占用时,插件不再整个加载失败**(此前 8 个工具会一起消失,界面上没有任何提示)。 > > 另修好:飞书通道里**你自己说的话不再被当成系统注入**(找回 49 轮真话)、**回灌网页端会话不再把它标成 DSH 会话**、检索档不再给出错位的行号。 > ✅ `package.json` 版本号为 **`1.6.1`**。**无新功能、无 schema 变化**(仍是 v17);⚠️ 修复多落在 host 侧 ⇒ **需重启宿主**才生效。 ### 一、记忆开场 - **钉住的条目现在真的"必进"**:此前条数上限与字数预算**先于**钉住判定生效 ⇒ 钉住数超过上限时,多出来的会被挤掉(实测 9 条钉住 + 上限 3 ⇒ 只进 3 条)。现在两道闸门对钉住项豁免:同样条件下**进 9 条**。 - **落选记录的"来源"栏一直是空的**:取错了列名。同批补齐外部面板需要的字段。 - **打分理由与真实公式不符**:改动后统一为 `(分项和) × 血缘 × 矛盾 ⇒ 总分`。 - **"热度"在留痕里被报成两种值**(最高差 88%):改为报加权后的值。 ### 二、原文与检索 - **检索档给出的"取原话"指引用错坐标系**:它印的是**事件行号**,而取原文要的是**对话轮号**。实测 274 条里 **207 条(75.5%)**照做会报"序号超出范围"。现在改为"先看对话目录,再按目录里的 `#` 号取"。 - **回灌会把网页端会话标成"DSH 会话"**:1,523 条网页端会话全是裸 id,回灌时被按 id 形状推成 DSH 来源 ⇒ 同一条出现两份摘要、双份记忆。现在按归档记录写入来源,并对不上时直接拒绝。 - **飞书通道里你自己的原话被当成"注入"**:这些句子在读档时被折进"(此处折叠 N 条注入消息…)"、目录里印成「(注入)」—— **"你说过什么"整段消失**。现在按会话通道区分:找回 **49 轮**真话,且不误收子代理收到的委托文字。 - **库被别的进程占用时,插件会整个加载失败**:等待参数原先排在设置 WAL 模式**之后** ⇒ 撞锁 **23 毫秒**就抛、连等都没等。现在挪到最前 + 对锁类错误做 **3 次 × 2 秒**有界重试;仍失败则**只停用记忆功能**(其它插件不受影响),并给出一条能照着做的排查提示。 ### 三、记忆树与遗忘 - **"只有摘要、没有原文"的记忆永远忘不掉**(主脉记忆、导入条目):遗忘前的归档守卫要求"原文轮数 > 0",而这类条目的内容**就在摘要里** ⇒ 判据改宽。同批修正"空归档"判据 —— 它原来把**只有元数据空壳**的会话也算成"有内容",于是真空归档会被放行,而遗忘标记一旦落下就永不召回。 - **改名后的枝名在三处不一致**(回执 70 字 / 留痕 70 字 / 库里 60 字):统一上限常量,并把改名改成**写后读回** —— 回执与留痕一律报库里的真值。 - **断边的回执可能是假的**:连边可被反向重复写入(`A→B` 后再 `B→A` 落两行),而删除只删一条 ⇒ 回执说"断开了"、树里还连着。现在写入侧查反向(**有向边不合并** —— "A 是 B 的前提"≠"B 是 A 的前提",渲染以 `→` / `↔` 区分),删除侧双向删。 - **传空权重会静默写成 0**:0 在界面上是**合法读数**,看不出来是手滑 ⇒ 改为先判类型,空值一律拒。 - **"被采用"的信号按请求窗口记账**:没被真正交付的轮次也 +1 ⇒ 改用**真正交付**的区间。 - **范围限制如实标注**:宿主没提供会话清单时,检索档开头写明「⚠️ **没能限定范围** —— 结果可能含子代理或其它会话」,不再静默放行。 ### 四、诊断 - 差分路的丢弃画像此前只在"一条都没写进去"时留一次记录 ⇒ **正常路径上丢了多少完全不可见**。现在每轮覆盖写。 - 该画像与事件画像现在**每次重启都清空**(此前会跨重启残留,看着像"刚跑过")。 ### 五、CI - 一条测试断言写的是墙钟下界 `dt >= 20`(毫秒),CI 上实测 **19 ms** ⇒ 红。它断言的是**定时器实现细节**(毫秒级钟有粗粒度、定时器可提前一两毫秒),不是行为缺陷 ⇒ 下界放宽到只证明"确实等过、不是同步 reject",上界与"结局来自那道闸"由同组另两条钉住。 ## 1.6.0 **找回原文(能读回一年半前的原话)· 深层库(结论层)· 记忆开场换料** > 一句话:本版三件事 —— ① **器灵第一次能读回原文**:此前它手上 7 个工具**没有一个能读会话原文**,开场里那几行概述是"别人替它挑的";现在新增第 8 个工具 `recall`,四档下钻直通磁盘真全文,并把**一年半的网页端历史(1,523 会话 / 726.7 万字)补进了库**;② **深层库**:在"会话流水"之上长出**结论层** —— 从 1,623 个会话里提炼出 **393 条**一句话结论(承诺/决定/事实/偏好/关系),每条都能点回原话;③ **记忆开场换料并瘦身**:开场不再注入"会话概述",改注入**深层库条目**,占用从 1,085 字降到 **约 460 字**。 > ✅ **本节即 1.6.0 的发布内容**;`package.json` 版本号为 **`1.6.0`**。 > ⚠️ 本版多处改动落在 **host 侧**(工具注册、注入面、库 schema)⇒ **需重启宿主**才生效;库 schema 由 **v14 → v17**(全部为 `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN`,启动时自动迁移)。 ### 一、找回原文:`recall` - **此前为什么读不到**:器灵侧 7 个工具(`tree_read` / `branch_edit` / `memory_forget` / `memory_backfill` / `rule_add` / `habit_propose` / `habit_resolve`)**无一能读原文** —— 它对过去的全部认识止于开场那 8 行概述。README 旧版只列了 2 个工具,同批更正为 8 个。 - **四档下钻(一次一层,不替你挑内容)**: 1. `recall()` ⇒ **会话清单**(磁盘上有原文的会话,最近活动在前); 2. `recall({ q })` ⇒ **按内容找会话**(宿主跨会话全文检索;默认只搜**主人自己的会话**,被范围挡掉的条数**一律报出**,越界需显式 `all`); 3. `recall({ conv })` ⇒ 该会话的**对话目录**(每轮一行:序号/时间/角色/前 70 字),**不给正文**; 4. `recall({ conv, from, to })` ⇒ 那几轮的**原文全文**。 - **它不替你挑"最重要的 1 万字"**:单次正文上限(`RECALL_READ_CHARS = 12000`)只是**护栏**,且**溢出必报**"还有 N 轮没给,下一次从 N+1 读" —— 工具只给坐标系,**选哪一段是器灵自己的判断**。 - **真人判据**(五条,2026-10-04 晚修正):① `source.kind === 'user'`;② **会话级**分流 —— 会话头过 `isMemoryEligibleHeader`(与捕获侧同一把尺,子代理会话整段不算);③ **合格会话里没有 `rpcId`** 的 user 消息**也算主人**(飞书桥来的原话就没有 rpcId);④ 正文行首 `[身份·` 仍算注入;⑤ **取不到会话头时只认 `rpcId`**(与修正前逐字同解,保住无头夹具与旧日志)。⚠️ 为什么必须落在**会话级**:事件里**没有通道字段** —— 飞书真人、身份注入块、子代理委托种子在**消息级完全同形**。实测:真人轮 951 → **1001**(+49 飞书真人 +1 探针),子代理种子 387 条**零回归**。 - **读不到时回落查库**:磁盘没有该会话日志时改读 `dsh_turns_raw`,并在回执里**明说"库那份是残的"**(只含真人消息与有正文的回复)—— 材质差异必须让读的人知道。 - **会话文件三代代次**:匹配 `session(\.v\d+)?\.jsonl\.zstd`(v4 / v3 / 无版本)取最高一代。此前**写死 `session.v4.jsonl.zstd`** ⇒ 旧版会话"能列出、读不到",同批修复(可读会话数 335 → 465)。 - **性能**:会话日志是**多 frame zstd**,`zstdDecompressSync` 只解第一帧、流式解压报 `Unknown frame descriptor`;靠 magic 逐字节暴力试解是 **O(n²)(实测 119 秒)**。正解是**纯结构扫描帧边界**(照 DSH 官方 `scanZstdFrames` 重写、**不 import 官方包**),实测 **516 帧扫描 1 ms + 全量解压解析 53 ms**。 ### 二、网页端历史补录(一年半) - `deepseek_data-2026-09-06/conversations.json`(24.15 MB / **1,523 条**)⇒ 写入 `dsh_turns_raw`:**1,523 会话 / 4,821 行 / 7,266,628 字 / 耗时 186 ms**;时间跨度 **2025-01-31 → 2026-09-06**。 - 只收 `REQUEST` / `RESPONSE` / `THINK` 的正文;`SEARCH` / `FILE` / `TOOL_*` 共 5,854 个 fragment **实测全为 0 字符**(纯元数据),丢弃无损;思维链(`THINK`,236.5 万字)本轮**有意不收**(源文件与幂等脚本均在位,可随时重跑)。 - `source='dsweb'`、会话键用**裸 uuid** ⇒ 概述器与习惯生成器**都不会认领它们**(判据是 source 优先)。 - 同批把 `dsweb` 加进 `KNOWN_FOREIGN_RAW_SOURCES` —— 它是**无前缀来源**,不列白名单会被判成"未知来源"并在库里挂假警报。 ### 三、深层库:结论层(`deep_item`,schema v16) - **它是什么**:浅层库是"会话流水"(含工具调用与调试噪声),深层库是**结论** —— 一条一句话、能独立读懂、带 `conv_id + seq_from/seq_to` 可点回原话。主人给的架构口径逐字:「从浅层记忆中提取关键的东西落入深层库,把工具调用、过时历史剥离掉……然后在召回的时候再重新装配」。 - **五类** `kind` = 承诺 / 决定 / 事实 / 偏好 / 关系;**三条硬判据**:与主人有关 · 可跨会话复用 · 不是过程与当场读数。**空结果是正确结果**(大量通用问答按判据判 0,未凑数)。 - **全量提炼**:1,623 个会话 / 208.9 万字给料 / 118 批 ⇒ **393 条**;密度 **`dsh` 6.0 条/会话 vs `dsweb` 2.3 条/会话**(工作会话有结论、网页端多为通用问答)。 - **三条写进列注释的语义位**(谁改都不许违反):① `hit_count` **只由"真被采用"推进,不由"被注入"推进**;② `superseded_by` **只追不替**(旧的留痕、不删);③ `branch_id` 是**气根**(枝侧反向引用,不动树结构)。 - **"被采用"已接线**:`recall(conv, from, to)` 取到与条目区间**相交**的原文 ⇒ 该条目 `hit_count +1` 并在回执里报出。挡的是"注入越多 → 分越高 → 越容易被注入"的**自我强化回路**。⚠️ 区间必须用 `dsh_turns_raw.seq`,**不是**目录里的对话轮序号。 - **条目硬度 `durability`(schema v17)**:`durable` **121** · `long` **159** · `ephemeral` **113**(**重判后**的分布 —— 首轮标注偏松、把"开发期技术结论"记成了长期,复核时 146 条改档) —— **从根源判断条目自身的临时/永久性**,而不是拿"同一次会话最多几条"这类机械限额去卡;**`ephemeral` 不进开场**。 ### 四、记忆开场:换料 + 瘦身 - **数据源**:`conv_overview`(会话概述)⇒ **`deep_item`(结论条目)**。两条路**并存**:库里还没有条目时回落旧路(否则尚未提炼的会话会在开场里整个消失)。 - **判据**(面向深层库条目,不再面向概述,六项相加后乘两个系数):**置顶必进** + **被反复采用**(5 次饱和) + **当轮关键词**(权重 1.2 —— 要能把"他这一轮问的那件事"顶进前几席,所以它高于长期分) + **硬度分**(`durable` +0.6) + **类别分**(承诺 .4 / 关系 .3 / 偏好 .2 / 决定 .1 / 事实 0 —— 让"关于人"的排在"关于事"的前面) + **新近**(半衰期一年、权重 **0.1**) × **血缘档位** × **矛盾降权**。血缘与矛盾两张表按 `source|conv_id` 索引 ⇒ 换料后**一个字没改**。 - **行形态**由 `- [标签] (日期) 标题 —— 摘要… (来源: …)` 变成极简的 **`- [承诺] 一句话结论`**(概述行那三段前缀约 15 字的税省掉了;出处落在投递留痕里,要原话用 `recall` 下钻)。 - **瘦身 60%**:`l1BudgetTokens` **1200 → 720**(条目均 52 字、`l1MaxItems` **10** —— 其中 **2 席留给"最硬的事实"**(`durable` 的事实类),没有合格事实时席位归还通用池 ⇒ 实占 **约 500 字**,比原 1,085 字降约 **55%**)。 - **同源限额 `MAX_PER_CONV = 2`**:同一次会话最多进 2 条(置顶不受限),被挡下的进落选者名单(第三类原因 `conv`)。真机实测:**10 条来自 4~6 个会话**(换料前那 8 条里,光一次生图会话就占了 13 条候选)。 - **视角框措辞同批改准**:注入的已经不是"他过去说过的话",而是**提炼出的结论** —— 段首改为「以下是我从与X的过去里记住的结论:…」。 ### 五、投递留痕(`inject_log`,schema v15) - 每一次**真正定稿**的注入落一条:**注入了谁 / 为什么是它(分数分解) / 多少字 / 落选者是谁且为什么落选 / 当轮触发词 / 定稿全文的指纹**。四类落选原因:`budget`(被字数挤掉) · `max`(排在 Top-N 之外) · `conv`(同源限额) · 被主动排除的计数(`archived` / `forgotten` / `zero`)。 - **只写不读**:不加工具、不进注入面、不占"工具面最多 +1"的预算。 - **写入判据只能是"首次定稿"** —— 重建与空闲刷新不落痕,否则同一批记忆会被反复记。 - **丢弃不再静默**(此前每一处过滤都不留痕,这正是"库里为什么只有 1.8% 现场"答不出来的根因):两条写入路径各记一本账 —— `dbg.event_stats`(逐事件:seen/kept/各类丢弃)与 `dbg.diff_stats`(空闲差分:水位/扫描/新增/丢弃)。逐事件那本在内存累加、**每 20 次或 5 秒**才落一次库(不给热路径加 sqlite 往返)。 ### 六、索引层:一个技能承载整层 - `memory-index` 技能 = **11 簇主题地图**(记忆树与记忆库 · 器灵插件与发布 · 鱼姬与主人 · 主人其人 · 合作规矩 · 本业与项目 · 这台机器与踩坑 · 模型与API配置 · 插件与界面 · 感官与主动开口 · 零散几条),覆盖全部条目。 - **常驻只有技能目录那一行**(description **431 字** + whenToUse 106 字),正文 2,341 字**按需展开**(模型主动取)。 - **整层一个技能,不是每簇一个**:实测技能目录的边际成本 ≈205 字/条,11 簇拆成 11 个技能会吃掉 2,000+ 字常驻 —— 而那个常驻目录面**正是工具面压缩要抢的同一个池子**。 - **description 里不写条数**:条数天天变,一写就会让目录重发、前缀缓存断;**簇名与触发线索稳定,条数放进按需展开的正文**。 ### 七、其它修正 - **L1 视角框**:修「代词混乱」BUG —— 概述是主人视角写的,注进器灵记忆时人称错位(最危险的是"限制性指令"读起来像对器灵下的命令)。段首声明视角,覆盖全部历史条目、零动库;称呼**跟随 `persona.userTitle`**(不硬编码,包要发 npm)。 - **README 重写**:新增「找回原文」整节与「设定全文(对外面)」区(**9 组 84 条**,逐条标状态:`✅ 已实现` / `🚧 在做` / `📋 设定`)。修正两处硬伤:测试套数 **39 → 43**、器灵侧工具 **2 → 8**。 - **`peerDependencies` 的 range 结论(查证后不改)**:`>=0.1.7-rc.2` 在 DSH 发新 `tuple` 的 rc 时确实不满足,但 ① 那 12 条全是 `optional: true`,**不满足只警告、不阻断安装**;② **semver 在数学上无法表达"接受任意 tuple 的 rc"** —— 实测 `*` 与 `>=0.1.0` 对 `0.1.7-rc.2` **同样不满足**(放宽反而当场报警)。⇒ **保持现状**,不改 range。 - **一条断言纪律**:测试里**不许依赖"此刻谁在跑"这类易变事实**(两处红都源于此:最新会话被子代理会话抢占、"最新"会漂移)。 ## 1.5.3 **跨插件挡位接口(对外开放 `dsh-ling/mode` 服务)· 导入批体量上限(超限不再砍连接,改回 413)** > 一句话:本版两件事 —— ① 把"设挡位"这件事从**只能由 GUI 发起**变成**可以被别的插件调用** —— 器灵注册一个轻量 Cordis 服务 `dsh-ling/mode`,暴露 `applyMode(sessionId, modeKey)`。调用方**无法**连带改掉全局默认模式,**也无法**连带改掉会话的模型。首个消费者是飞书桥(`dsh-feishu-host`):它在**新建一条线**时给新会话钉上 `life` 挡位;② 修掉"一千多条的历史一次导入,一条都没进去、界面还显示**批失败(网关)**"的那道体量闸门(两端判据**单位不同** + 超限时**砍连接**导致错误文案没有通路)。 > ✅ **本节即 1.5.3 的发布内容**;`package.json` 版本号为 **`1.5.3`**(开发自 2026-09-29 夜起,发布前已去掉开发期后缀;1.5.2 已双发)。 > ✅ **②的真机状态(2026-09-29 夜 · 重启后已验)**:**修前**留档 = 宿主未重启时(PID 6260)`3 MB → HTTP 200` / `5 MB → ECONNRESET`;**修后**重启(PID 4344,跑新代码)实测 **`3 MB → HTTP 200` / `5 MB → HTTP 413 {"ok":false,"reason":"too-large","message":"单次请求体过大(超过 4MB 上限),请拆分后重试","limit":4194304,"got":5242921}`** —— `got` 与客户端量到的长度逐字节相等。 > ✅ **浏览器那条链也验了(2026-09-29 夜 · 主人在真界面点了一次)**:一份**合成的**"单条 5.0 MiB"文件走「记忆中心 → 接入历史 → 确认导入」,界面如实报出「**另有 1 条会话单条太长、没能通过导入通道(已跳过)**」「校验通过 0 · 拒绝 0 · **单条过大跳过 1**」并**点名**该条 ⇒ **不再出现「批失败(网关)」**;其中 `4 MB` 取自服务端回执 `limit`(**客户端没有镜像这个常量**)。 > ⚠️ **当时仍未做(当日已补,见「二」里"被跳过的'单条过大'进持久账本"一条)**:被跳过的条目**不进持久「导入记录」**(当时账本只有 `errors` 一个字段);另外**单条 >4 MiB 的文件仍然进不来** —— 现在至少说得清是为什么。 ### 一、新服务 `dsh-ling/mode` - 注册走 **`ctx.provide(MODE_SERVICE_NAME, api)`** —— **不是** `class extends Service`:本包以 `file:` + Junction 装载,**包内没有 `node_modules`**(`Test-Path node_modules` = `False`),`import '@deepseek-ai/cordis'` 必 `ERR_MODULE_NOT_FOUND`(尽管声明了该 peer);`ctx.provide()` 在同一套 reflect 机制内,**零 import**; - 暴露两个方法:`applyMode(sessionId, modeKey, opts)` → `{ ok, queued?, mode, reasoningEffort?, warning? }`;`currentMode(sessionId)` → `'work' | 'life'`; - **内部固定 `setDefault: false`** —— 服务只改**指定会话**的挡位,**绝不**写 `settings.mode.lastMode`。GUI 那条老路仍走 `applyModeToSession(...)`(内部即 `{ setDefault: true, ...opts }`),**行为逐字不变**; - **`keepModel` 可选**:`currentModelSelection(ctx)` 读的是**全局**当前模型,跨插件场景下会把调用方自己的模型盖掉 ⇒ 调用方可传 `keepModel: { provider, model }` 原样带回; - **`syncEffort: false`** 可选:只记模式、不动会话推理等级(连 `selectModel` 都不调); - **注册失败不外抛**:只写一行 `console.debug` 并返回 no-op disposer —— 器灵装不上不该拖垮宿主。 ### 二、导入批体量上限:超限不再砍连接,改「排空 → 413」 > 根因(两端判据单位不同、且互相不知情):客户端按**条数**切批(`batch = 200`),服务端按**字节**封顶(4 MiB),而 `readBody` 超限时是 `reject` **与** `req.destroy()` **同一拍** ⇒ **那句现成的"请拆分后重试"永远送不出去**,浏览器只看到 `ECONNRESET`,前端把它归成「批失败(**网关**)」—— 一个确定性、可自愈的失败,在界面上长得像网络故障。186 条 / 4.89 MB 的真实导出就是这么**一条都没入库**的。 - **服务端 · 排空再回 413**:`readBody` 超限后**不砍连接** —— ① 立刻停止缓存(**已缓存的那几 MiB 也丢掉**,不抱着它等到 `end`);② 继续消费 `data`、只累加计数;③ 等 `'end'` 到达、连接健康时再 reject 一个 `code='BODY_TOO_LARGE'` / `status=413` 的错误;`guard` 里**一处**出口把它翻成 `413 {ok:false, reason:'too-large', message, limit, got}`(49 处调用点零改动)。`message` **逐字沿用旧文案**; - ⚠️ **边界(实测边界,别读成全称命题)**:**只有 `4 MiB < 体量 ≤ 64 MiB` 才保证拿到 413**。超过 64 MiB 撞硬熔断 ⇒ 仍是 `ECONNRESET`(与修前同症状);`Content-Length` 撒谎一类的**协议违规**客户端也拿不到 413(浏览器不会这么发)。实测边界严格翻转:`4 MiB` 整 → 200、`4 MiB + 1 B` → 413; - **排空换来的两个新代价,各配一道闸**:`BODY_DRAIN_MAX_BYTES = 64 MiB`(硬熔断 —— 给一份真实导出留够"把 body 发完"的余量,余量不足则排空本身会变成**新的** reset,白改一场)+ `BODY_DRAIN_IDLE_MS = 30 s`(静默超时,替代原来那条防 DoS 动机:只要还有数据在流就算正常,**一旦静止就是僵尸连接**;滑动窗口,每收到一个 chunk 重置);计时器在 `'end'`/`'error'`/熔断**三条出口**都 `clearTimeout`; - ⚠️ **这道防线的形状边界(实测边界,本批未关)**:静默超时只覆盖"**已经在发、中途静默**"这一形状;「**只发头、不发体**」的请求在**应用层没有计时器**(计时器只在 `req.on('data')` 里武装),兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。⚠️ **这不是回归**(修前同样没有),但**别对外写成"防僵尸"的全称命题**; - **客户端 · 按字节切批**:`batch = 200` → **≤200 条 且 ≤3 MiB/批**(留 1 MiB 余量给数组封套与转义);新增手写 `utf8Len`(CJK 3 字节、emoji 代理对 4 字节——**不许用 `s.length` 冒充**)、`takeChunk`(**前进性硬保证**:单条自身超限也单独成批,绝不切出空批 ⇒ 不会死循环);进度文案改双口径(`12/186 条 · 已送 4.2 MB`);拒绝条目序号改以**本批起始下标**为准(`idx - batch` 随切批判据变更同步修正);`reason === 'too-large'` 映射成人话,且上限数字**取自服务端回执的 `limit`**(不在这边再写死一个); - **契约注释(④)**:`api.js` 那句「每批 ≤500」**与实现自相矛盾**(500 条要装进 4 MiB,需每条 ≤8 KB,而带正文的会话实测 ≈26 KB/条),已改写为**条数 × 字节双限**,并写明**两端耦合** —— 任何一方单独改上限都会让口径重新错位,**这正是本缺陷的成因**;`ACCESS-DESIGN.md` 的「分页提交」契约同批对齐(历史记录只加"已作废"指针,**不改写历史**)。 - **客户端 · 单条超限不再含糊(C-1;实测后补)**:单条会话 ≥ 4,194,210 B 时服务端必回 413,旧行为是**该条被永久丢弃**、只在界面上记一句「批错误 1」,而文案「请把导出文件拆小」对**单条会话物理上不可执行**。现在:413 **且本批只有 1 条** ⇒ **点名**(`title` → `name` → 「未命名」三级退路)+ 报出**真实字节**(客户端复算,不用服务端被截断的 `got`)+ 说清「这条太长、拆文件也拆不开它、已跳过、其余照常导入」;计入独立的 `tooBig`(**不混进 `errors`**)、**不中断**后续批;`finish()` 有跳过时**不再说「✓ 导入完成」**。判据只用**服务端回执**+ 本批条数(**客户端不镜像服务端的 4 MiB 常量**,将来放宽上限不用改这里)。⚠️ **当时**仍未做:被跳过的条目**不进持久「导入记录」**(账本只有 `errors` 一个字段)—— **当日已补,见下一条**。 - **被跳过的"单条过大"进持久账本**:`POST /import/log` 现在接受 `{ tooBig, tooBigNames }` 并**允许只报跳过、让账本的 `errors` 保持 0**(这是命门:否则"已知跳过"会被记成"失败批");账本行在「失败 X 批」之后追加 **`· 单条过大 N`**;**`tooBig = 0` 时落账形状逐字不变** —— 包括老客户端**不传 `errors`** 的那些请求,行为与修前完全一致。护栏:`tooBig` 夹 `0..10000`、名字**只留前 5 条 / 每条 40 字 / 非字符串丢弃**,同一 runId 合并时**再归一一次**(防多批把账本撑大);清洗实现**只有一份**,端点复用。✅ **真机已验(宿主重启后)**:一次真实导入落账为 `"tooBig":1,"tooBigNames":["未命名"],"errors":0`(只读查 `kv` 得到)—— 命门成立:**已知跳过没有被混成"失败批"**。 - **首字节闸(「只发头、不发体」;非回归,顺手关掉的洞)**:原先那套静默计时器**只在收到第一个 chunk 时才武装** ⇒ 这条形状在**应用层没有任何计时器**,兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。现在 `readBody` **进函数即武装**一次首字节闸、收到第一个 chunk **立刻解除**(参数复用 `drainIdleMs`,默认 30 s);错误形状是**独立的** `code='BODY_IDLE_NO_FIRST_BYTE'` / `status=408` / `got=0` / **不带 `limit`**(它与体量无关)。真 socket 实测:头齐 + `Content-Length: 4194304` + **0 字节** ⇒ **61 ms 断开**(旧实现:零响应一直挂着)。 - ⚠️ **别读成"客户端会读到一句解释"**:首字节闸的错误**不走 413 出口**,真机上落到"往将死的 socket 写一句、客户端收不到"那条路 —— **它买到的是"连接 61 ms 就断",不是"错误文案可达"**。 - ✅ **真机已验(宿主重启后 PID 12972)**:头齐 + `Content-Length: 4194304` + **0 字节体** ⇒ 客户端实测 **`ECONNRESET` @ 30,011 ms**(= 30 s 闸门到点);修前是挂到 Node 自己的 `requestTimeout`(**300 s**)。 - **记忆包导入的 413 说人话(只改文案)**:`⬆ 导入记忆包…` 是**整份一发**(`POST /import`)—— UI 默认包实测 **1.386 MiB**(安全),但 `includeRaw=1` 的**带原文包 5.959 MiB** 会撞 4 MiB。原先 413 时**吐一段原始 JSON**、连接被熔断时只说「导入失败(网关不可达)」。现在说清:①**整包**超上限、**不是包坏了**;②上限**只用回执 `limit` 换算**(回执没给就直说"上限未随回执返回",**不猜数**);③**怎么办** —— 点「⬇ 导出记忆包」重导一份**默认不含原文**的包再导入,并如实说明"整包一次发不完"是当前通道的结构限制。其余失败优先用服务端 `message`;`sealed` 分支**逐字未动**。**分批上传不做**(留 1.6)。 - **三处方法闸补齐(`POST_ONLY` **51 → 54**;后随手术门新增 `/surgery/ticket`,本版合计 **55**)**:只读审计盘出"未进 `POST_ONLY` 的差集 26 条"里,**唯一一条真写**是 `GET /branch-log/pending`(它在"水位键缺失 + `branch_log` 非空"时会**一次性补种水位** ⇒ GET 能把「复盘守门」的欠账计数顶成 0),另有一条**成本型** `GET /persona/draft` —— **GET 就能花一次全局模型额度**(它对"库"只读为真,对"额度"为假;注释已改准)。现在这三条(`/branch-log/pending` · `/persona/draft` · `/persona/check`)一律只收 POST:非 POST ⇒ **405 `method-not-allowed`**(且**方法闸先于 `readBody`**)。前端调用点**全仓核过**:`client.js` 三处默认 GET → 显式 POST,另两条**本来就是 POST**(零回归),`tests/` 与 `dsh-feishu-host` 下 **0 处**。⚠️ **host 侧改动 ⇒ 需一次宿主重启才在真机生效**。 - **折叠不再覆盖已有原文(数据安全)**:导入时若某会话在**网页端旧库(旧域)**里已存在,会走"**折叠**"(元数据与原文并进去)。原实现把**导入侧的 `seq`** 直接写进那个会话,而底层是 `INSERT … ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text` ⇒ **序号一重叠就把目标里的真原文静默换掉**(离线测试台实测 `clobbered=3`)。现在:**目标已有原文就不覆盖** —— 逐条按 `(role, text)` 比:相同 ⇒ 幂等跳过;不同 ⇒ **保留现有的**、只计新计数 `foldSkippedRaw`、**一个字节都不写**;**目标一个字都没有原文** ⇒ 照旧写入(升级路径不堵死)。⛔ 明确否决两条替代:按 `max(seq)` 往后追加(**同段对话在库里变两份、污染检索**)、整段替换(**丢真的方向**)。 - **"跳过了"必须说出来**:该计数贯通 **HTTP 回执 → 导入面板人话**(`ⓘ 另有 N 轮原文没有替换…不是失败,也没有丢东西`)→ **持久账本**(行末尾 `· 原文未替换 N`,且 `= 0` 时落账**逐字不变**)。 - ⚠️ **已知取舍**:同一会话**二次折叠不会再"补全"** —— 目标哪怕只有 1 轮原文,导入侧全轮也只进计数、不写库(宁可少副本、不换真原文)。 - **命名空间裂缝(同批修)**:折叠判断"有没有原文"原先**只看写入目标(dsweb 裸 id)**,没把 `import:` 命名空间一起算 ⇒ 某些顺序下同一段对话会在库里**存两份(两个 session_id)**,而那正是折叠要消灭的"**检索里召回两次**"。现在判据扩成"**候选链任一侧有原文**"(复用既有 helper):任一侧有 ⇒ 不写、计入 `foldSkippedRaw`;两侧都空 ⇒ 照旧写入。**真机库只读盘查:两侧都有 = 0**(102 个 session:裸 id 87 / `import:` 侧 15)⇒ 无旧账要清,本次为**纯预防**。 - **反代伪装 loopback 的加固(守卫⑤)**:`guard.js` 的"Host 是 loopback 就免检"在**引入反向代理后失效** —— Host 由客户端自报,而 cookie 只验名字(算法公开)⇒ **公网匿名可拿到真实数据**(实测:`Host: 127.0.0.1:3080` + 自算 cookie ⇒ 200)。现在新增 `realPeerAddress(headers)`:取 `X-Forwarded-For` 的**最后一段**(nginx 的 `$proxy_add_x_forwarded_for` 追加、客户端删不掉;**取首段可被预塞绕过,注释里已写明不许"优化"成首段**),回退 `X-Real-IP`;凡"Host 声称 loopback 却带着非 loopback 的代理对端" ⇒ **`spoofed-loopback-host` 拒绝**。本机直连(无代理头)与经 nginx 的正常远程访问**都不受影响**(后者根本不走 loopback 分支)。对照实验可自行复核:`tests\guard.test.mjs` §8 收录 4 合法 + 6 攻击 + 边界(**双向**:临时禁用判据 ⇒ 6 条 ✗)。 - **人格手术门**:`"手术门不是挡主人的,是万一网络失守时最后失守的位置"`(主人原话)。新增 `lib\host\surgery.js`,把门**下沉为一处** `requireSurgery()`,覆盖**全部 14 条**能改人格/规矩/习惯的写面;`sealed` 时依次判 **非本机**(带代理头 ⇒ `surgery-local-only`)· **每启动轮换的手术票据**(`/surgery/ticket` 只下发给"无代理头"的请求) · **原句匹配**;并**删掉 `wantLock` 绕过分支**(旧代码下"带 `sealed:true` 的补丁 + 假句"能直接改库,已实测证明)。另加**带哈希链的手术留痕**(`surgery-*.jsonl`,`prevLineHash` 串链,改一行即断)与**只读**事件面 `GET /surgery/events`。 - ✅ **真机复验(2026-10-01 下午)**:本机直连 `200` · **伪造代理头一律 `403 spoofed-loopback-host`**(守卫⑤ 真机生效) · 本机取票 `200`(64 位 hex) · **经代理取票 / 读事件面 / 打手术端点全部被拒** · `surgery.ticket` 与 `surgery-2026-10.jsonl` **均已落盘**。 - 🐞 **同批修掉一个"链永远串不上"的 bug**:`createSurgeryEvent` 原先**先算 `prevLineHash(opts.file)`、后解析 `opts.file || surgeryLogPath()`**,线上调用方不传 `opts.file` ⇒ `readFileSync(undefined)` 抛错被 `catch` 吞掉 ⇒ **每条留痕的 `prevLineHash` 恒为 `null`**(真机 5 条全是),`verifySurgeryChain` 因此**永远报 `brokenAt=2`**。已改成**先解析路径、再取上一条哈希**;新增回归测试 `tests\branch.test.mjs` **§7.8b**(走**默认路径**、不传 `opts.file`;**双向**:换回 buggy 行 ⇒ 两条断言 ✗ 且症状与真机逐字一致)。⚠️ **真机那 5 条历史留痕一个字不动**(审计留痕永不改写/重排)—— 它们会**一直**被报为断链,那是**历史事实**;"让校验能表达**含已知历史缺口**"的**链基线**见下一条。 - ✅ **承诺句按渠道分流(2026-10-01 下午 · 主人定调「这不是密码,而是庄重和郑重感」)**:`/state`(含面板档,共 5 个面)· `/persona/history` · `/export` —— **带代理头 ⇒ 不回承诺句**(只回 `hasSeal:true` + `phraseHidden:true`),**本机直连 ⇒ 照旧回显**(所以**永远不会因遗忘卡死**)。渠道判据**复用** `surgery.js` 的 `isLocalSurgeryRequest`(即 `realPeerAddress(headers) === undefined`,与守卫⑤、`surgery-local-only` **同源**),并有源码护栏钉住"1 处定义 + 恰好 3 个读面调用"。⚠️ **有意不做**:不改存哈希(哈希只在"能打端点、读不到文件"的人身上有增益,而远端已被掐死、本机同用户进程本就能读 `settings.json`;代价却是把"**宣言**"降级成"**密码**")、**不做**"本机+票据重置"(主人:「只用本机回显可以了,够的」)。 - ✅ **手术留痕的链基线(2026-10-01 下午)**:`verifySurgeryChain` 读 `surgery-chain-baseline.json` 的 `brokenRanges`,**区间内断链 ⇒ `ok:true` + `knownGaps`**(并继续校验后段、区间后重新接上),**区间外仍 `ok:false`**;⚠️ **只豁免"链"这一项** —— **区间内的自哈希照样核**(改过历史行仍会被抓)。内置默认区间 = 真机实况(`from 1, to 5`,理由:建链 bug,历史行不改写)。目的:别让"永远报错的告警"变成没人看的告警。 - ✅ **留痕去重(2026-10-01 晚)**:`/surgery/events` 的 `chain.knownGaps` 原来**按受影响的行各报一条**(真机 5 行 ⇒ 4 条一模一样的 `{from:1,to:5}`),长日志下会成倍膨胀。现在改成**每个区间一条**、`at` 记**首次命中行**;⚠️ **判据一字未动**(区间内断链仍 `ok:true`、**区间外仍 `ok:false`**、**区间内改一行仍被抓** —— 后两条用新写的负对照钉住)。真机复测:`{"ok":true,"count":5,"knownGaps":1}`。 - **免检通道:黑名单 → 正向白名单(E2)**:概述器与习惯生成器原先用 `session_id NOT LIKE 'import:%'` 这种**黑名单**排除导入原文 —— 黑名单把"**没见过的**"当自己人。现在收成**一处正向判据** `summarizer.js` 的 `dshSessionSql()`(= **裸 id 即 DSH 会话**,habit-gen 直接复用),凡**未知前缀**一律排除并写一条 kv 告警 `dbg.raw_unknown_namespace`(库干净时零写入,首见另打一条 warn)。**真机库实测:104 会话 / 89 个裸 id / 唯一带冒号的前缀是 `import:`(74 轮)⇒ 无未知来源**,且**真 DSH 会话的输出与修前逐字一致**(修前 golden 负对照)。⚠️ 边界:`dsh_turns_raw` **没有 `source` 列** ⇒ 将来若出现**不带冒号**的新来源名,物理上分不出来(判据是"裸 id = DSH 会话")。 - **标题长度的"同名不同语义"(E1)**:`retitle.js` 的 `TITLE_MAX_CHARS = 58` 是**生成上限**(切句读/硬截的落点),`session-line.js` 的 `= 60` 是**显示兜底夹取**(对任何来源标题生效,含手改标题)。旧注释本就自陈"概述器侧是 58,这里只做兜底"⇒ **60 是有意留的余量**。硬统一到 58 会**多切手改标题**、统一到 60 会**改 59–60 字输入的生成行为** —— 双向都是行为变更 ⇒ **只补注释 + 把不变式写成断言**("显示兜底 ≥ 生成上限")。 - **预览与写路径的 `changed` 判据收敛(C 组)**:写路径有 3 条判据(逐字节 / `md5(candidate)===lastMd5` / 实质键),预览只有 2 条(缺 md5)⇒ 存在"**预览说追加、写路径判 same**"的分化可能。现在**收敛到唯一判据** `session-line.js` 的 `contentChanged()`,两条路都调它 ⇒ **按构造**不可能再分化。⚠️ 如实说明:该分化**在干净库上产生不出来**(`writeLast` 历来把 `text` 与 `md5` 一起写),本次修的价值是"**让分化不可能发生**",不是"修一个线上已复现的错"。 - **折叠指纹判据扩到候选链(C′)**:上一版把"有没有原文"扩成了"候选链任一侧",但**逐条比对的指纹**仍只取写入目标那一侧 ⇒ 两侧都有原文时 `import:` 侧已有的同一轮会被算成"未替换"(计数偏大)。现在指纹集取**两侧**(实测 2 → 1),**零写入、零迁移**(历史账本旧数不回改)。 - **归因修正:不再拿"有没有原话"反推"是谁写的"(E3)**:旧写法 `source: body.quote ? 'session' : 'panel'` 用**客户端可控字段**推断身份,而**真面板发的请求本来就不带原话** ⇒ 「**会话内、不带原话的调用**」被记成 `panel`,而 `rules.js` 对 `panel` **免掉原话要求**并把原话位填「(面板直接写入)」—— **一次无原话、无主人在场的调用被记成"主人亲手写的"**。现在**面板身份改按请求形状判**(无会话身份 + 无原话),会话内缺原话记 `session-no-quote`、原话位如实写「(会话内调用,未附原话)」、`sessionId` 留真实 id;面板那条路径**逐字未动**(负对照)。⚠️ **结构限制**:`rules.js` 组装 meta 时写死两值(`panel`/`session`)⇒ **第三态进不了落库的 `ruleMeta.source`**;交付的是"**不再冒充 panel**",另有护栏钉住该两值契约防漂。⚠️ **残余**:面板身份是**形状识别不是签名** —— 本机任何调用方(含经 shell)都能照抄形状冒充面板;根治要改 `client.js`。 - **`/state?sessionId=` 的基数上界**:`freeze.js` 的 `_byId` 原来会随任意 `sessionId` **无限增长**。现在 `MAX_IDLE_SESSIONS_DEFAULT = 128` 只淘汰**空壳**会话;**带读数者(running/pending/snap/snapStale/mode)永不淘汰** ⇒ 已存在会话的**读数逐字不变**。 - **`/dsweb/summary/preview?db=` 的路径白名单**:原来任何路径都能当"只读存在性预言机"(靠 `readOnly:true` 兜底)。现在只接受**默认库 + 环境变量 + settings 里配置过的路径**,越界回 `{ok:false, reason:'db-not-allowed'}` 并给出**两条出路**(env / settings)。⚠️ `/dsweb/scan`、`/dsweb/summary/run` **未动**(不在本条范围)。 - **`POST_ONLY` 差集护栏**:`api.js` 注册 **79** 条路由、`POST_ONLY` **55** 条 ⇒ 差集 **24** 条。新增源码级护栏把**这 24 条逐条钉住** —— 将来**新增/删除**端点都会让断言变红,逼后来者**有意识地**决定"这条要不要进 `POST_ONLY`"。⚠️ 断言里写明"**这是快照不是白名单**:变红时该判断该端点是不是写面,不是无脑抄进数组"。 - **出厂默认值里不再夹带任何一台机器的私有配置(2026-10-01)**:`lib\audit\access-log.js` 的 `deviceCookie` 默认值原先是**开发机改过名的那个 cookie 名**(上游 `remote-web-ui` 的默认是 `dsh_pair`,开发机给它加过一个自定义后缀)。它同时是**泄漏**(会随 npm / GitHub 公开)和**对别人的缺陷**(别人的 cookie 名不是它 ⇒ 访问日志里 `deviceCookie` 恒 `false`、**设备维度静默失效**)。现在默认回到上游的 `dsh_pair`,并新增三级解析 **宿主配置 `accessLog.deviceCookie` → 环境变量 `DSH_LING_DEVICE_COOKIE` → 中性默认**;⚠️ **改过 `cookieName` 的机器必须显式声明**(否则自己那台也会静默失去设备维度;回滚只需删掉那段配置)。 - **迁移不再把"库只读 / 库被写锁"变成"插件加载失败"(2026-10-01 发现并修)**:`dsh_turns_raw` 的 `source` 列迁移里那笔**诊断写入**原先没有保护 ⇒ 遇只读库 / 被写锁时异常会冒到 `apply()` ⇒ **插件整个不加载**(实测对撞:同一份忠实 v13 库上,只读 ⇒ 旧行为构造成功 2ms、新行为抛 `attempt to write a readonly database`)。现在那笔诊断写进 `try/catch`(接住时只打一行 warn),迁移语义不变:`ALTER` 仍照旧**尝试**,失败则读侧回落"裸 id"判据(与修前同解),成功路径仍**零多余写入**。⚠️ **仍留一处慢未收**:库被**写锁**时启动会顿住约 **49 秒**(三笔串行写各等满 `busy_timeout`)—— 本轮**有意未改** `busy_timeout`(会动别处的锁语义),要收需单独决定。 - **反馈通路:停掉空转的轮询,改订宿主推送(U7)**:插件原来**每 60 秒轮询** `storages\message_feedback.json`,而该文件**自 2026-09-11 起零写入**(同目录 `workspace.json` 是当天 16:12)⇒ **空转约 20 天**;宿主 `dsh-message-feedback` **早就提供** `feedback/committed` 推送(`lib\index.js:288`),插件侧**零订阅**。现在轮询整块删除,改订 `feedback/committed`(冷会话)+ `session/event` 的 feedback 事件(活会话;`lifecycle.js` 放行并就地 return),并把两句已被证伪的注释("平台无推送")改写。⚠️ `entriesFromFeedbackFile` **保留**(`tools\process-feedback.mjs` 仍用它,删了会打断一个能跑的 CLI),注释降级为"CLI 兜底"。 - **CI(新增 `.github\workflows\test.yml`)**:Node **24.x**(22.x 的 `node:sqlite` 免 flag 未实测,故不写),**逐段跑 + 逐段记退出码 + 红段单列 + 段数自检**(不写 `npm test` 一把梭,避免截尾判绿)。⚠️ **该 workflow 与 1.5.3 一并公开**(随本版同一次发布动作同步到公开仓,不再另等一次点头);⚠️ **它从未在真 runner 上跑过** —— bash 段**在作者本机跑不了**(沙箱起不了 Git Bash、无 WSL)⇒ 只做到 YAML 解析 + Node 等价复刻 + 反向假段判红(作者本地的发布脚本里另有一道同类闸,**不在本包内**),**若首跑红在 shell 语法,改的是 workflow 不是代码**。 - ⚠️ **已知限制**:"封存但既无明文句也无哈希"的老档案按现状**进不去**(当前档案有明文 ⇒ 不受影响)。 ### 三、测试 - 新增 `tests/mode-service.test.mjs`(**11 组断言**):注册名与 disposer、`applyMode` **不动全局 `lastMode`**、`keepModel` 优先于全局、`syncEffort:false` 不调 `selectModel`、非法入参 `no-session-id` / `unknown-mode:*`、running ⇒ `queued` 且未写模式、`selectModel` 抛错仍 `ok:true` + `warning`、`provide` 抛错不外抛、`currentMode` 三级回退、对照 `applyModeToSession` 不带 opts 仍改 `lastMode`; - 另有**源码级护栏**两条:禁止出现 `setDefault:true`、必须出现 `setDefault:false`; - 新增 `tests/bodylimit.test.mjs`(**55 条断言**,每条打印实测值):排空到 `'end'` **之后**才 reject、期间 `destroy()` **零调用**、**排空态不再缓存**(拿旧的按条数实现当仪器做正对照:旧 retained 2 条 / 8192 B,新 0)、硬熔断、静默超时(活跃计时器 0→1→0、残留 0)、**端点级端到端 413**、socket 已死时不抛; - 新增 `tests/client-batch.test.mjs`(T0–T5):直调**真实源码**的 `takeChunk` / `utf8Len`(经 `tests/vm-light.mjs` 的 `contextify()` + `exports.__test__` 钩子,**不在测试里照抄一份实现**),含 20 条 × 200 KB 的真实请求体复算(2.86 MB < 4 MiB)、2000 条小条目仍 ≤200 条/批、单条 4 MB 的前进性、边界 ±1 字节; - `npm test` 链由 **36 套增至 37 套**(1.5.3 前两节),本批再增至 **39 段**;分段跑全绿:`PASS_SEGMENTS=39 · FAIL_SEGMENTS=0`。 - 后续三处修复(账本 / 首字节闸 / 记忆包文案)**只在既有测试文件里追加断言**,故**段数仍是 39**:`tests/bodylimit.test.mjs` 增 **T8 首字节闸**(14 条,含三条负对照:正常上传 / 慢但持续 / 首字节在闸门内,以及"武装点早于任何 `req.on` 注册"的源码护栏)、`tests/importlog.test.mjs` 增 **§8**(26 条,含**端点级命门**:只报 `tooBig` ⇒ `errors=0`,以及"`tooBig=0` 与不提这俩字段落账逐字相同"的对照)、`tests/client-batch.test.mjs` 增 **T8/T9**(记忆包文案与账本上报的源码护栏)。**双向验证**:把改动前的文件换回去跑新断言 ⇒ **全红**,换回 ⇒ 全绿(三组各留红绿,换回后 sha 逐字还原)。 ## 1.5.2 **`/state` 分档提速 · 活行档位改判 · 面板可见性收口 · 依赖声明与「承担」** > 一句话:本版补的是"看不见的那一层" —— 轮询路径与面板路径分开计价,`/state` 单次耗时降到零头;活行(面板上跟着会话走的那一条)的档位从"15 分钟 / 10 轮"改成"轮次边界 + 内容真变了才追加";面板第一次看得见"那一行"和"为什么没变";侧栏的「最近改动」不再静默截断;另外把 `peerDependencies` 补全(这道闸门决定插件会不会**悄悄不激活** —— 线索只有一行,而且不是每条启动路径都会打),并给器灵加了「承担」这一栏。 ### 一、`/state`:轮询档与面板档分开计价 轮询路径与面板路径问的是同一个端点,但要的东西不一样 —— 面板要 L0 预览文本、快照读数、活行预览,而 3 秒一拍的轮询只要几个计数。此前两条路走同一份重活。 - **按 `l0Preview` 分档**:面板路径恒带 `l0Preview=1`、轮询路径从不带 ⇒ 轮询档在算 L0 预览**之前**就早退,只回轻字段(既有 5 个快照字段逐字未动); - **`MemoryStore.fingerprint()` 的缓存键由一维扩成五维**:`total_changes()`(本连接自写)+ `-wal` 文件的 `(size, mtimeMs)`(外进程写入)+ `PRAGMA data_version`(维护脚本若用 `journal_mode=DELETE` 改库**根本不产生 `-wal`**,只有这一维兜得住)+ db 句柄(关连接再开)。其中 `-wal` 与 `data_version` 两维**取不到时各给哨兵值**(`[0,0]` / `-1`),**不因取不到而抛**(否则 `/state` 会 500);而 `total_changes()` 那一维**没有这层降级** —— 它直接问 db 句柄,句柄已关就照常抛(由路由的兜底 catch 变成 500)。返回契约逐字不变(`sha256` 全长 64 位,面板取前 16 位展示); - **`countOverviews()`**:记忆条数从"拉全量再取长度"改成一条 `COUNT` 聚合 —— 面板与 `/state` 都不再为两个数字付全表扫描的钱; - **实测**(当天 `00:00`–`16:34` 的访问日志 `access-*.jsonl` 里 `path` 为 `/api/dsh-ling/state` 的请求,取 `ms` 字段;分界取 `13:13` 这一分钟、两侧都不算它 —— 切换就发生在**这一分钟之内**:`13:13:08` 仍是 28 ms,`13:13:16` 起已是 1~2 ms,而该分钟自身 15 次里 13 次 `≤2 ms`):分界前共 **n=51,573** 次、均值 **24.4 ms**、`≤2 ms` 占 **0.02%**;分界后共 **n=3,945** 次、均值 **0.46 ms**、`≤2 ms` 占 **99.5%**。两段是同一天、同一个端点的读数,差别只在"这一版是否已经生效"。以上是**本机这一次**的实测:**两段的窗口起止与样本量都写在句内**(样本量是**读日志那一刻**的值,日志继续写入后按同一口径重算会略大),可用同一天的日志按同样口径复核;换台机器、换个库、换一天都不会一样。 ### 二、活行(面板上的「那一行」):档位改「轮次边界 + 内容真变了才追加」 - 旧档位(**15 分钟最小间隔 / 10 轮**)撤除,改为两条:① 只在新轮次的**第一批**(`step === 1`)考虑追加;② 「内容真变了才追加」用**三道同源判据**,任一说"没变"就不追加; - **为什么非要第三道**:那一行正文自带 `#N`,若拿整行去比,**每试一次序号就 +1** ⇒ 恒判"变了",闸等于没有(实测钉过);第三道比的是**实质内容键**(标题 + 最新进展,剥掉 `#N` / 轮次 / `HH:MM`); - 旧档位常量与判定函数**保留导出**(测试与探针仍引用),但只读探针里明确标 `tier.active: false` —— 一眼看得出它不在生效路径上; - 顺带:这一行"上一次追加过没有"的留痕带 `appendCount`,**重启后判定照旧有效**(不会"重启即多追加一条")。 ### 三、面板:两条「那一行」+「为什么没变」+ 可折叠看全文 - **「那一行」在盘上有两条**,长得像但不是一回事:① **L2 本会话标题行**(概述器写进开场记忆的那一条,只有标题)② **活行**(面板上跟着会话走的那一条,每轮重算)。此前面板不显示、也不区分 ⇒ 现在**各自成行、各自标注**; - **「为什么没变」拆成四类看得见的读数**,每条只印真读数;后端这一版没带那个字段时**明说"不做猜测"**,不拿默认值冒充结论; - **可折叠看全文**:折叠状态存**闭包**而不是 DOM —— 面板每一拍(store tick)都会重建这一块,存 DOM 会每一拍被清掉; - **没有快照时不再谎报「已定稿 0 字符」**:后端在没有快照时回的是 `{exists:false}`,而它是个**真值** ⇒ 前端原先照样进"已定稿"分支。现在先判 `exists === false`,显示「(未定稿 —— 本会话尚无快照,通常在会话第一次定稿后出现)」。⚠️ 这条尤其要紧:**"没有快照"与"有一个 0 字符的快照"外观一致、语义相反**,而后者真会发生(例如人格为空),所以它不是"显示得难看一点"; - **面板打开后不再冻结**:复用 store 已有的 tick 重画,**不新开 `/state` 轮询**(轮询刚降过频,不能再加回去);store 覆盖得到的行跟着 store 走,覆盖不到的逐行标 `〔打开时快照〕`,快照块前另加一行分隔说明; - **两条残留(如实写明)**:① 全局视图最快 **30 秒**才动一次 —— 全局 ticker 已被有意降到 30 s,这是"不加回轮询"的必然结果,不是未修;② 面板开着切会话时,"打开时快照"那一整块仍是**旧会话**那份(实时行会跟着变)。 ### 四、侧栏「最近改动」不再静默截断 - 原先客户端**请求写死 6 行**、渲染**再硬钉 6 行**,**溢出无任何提示** —— 恰好改了 7 处时,看上去就是"少了一条"; - 现在取数与渲染**两个上限分开命名**:取 100 行、渲染仍是那 6 行,其后**条件追加**「…另有 N 笔改动(此处只列最近 6 笔)」; - **N 取服务端真计数**(与同一块红字「有 N 笔还没跟器灵说过」**同数同界**),不是"返回条数 − 6" —— 侧栏只取**一页**,而改动留痕是**全量**:页面恒满 ⇒ 这个差值恒等于"取数上限 − 6"这个纯常数(留痕随库增长按条数线性上涨,可它一动不动),属于"用两个常量的差冒充积压量"的老坑; - 满页时退路说「**N 笔以上**改动」(保留量级,不冒充确切数);`id` 读不出来的行**当作已列出**(宁可少报,不谎报);`snapshot` 行**不算债**; - **零后端改动**,硬刷页面即生效。 ### 五、依赖声明:这道闸门决定"插件会不会悄悄不激活" - `package.json` 落 **12 条 `peerDependencies`**,范围一律**开区间**。宿主启动时对每个 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peer 跑一次 `semver.satisfies(宿主版本, 你声明的区间, { includePrerelease: true })` —— 判的是「**宿主版本是否落在你声明的区间里**」,**不是**"你的写法合规不合规";判不满足 ⇒ 整个 bundle 进 `skippedBundles`、**不激活**。线索分两条路径:**从命令行启动**会往 **stderr 打一行** `skipping profile bundle …`(**容易被忽略,但不完全静默**);而**应用自持 profile 的那条装载路径一个字都不打** —— 那才是真正难查的一种,表象常常只有"它突然不见了"。⚠️ 所以"改成 `>=` 就安全"是错的:**开区间同样可能不满足**(取决于宿主版本);`^` / `~` / 钉死版本只是更容易被将来的一次宿主升级甩出去。(确需放行某个确切版本时,宿主提供 `dsh plugin allow-version` 豁免通道;豁免生效时它照常加载。) - `dsh.client.inject` **5 项 → 3 项**:删掉 `@deepseek-ai/dsh-client-store` 与 `@deepseek-ai/dsh-client-ui-slots` —— 这两个包在宿主里**没有客户端半边**,列在这里是从模板继承来的死边(⚠️ 顺带一条命名陷阱:那一列装的是**包名**,不是服务名); - `tools/check-install.mjs` 的 `--profile` 默认值改读 `DSH_HOME`(此前一处写死 `~/.dsh`,与同文件另一处口径不一致); - 新增开发侧只读自检 `tools/check-peers.mjs`(**不进发布包**):把"宿主版本还落不落在本插件声明的 peer 区间里"(不落就整个 bundle 不激活)变成一条命令能查的事。 ### 六、「承担」:职责字段 - 人格新增一栏 `duty`,注入面段头 `[承担]`;**引导**写成陈述句(起草提示与面板都这么建议 —— 保存侧**不校验句式**);**空值 ⇒ 整段不渲染**(不留空段头); - 诞生仪式的 JSON 契约 **4 键扩成 5 键**(新增 `duties`):由模型从导入的历史里总结 1~3 条候选 —— **起草权归她**,但**与其它候选项一样只是草稿**,终审与保存仍在人格中心; - 提示词里明令**禁止授权式写法**(「我应当 / 我必须 / 我的任务 / 我的责任是 / 我被要求 / 请让我负责」):授权口吻会把她写成一个**被授权者**,而不是"她自己是什么" —— 那是语义劫持最好用的钩子;前端在候选旁配同款提醒; - 字段白名单同步(`PERSONA_PATCH_KEYS`)—— 这一类字段**漏加就会在落盘前被丢弃**;丢弃**不是静默**的:宿主把被丢掉的键路径回在响应的 `ignored` 里、另写一行 `console.warn`(面板不读这个字段,所以漏加时**只在宿主日志里看得见**)。 ### 七、树改动播报:拆成三条水位 - 原先「已播报」水位**按会话**存 ⇒ 新会话水位从零开始,把最新几条**又播一遍**;而播报后水位直接推到**全表最新 id** ⇒ 一次播报把中间一百多条标成已读,而它们**从未被示人**; - 现在三条水位并存:**全局** `announced`(只推到**这次真正展示了的最大 id**)+ **会话**水位(本会话增量)+ `last`(全表最大 id)。⚠️ 第三条**有意不动** —— 它同时是**复盘模式的退出判据**,顺手改会让复盘模式退不出去; - 超窗老账单独用 `retired` **计数式处置**(只计数不展开),不算"已示人";并修掉"超窗计数只在扫描窗口内累加"造成的**一次性低报**,以及老账多于扫描窗口时**游标饥饿**(每轮只前进 120 行、明细行永远渲染不出来)。 ### 八、测试与计数口径 - `npm test` 链登记 **36 套**,全绿; - **计数口径统一**:以 `npm test` 链里登记的段数为准(不再数 `tests/` 目录);README 与发布门禁**两边同口径**; - 门禁新增**双向核对**:链里登记的路径必须在源目录里存在;`tests/` 下有测试文件却没登记进链 ⇒ **报红**(它会被 `npm test` **静默跳过**,一条断言都不跑)。 ## 1.5.1 **审计必修落地 + 一处平台升级事故 + 会话历史可见性** > 一句话:1.5.0 之后又跑了一轮全面检查(109 条发现),必修**逐条落地并单独实测**;期间揪出一处「平台升级把事件改名,导致记忆与人格注入**整段静默失效**」的事故;另外修好了「接入历史只看得到三成会话」。 ### 一、平台升级事故:一个改名,让注入整段静默失效 DSH `0.1.7` 把事件 `agent/session-start` 改名成 `agent/created`(**载荷形状不变**)。器灵监听的是旧名 ⇒ 在 0.1.7 上**零命中** ⇒ 会话定稿**从不发生** ⇒ 注入的「无快照 + running」分支**只送出时间锚**,记忆正文与**人格全部空白**。 - **失败形态纯静默**:无异常、无日志、无告警、库里零痕迹; - **修法**:`lifecycle.js` **两个名字都注册**,并加 3 秒短窗口去重(跨平台版本,不赌它保留哪一个); - **验收**:`branch_log_wm.last` 392 → 395,注入正文回来; - **教训**:平台契约不只是**字段**,还包括**事件名** —— 凡 `ctx.on('<事件名>')`,平台升级后必须 grep 一遍这个名字还在不在。 ### 二、必修 8 条(1.5.0 审计的必修,全数落地) - **发布链**:游离的 `.bak` 文件会卡死发布前检查 ⇒ 移入归档区; - **补丁锚点多 cohort 化**:E4 补丁的锚点在不同 DSH 版本里写法不同 ⇒ 改为**逐 cohort 候选**,**恰好一个唯一命中才动手**;多个同时命中 = 歧义 ⇒ **拒绝改动**,全不命中 = 漂移 ⇒ 退出码 2 等人工更新锚点(宁可不动,也不在核心代码里插错地方); - **时间锚出口补中和**:`context()` 出口补 `neutralizeMustache`,堵住一个从新出口复活的注入面问题; - **器灵记自己的端点**:不再往核心补第三条补丁,改为在器灵自己的 `guard` 上挂 observe ⇒ **器灵自己拦下的请求也进访问日志**(受控实测:修复前 0 行 → 修复后 4 行); - **回灌写入侧跨源污染**(第三次复发)⇒ `restoreEntry` 写入侧统一走 `rawSessionId`; - **主脉记忆**:提炼结果采纳时**备份后覆盖**,并落总变动日志(概括应随主脉一起长); - **主脉素材排除已遗忘条目**(遗忘条目被提炼灌回库 ⇒ 遗忘前 1 → 遗忘后 0 → 撤销后 1); - **「↶ 撤回」修好**:状态原本放在组件体内、被重渲染清空 ⇒ 改存 ref。 ### 三、会话历史:只看得到三成(本轮新必修) 会话文件名是**随平台版本变化的**:`session.jsonl.zstd` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`。器灵原先**写死**了第一种 ⇒ 本机实测只认得 **53** 个会话,**漏掉 124 个**(同一个会话目录里的多代文件只算一个,去重后本机共 **177** 个)。下面这几个数字是修复当天的**实测快照** —— 会话目录会持续增长,你自己机器上测到的数不会与它相同,判据是「候选数显著变大、解码失败为 0」。 - 改为按 `session(.v\d+)?.jsonl.zstd` 匹配并**取版本号最高**的一代(跳过 0 字节的未完成文件);同一会话目录只出一个候选,**天然去重**; - **实测**:候选 **53 → 177**,解码失败 **0** ⇒ 新旧两代会话格式**内容形状完全兼容**,不必做格式适配; - 顺带修掉一个真病灶:**「0 候选」与「这台机器没有历史」原先不可区分** —— 正是它让上述缺陷**静默了两周**。现在 0 候选时会显示 `⚠` + 一行具体原因(没找到会话根目录 / 没有会话目录 / 都是空文件 / 文件名代次不符),并列出目录里**实际见到的文件名**。 ### 四、`rejection` 不再误标 访问日志的 `rejection` 原先**从状态码反推**(`403 ⇒ untrusted-host`),前提是「全系统只有核心栅栏会返回 403」。第二节那条修好之后,器灵自己拦下的请求也进日志了 ⇒ **拒因被大面积误标**(7 种拒因里 **6 种**被记成 `untrusted-host`)。 现在改为**真因优先**:判定完成后把真实拒因挂在请求上,日志优先用它,读不到才退回旧判据(核心栅栏的 403 依然正确)。取值域因此从 3 类扩到 **9 类**:`no-host` / `bad-host` / `untrusted-host` / `cross-site` / `origin-mismatch` / `no-cookie` / `empty-cookie` / `bad-cookie` / `method-not-allowed`(`bad-cookie` 仍由 401 退路产出,没有消失)。 ⇒ **1.5.0 条目里那句三值清单(`untrusted-host · no-cookie · bad-cookie`)自本版起不再完整** —— 那三个值**都还在产出**(核心栅栏的 403 与 401 两路照旧),只是已不是全部,以本版为准。 ### 五、设备标识不再落凭据原文 访问日志里的 `deviceId` 是**设备凭据的原文**。现在改为 **sha256 不可逆摘要**(取前 16 位十六进制):日志仍能回答「是不是同一台设备」,但**不再持有可用的凭据**。 ### 六、归档 / 遗忘 / 回灌:从零覆盖到两处 fail-closed > 起因:1.5.0 新加的 `memory_forget` / `memory_backfill` 把「删掉的还能不能捞回来」押在这条通路上,而它此前是**零测试覆盖的裸区** —— 审计里最危险的一块。 - 新增 `tests/forget-backfill.test.mjs`(**307 行 / 86 条断言**),四段对账:**行数守恒**(归档里的 `turns.jsonl` 一行不少)· **召回**(遗忘后不再被想起来)· **撤销**(`undo` 之后回来)· **回灌往返 hash**(灌回去的概述与归档里的一致);另加 **import 源单独断言**(裸 id 零出现 —— 跨源污染此前复发过,这是防下一次的回归网); - **回灌不再丢标题锁(B-02)**:`upsertOverview` 的 INSERT 列清单与 `ON CONFLICT SET` 里**都没有** `title_locked` / `title_by` ⇒ 硬删之后回灌,「这是主人亲手定的名」这个状态会**丢**(旧 SET 里那句 `title=CASE WHEN title_locked=1 …` 只是**读锁**的写法,救不了已经丢掉的锁)。现在改为「**调用方显式给了才写**」(对象里带 `title_locked` 这个键才算声明);概述器重建与批量改名因此不会再顺手把锁抹掉,解锁只能走 `unlockTitle`; - **`forget` 空归档守卫(B-05):两处 fail-closed** —— 归档侧给出 `empty` 判据(0 轮原文 + 无 summary 正文 + 无 sessionMeta),数据层再加一道**验盘闸门**(没有归档路径 ⇒ 拒;未声明轮数 ⇒ **直接数盘上的 `turns.jsonl`**;声明 0 ⇒ 拒)。旧行为下「条目还在、原文已被硬删」会拿到 `ok:true`、**打上一个永久遗忘标记**,而回执还谎称已归档 —— 那条标记永远召不回任何东西,界面上却显示「忘过了」。 ### 七、会话内工具面:入口那一簇 > 1.5.0 把工具面从 3 个扩到 7 个。本版修的是那一簇的**入口** —— 模型照着提示做会掉进 `no-match`,或者回执说的和库里的不是一回事。 - **工具回执改印全长 id(D-T01)**:`tree_read` 概览行尾原先印 8 位短 id,而 `branch_edit` 的查找全是**精确相等**、只认全长 id ⇒ 照提示做必然 `no-match`,而 `no-match` 的提示语还在教「用概览行尾那 8 位」(一个自洽不了的闭环)。现在印全长 id; - **`link` 校验两端存在性(D-T02)**:这是**悬空边唯一的产生口**,现在挡在写之前,并把**哪一端**找不到念出来(此前只说「不存在」,调用方只能猜)。`unlink` 有意**不**以「两端必须存在」为由拒绝:库里可能本来就躺着旧版造的悬空边,而它是清掉悬空边的通道 ⇒ 照删,并把 `dangling` 如实回传(说清「清掉的是一条悬空边」);边本来就不存在则回 `no-match`,不写假留痕; - **`weight` 回执如实(D-T03)**:`setBranchWeight` 会把系数夹到 `[0,2]`,而回执原先用**入参**覆盖 ⇒ 请求 99、库里 2、回执说 99。现在回**库里的值**;请求值与落库值不同时另带 `weightRequested`,让回执能说清「被夹了」; - **`impact` 闸门(D-T05)**:非空枝删除的闸门此前**形同虚设** —— `why` 表里没有 `not-empty` 这个键,memory 算好的四个数字(子枝 / 会话归属 / 条目归属 / 连边)在 render 这一步被整个丢掉,模型只看到一句「未执行(not-empty)」。现在把四个数字念出来,并给出更稳的做法(先把里面的东西挪走,或者只做 rename / reparent); - **`reparent` 不再静默挂到主干(D-T06)**:缺 `parentId` 原先静默挂主干 ⇒ 现在与「父枝不存在」**分开成两个码**,参数缺失直接报错(要挂回主干请显式传 `trunk`);`create` 的默认仍是主干(新建时没有「你忘了给」这回事,性质不同因而保留); - **`source` 参数白名单(B-11 / D-T07)**:`forget` / `backfill` 的来源域只在库里真有的域里选;且**目标必须真的存在**才动手,存在性检查放在归档**之前** —— 给不存在的条目打遗忘标记,只会留下一条**永远召不回**的空标记,界面还显示成「忘过了」。 ### 八、主脉提炼那条链:并发、悬空候选、回执口径 > 起因:`/vein/distill` 是本版新链路的入口,而它在最外面**一把锁都没有**;再往里两处,回执与库里的实际情况会分叉。 - **提炼并发锁(E2 / C-06)**:`/vein/distill` 原先**没有任何单实例锁**,连点两次 = **两次模型调用** + 同一条主脉**两条候选**。现在复用本仓既有的 `BATCH_RUN` / `batchEnter` 那一套(与 `/conflicts/judge`、`/tree/autobuild/name` 同一套:模块级标志 + 整批 deadline,到点不再开始下一次尝试),响应形态与 `busy` 文案因此与客户端既有处理一致;抢锁在**读 body 之前**(`batchEnter` 是同步的、同 tick 原子,而 `await readBody` 是本处理器第一个让出点 —— 放到它后面,两个并发请求会先读完 body 再一起冲进模型调用,锁等于没上),`finally` 放锁,并发的第二次回中文 `busy`; - **悬空候选不再谎报成功(A-06)**:主脉被删之后再采纳它的悬空候选,原先照旧回 `ok:true` + 一个**已删枝的 id** —— 底层把 `setConvBranch` 的 `{ok:false, reason:'no-branch'}` 吞掉了,归属其实落回了主干。现在**采纳前先做真实存在性门**(候选所属主脉此刻还在不在 branch 表里,不在就**一个字节都不写库**),**写完再复核一次归属**,没落上就如实回「记忆已写入,但没能挂到主脉下」; - **锁的语义说清楚(A-12)**:`title_locked=1` 只让 `upsertOverview` 不动 title,**summary 照样被覆盖** ⇒ 主人手改过名字的那条主脉记忆,下次采纳会换掉**正文**、留着名字。这把锁**不该由端点扩权去挡主人**(采纳本就是主人的显式动作,前面还有解锁门),该做的是**把这件事说出来**:回执带 `naming{title, titleBy, locked}` 与一句中文(名字是你手改过的、按约定保持不动),而不是让主人以为正文也被保住了; - **失败不再抛英文内部码(E9)**:`/vein/suggestion/resolve` 失败时(`empty-text` / `bad-id` / `archive-failed` …)补一个中文 `message`;`ok` / `reason` **逐字不动**(客户端可能仍在读 reason,兼容优先),底层已带 message 的优先用它。 ### 九、前端一簇:吞错、骗人的提示、写错内容的卡片 - **三处吞错(E6 / E7 / E13)**:断边、「忽略候选」(语料建议与反馈各一处)、器灵概览那几路请求,原先**不看 `r.ok`、也没有 `.catch`** —— 而 `api()` 只在 fetch **reject** 时抛错,后端「HTTP 200/400 + `{ok:false, reason}`」这条**业务失败**路径不会抛 ⇒ 后端明确回了失败,界面照样报「已断开 / 已忽略」。现在统一走一个共用判读器 `apiOk()`(后端若已带中文 `message` 就优先用它,不用 reason 覆盖更准的说明),失败时报中文原因;概览页另修掉一个「永久停在『加载器灵概览…』」的坑,拿不到的那几项**如实说缺哪几项**,不再假装数字是 0; - **删掉骗人的 Ctrl 多选(E12)**:记忆中心的提示一直教「Ctrl+单击 = 加选/减选」,而那个染色的选中集在拖放被裁掉之后**再无任何消费者** —— 它从「双真值缺陷」变成了更坏的东西:**一个骗人的提示**。修法是**把提示与实现一起删掉**(`treeSel` 成为唯一选中真值:点击 = 选中本行、再点 = 取消),而不是「让它生效」:这个视图是只读的,没有批量动作可挂,后端也没有被它调用的批量端点。(侧栏树那一路的多选**另有消费者** —— 批量拖动本身就吃那个集合,故原样保留。) - **待审核候选只渲 6 条(E3)**:标题如实写「待审核 20 条」,下面只画 6 张卡,没有分页、也没有「还有 N 条」⇒ 第 7 条之后的候选**永不可达**(既看不到,也无从采纳/驳回)。根因是**前端硬截断** `slice(0,6)`(同一次渲染里标题就用 20 算,说明 20 条**已经在手里**);现在**全量渲染**,并把请求的 `limit` 从 20 提到 **100**; - **判定循环的忙锁口径(E14)**:三个复盘按钮的兜底原写成「单次操作总时长上限 180s」,而 `judge()` 的最坏形态是 **40 轮 × 每轮一个 HTTP 请求**,单轮最坏 ≈ 210s(后端整批上限 120s + 在飞那一条收尾)⇒ 一到点锁自动解除,主人再点一次就能**并发起第二个判定循环**(后端有单实例锁,不会双倍烧模型,但界面会莫名报错、半途而废)。现在把语义改成「**无进展容忍上限**」(默认 180s;judge 单轮另给 240s 窗口),判定循环**每轮回来续期** —— 「既不 resolve 也不 reject 的挂死」照样解除,「活着、只是在跑很久」不再被误判; - **审核卡片的 key(E8)**:下标当 key + 非受控 `defaultValue` ⇒ 列表一缩短(采纳/驳回掉一条,或重拉后顺序变了),React 按**下标**复用同一张卡片的 DOM:第 i 个框里**留着上一条候选的正文**,而卡片元信息与按钮闭包已换成第 i 条 ⇒ 点「采纳」= **把 A 的正文写进 B 的主脉**,静默写错内容、界面没有任何迹象。改用条目主键 `sg.id` 立 key(正文本身也进 key,内容变了 ⇒ 重挂载);**并脉 / 连边的下拉也是常量 key,而「并到」按钮读的正是那个 DOM 值 ⇒ 可能静默并错枝**,一并改成按选中节点立 key。 ### 十、安装与分发:包名改了 > npm 上 `dsh-ling` 这个名字**已被他人占用**,本版起发布名是 **`@nightpainters/dsh-ling`**。 - `package.json` 的 `name` 改为 `@nightpainters/dsh-ling`,并加 **`publishConfig.access = "public"`** —— scoped 包**不显式声明会默认 restricted**(别人装不上,还会在 npm 上占住一个私有包名); - README 补**两条安装路径**:官方入口 `dsh plugin add`(在 profile 目录里转发给 pnpm,并自动登记进 profile 的 bundles 层列表)与**源码 / 本地开发**路径(`pnpm add file:<路径>/dsh-ling`); - ⚠️ **最容易配错、且配错就整片前端黑屏的一处**:客户端 bundle **自报的注册 id 必须逐字等于 npm 包名** —— `lib/client.js` 顶部 `window.__ModuleLoader__.load({ id: … })` 的 id 已随包名改为 `@nightpainters/dsh-ling`; - **机制**:客户端模块表以**包名**为 row id(`dsh-client-modules/lib/index.js:884` 的 `graphRow(packageName, …)`),浏览器按 row id 判断"该插件模块是否到位";bundle 自报 id 与之不符时,系统会**回退去取单资源 URL,把同一份字节再执行一遍**,第二次注册撞上去重守卫 ⇒ `duplicate factory registration` ⇒ `web boot: 1 entry did not activate` ⇒ **前端整片黑屏**(本版开发期间实测踩中两次); - `cordis.patch.yml` 的 `id: dsh-ling` **保持不动**(它是 Loader 的身份键,与 import 无关),`name` 必须是 npm 包名(它就是被 import 的说明符); - **防复发**:`tests/unit.mjs` 已加「注册 id === `package.json` 的 `name`」断言 —— 以后再改名,它会先红; - 已在 profile 里按旧包名登记过的:把那一行的 `name` 换成新包名,或直接用 `dsh plugin add @nightpainters/dsh-ling` 重装一次。 - README 补上**访问日志与核心补丁的总开关**:`accessLog.enabled`(**默认开**)、关掉之后**退化的只有留痕这一件事**(不挂观察钩子 / 不写日志文件 / 健康读数归 `null`,栅栏与插件其余功能不受影响),以及核心补丁 `--revert` 这条退路 —— 此前**有开关、没文档**,看文档的人不知道它能关; ### 十一、注入面:段头、分隔与计数(本轮同批) - **段边界与排版**:规矩 / 习惯的段头去掉括号里的**元描述** —— 那是写给主人看的说明,不该进模型上下文(且「可直接追加」读起来像一条授权指令);底线段头由 `底线:` 统一成 `[底线]`,三类段头自此都是**纯标签**;段与段之间补空行(段内条目仍单换行);条目正文里的方括号做**转义**,否则正文能自造一条段边界 —— 规矩 / 习惯 / **底线**三类条目统一转;渲染出口再按同键**各去一次重**(规矩 / 习惯):保留首次出现、顺序不变、只在渲染期生效、绝不回写存储; - **改动播报的数字改成真的**:注入面末尾那句「另有 N 笔改动」原先取的是**两个写死上限的差值**(窗口上限减渲染上限),与库里实际剩多少**无关** —— 积压一旦超过读取窗口,它就恒等于同一个数、不再随真实积压变化;现在**直接问库**(一条主键范围扫描的独立计数,代价与积压量无关),报的是**真实剩余条数**。 ### 十二、发布面文档:形态声明、措辞统一、机制性声明勘误(本轮同批) - **README 新增「本项目是什么形态」一节**(开头项目介绍之后、安装段之前):本地运行的软件、跑在你自己的机器上;上游不提供任何在线服务、不运营任何服务端、不托管任何用户数据(作者不接触、不接收你的会话内容与记忆库);**不上架任何应用商店**,仅通过 GitHub(源码)与 npm(包)分发;模型与 API Key 由使用者自行提供;不提供医疗 / 心理 / 法律或紧急救助服务。同节另附三句:**定性归属**(本项目不定义你与它的关系)、**AI 生成标识**(回答与记忆摘要由人工智能生成,可能存在错误,请自行判断)、**面向性**(面向具备自行配置与运维能力的用户;不面向未成年人,也不提供在线陪伴服务); - **定型门的说法统一为「庄重感」**:README / `lib/host/seal.js` / DESIGN 三处原先写作"仪式感 / 仪式"的字样改为**庄重感**,与代码里既有的「锁只造庄重」同一口径 —— **语义零变化,纯措辞**(手术门的定性没变:它是庄重与自我提醒,不是安全边界;真正把门的是来源栅栏); - **机制性声明勘误一批**:抽取三份发布面文档里**可证伪的机制性声明**逐条对代码事实核验,**不符 6 条、部分符合 13 条,全部点名旁路并改准**。典型:习惯生成器的阈值口径(代码已收紧为「≥4 次且跨 ≥3 个会话」,文档仍是旧值)· **时间锚示例给了代码产不出的精度**(日期的时分)⇒ 改为只给日期 + 时段,并注明要精确到分钟走 `clock` skill · 规矩 / 习惯的「上限」实为**建议上限**(超出只在写入后提醒,**不拦**)· 「必须带原话作凭据」与「reason 必填」实为**仅器灵工具通道**强制,**面板豁免** · `Ctrl+Z` 实际只覆盖**复盘栏的拖动 / 连边**(记忆中心那一份监听早已删掉) · L0 人格实为**顶层会话与会话分叉**注入、**子代理会话不注入** · 「内容零改写」有一个例外(主脉提炼**再次采纳**会覆盖该主脉记忆的上一版,**覆盖前自动归档**,注入面说明"旧版已归档") · 打分公式里的 `× 枝系数` 删掉(该系数**预留、当前恒按 1 计、不参与打分**)· 导入返回字段名与实现对齐 · 并**删掉一句不存在的承诺**(原先写"登记由单测兜底",实际是**手写清单**,漏登记不会被自动发现); - **这一批为什么会存在**:不是手滑,而是**文档写得比代码具体,就会长出偏差** —— 代码一改,文档不会自己跟着变,而读文档的人会把它当事实。三类最易失真:**具体数字 / 机制名 / UI 可见性**(声称"面板可见"的,后端接口里有 ≠ 面板上看得见); - **接住这条规律的动作(发布前常设一步)**:把**机制性声明核验**写进发布手册的固定动作 —— 逐条拎出可证伪的声明;数字类去代码里**把那个常量找出来**;机制类**主路径之外必须查旁路**(面板路径 / HTTP 路径 / 工具通道三条都要查);判「符合」必须给代码证据,不接受"听起来合理"。核验基线文件随仓库保留,下次对着它看差异 —— 让这条规律**有动作接着**,而不是靠下次还记得; ⚠️ 顺带一句边界:合规检查(关①.6)只拦**禁用措辞**与**声明缺失**,**不检查声明是否正确**;而判据表本身就是从同一批文档里拎出来的,带着同样的理解 ⇒ **它抓不到"文档与代码不符"这一类**,那一类靠上面这条核验动作接。 - **随仓库新增判据表** `tools/compliance-spec.mjs`(禁用措辞 / 必须存在的声明 / 出站白名单;纯数据 + 纯函数,不读盘也不写盘),供发布链路复用;**它不入 npm 包,也不参与运行期**。 ## 1.5.0 **记忆树闭环 + 器灵侧正规通道 + 可取证访问日志** > 一句话:记忆树从「看得见」走到「器灵能亲手改」;时间从「每分钟对着人嚷嚷」改成「按需查」;访问日志从「一行都没有」变成「可取证」。 ### 一、主脉提炼(R7 · D9-b 第 4 项) 主脉不只是一层目录 —— 它可以有自己的记忆。 - 新增 `POST /vein/distill`:取该主脉**及其子枝**的概述,交给模型提炼共性;四道守卫分别回应「主脉不存在 / 不是主脉 / 没素材 / 那类失败」; - **提炼结果不直接进记忆**,先进 `vein_suggestions` 候选表(schema **v11**),主人**可改后采纳**;驳回只改状态; - 采纳写入 `conv_overview(source='vein', origin='vein-distill')` 并挂到该主脉自己名下 —— 天然享受标题/锁/权重/深摘;`sources` 记录派生来源(哪几条枝,截 40 条); - 提炼用**器灵自己的全局模型**(不是小助手):「你在给自己做整理,别让小助手代劳」—— 判断类工作归器灵,压缩/分类类留给小助手; - 侧栏新增「✦ 提炼主脉」(选中主脉时出现;选中**枝**时显示它所属的主脉并直接对那条提炼)与审核卡片; - **采纳需要解锁**(进入复盘模式)—— 它写记忆、改归属,与改名/并脉同级;**提炼不锁**(只产草稿)。 ### 二、器灵侧正规通道(Part G · G1) > 起因:复盘时器灵要改记忆树却**没有任何 agent 侧工具** —— 只能伪造成本机进程走 UI 的 HTTP 口、或者直接开库;留痕还记在主人名下。 - 新增 4 个会话内工具:**`tree_read`**(读树 / 读某枝会话 / 看待理矛盾)· **`branch_edit`**(建枝 / 改名 / 挂载 / 连边 / 断边 / 调权重 / 删枝)· **`memory_forget`**(软遗忘)· **`memory_backfill`**(回灌);工具面 3 → 7; - 把树操作从 HTTP handler 抽成 `lib/host/tree-ops.js`,13 个 handler 变薄壳 —— **界面与工具共用同一份实现、同一份留痕**,零行为变更; - `branch_log` 新增 **`actor`** 列(schema **v12**):器灵改的写「鱼姬」,主人改的写「主人」; - **永不彻底删除**:`memory_forget` 先把原文与概述归档到 `forgotten/<时间戳>-<原因>/`(`turns.jsonl` 逐行 JSON、可 grep,`README.md` 写改动日志与还原方法),**再**在库里打标记 —— 两条独立还原路径(撤销标记 / 按 jsonl 重建); - `memory_backfill` 按「比遗忘更危险」处理:回灌前先归档当前状态,写回的概述带 `origin=backfill-v1` 标记,**绝不夺走用户态**(importance / hit_count 原样保留); - 修复**工具注册不生效**:原先只注册在全局层,而 Web 端按 agent preset 用 `restrict({allow})` 组工具 ⇒ 全局工具被静默过滤(连更早的 `rule_add` 也从未出现)。现在每个 agent 创建时在**它自己的 scope** 补注册一份。 ### 三、时间锚:从「每分钟嚷嚷」到「按需查」 > 「这就像一块手表,它该在人需要的时候被看一眼,而不是总对着人嚷嚷现在几点。」—— 主人 - **`[现在] … HH:MM` 从 system 前缀移出**:system 只留 `[今天] 2026-09-23 周三`(**跨天才变**),`[现在] 时段 · 距上次对话:档位` 改挂 `systemPrompt.context()`(会话中、末条消息,每轮重算); - 实测收益:**system 侧时间锚逐轮变化 1440 次/天 → 0 次/天**,不再每请求作废整段前缀(连同 13.8k 的工具 schema); - `formatGap` 保留原精度,新增 **`formatGapCoarse`**(按**日历天**分档:`刚刚 / 今天早些时候 / 昨天 / N 天前`)—— 纯小时档会把「隔 36 小时」错报成「昨天」; - 顺带修掉一个真病灶:原先时间锚可能来自**更早某次注入的快照**(实测:深夜报「凌晨 1:10」而实际 `00:47`);现在是每轮重算; - 新增 **`clock` skill**(嵌入式,随插件注册):要精确到分钟的时间时主动查,平时不打扰。 ### 四、复盘界面与交互 - **树多开 + `Shift+C`**:侧栏枝展开由「单开」改「多开」(各节点独立),`Shift+C` 一键折叠全部(输入框内不触发;只清展开态、保留成员缓存); - **主脉认得出来**:侧栏列表行首 `🌱 主干 / ◆ 主脉 / · 枝`; - **连边不再铺满**:有连边的节点显示 `⇄N` 徽章,选中该行才展开「连到哪里」; - **记忆中心选中与侧栏对齐**:直接点击 = 只选这一条(`Ctrl+单击` = 加选/减选); - **工具条左对齐**(就地覆盖,不动全局 `.btnrow` —— 它被另外 15 处按钮行复用); - 主脉提炼结果紧跟「✦ 提炼主脉」入口;「最近改动」移到「树的保存 / 恢复」之下; - **保存快照不再弹「有 N 笔改动还没跟器灵说过」**:保存不改树 ⇒ 留痕照写但**不计债**(`action='snapshot'` 排除);恢复 / 改归属 / 改名等真改动照旧计入。 ### 五、修复 - **前端黑屏(React #310)**:新增的 `Shift+C` effect 落在 `LingReviewBody` 的 early return **之后** ⇒ 首次渲染 26 个 hook、数据到达后 27 个 ⇒ React 卸载整块面板。hooks 已上移到所有 early return 之前,并在源码内写死位置纪律; - **主脉提炼「模型调用失败」**:换通路时只改了取值、没改判据(`llmOnce` 返回 `{text}`,没有 `ok` 字段)⇒ 每次提炼都走失败分支(而模型其实答了)。判据已随返回形态整体走,并加**三档重试**(`off/1200` → `low/2400` → `off/2400` + 裁短材料),失败回执带 `attempts` 便于诊断; - **采纳后记忆「不见了」**:`resolveVeinSuggestion` 只写概述、没写归属 ⇒ 解析链落到**主干**;`MEM_SOURCES` 与客户端来源下拉也缺 `vein`(那条记忆当时**无法置顶/删除**、也没有筛选入口)。三处一次改齐,存量已回填; - **`tree_read` / `branch_edit` 八条缺陷**(第三方实测报告 7 条 + 复核发现的第 8 条):`lossless()` 兜底(返回值带 `undefined` 会被平台的无损 JSON 校验判成整次失败,而**写已提交** —— 调用方重发即重复写)· 成员计数取值 · `no-match` 与空枝分离 · `limit` 生效 · 行尾短 id · `links` 回传 · `kind` 按 action 分流 · **create 回执丢失新枝 id**(「建枝 → 立刻改它」链路断裂)。 ### 六、E4 · 可取证访问日志(`lib/audit/`) 起因:2026-09-24 19:49:51 进程被 `dsh-setting-restart` 调度重启,8000+ 文件的全机扫描也答不出「是谁调用的」—— 因为**整个系统没有一行访问日志**。 - 新增 `lib/audit/access-log.js`:JSONL 落盘 `$DSH_HOME/logs/access-YYYY-MM-DD.jsonl`(按天切 · 总量上限删最旧 · 当天永不删),让「谁 · 何时 · 从哪台设备 · 调用了哪个端点 · 结果如何」事后可回答; - 字段:`at`(本地时间+毫秒+偏移)/`method`/`path`/`status`/`rejection`(`untrusted-host` · `no-cookie` · `bad-cookie` —— 本版已扩至 9 类,见 1.5.1 节)/`ip`/`fwd`/`host`/`ua`/`deviceCookie`/`deviceId`/`bytes`/`ms`;高危端点(`settingRestart/restart`、`/dsh-ling/*`)额外打 `risk`,`/api/remote.mux` 升级请求打 `phase:"upgrade"`; - **只记「发生了什么」,不记「内容是什么」**:请求体 / 响应体 / cookie 值一律不落,查询串一律丢弃; - **覆盖边界**:只记 `/api` 与 `/api/remote.mux` 的 upgrade —— 静态资源与非 `/api` 路径不记; - `tools/apply-access-log-patch.mjs`:给宿主 core 打两行**可选调用**(`/api` 唯一入口 + `/api/remote.mux` upgrade),支持 `--check` / `--revert` / 幂等重放;插件缺席或补丁未打时那两行是 no-op,**宿主照常启动**; - 同步落盘(`appendFileSync`):异步缓冲会丢掉「调度重启后进程退出」前的最后一行,而那一行恰恰最重要; - `tests/access-log.test.mjs`(已挂进 `npm test`):四态可辨、字段形状、查询串丢弃、超限裁剪、坏目录静默降级、写入后元数据当场可见。 - `deviceId` 取值的**权威形态已核实**(远端 UI 的 `pairing.ts` 用 `randomToken()` 生成 32 位十六进制明文写进配对 cookie),单测用**合成值**钉住该形态 —— 不拿真机 deviceId 当测试夹具(那是凭据)。 - **日志会自我标注良性噪声**:命中自家**部署姿态自检**签名的行多打 `probe:"posture"`(`POST /api/session.list` + 本机回环来源 + 无 UA + 被 403 拦下,五要素齐全才标注)。该探针由远端 UI 插件每次启动后自带、`403` 正是它的健康结果 —— 标注**不代表放行**,该行 `status` 仍是 403(判据刻意收窄:五要素同时命中才标注,放宽任何一条都会把真攻击错标成"自检")。 - **日志标注调用通道**:没有 `user-agent` 头的行多带 `channel:"no-ua"`。它只陈述"**非浏览器直连**"这一事实 —— 这一类**混装两种调用者**(进程内客户端 / 经远端通道代理进来的设备,代理按设计不转发 UA),故**不指认是哪一台**;按端点语义判读的口径见 `lib/audit/access-log.js` 的字段注释(`CHANNEL_NO_UA`)。 - **观测边界(2026-09-25 真实流量见证)**:远端桌面通道的内侧代理按设计只重建 `host` / `content-type` / `content-length` / `accept` 四个头 + 它自己的鉴权 cookie,**不转发 UA、丢弃 Origin/cookie/Sec-Fetch** ⇒ 凡经代理进来的远端设备(手机/平板/物理机)在内侧日志里一律是 `ip`(本机回环地址) + `ua:null` + `channel:"no-ua"` + 授权通过:**能看出"有远端在用",看不出"是哪一台"**。要按设备归因,需在代理侧透传一个**由代理自己写入**的标识头(这要代理侧配合,日志侧自己补不出来)。 > 注意:core 那两行补丁位于 `node_modules` 内,**DSH 升级后需重跑补丁脚本**;补丁缺失时插件启动会 `console.warn`。 ## 1.4.1 **审计修复轮 + 复盘守门:改动必须让器灵看到** ### 一、排查出的中危项(27 条) 1.4.0 发布前修掉了 8 条高危,随后对全量代码做了一轮全面检查(数据层 / 端点与注入面 / 前端,另加一路迁移安全验证),中危及以上本轮全部关闭: **数据层** - `branchMembers` 的 `limit` 改为**两路查询共用总预算**(原先各吃一份,展开主干时条数是配额的 2 倍); - 新增 `branchMemberCounts()`,与展开**共用同一份谓词** —— 界面上的「N 个会话」不再和展开条数对不上; - `resolveConflict` / `reparentBranch` / `setConvBranch` 补**存在性检查**,对不存在的枝不再假成功; - `tree_snapshot` 新增 `kind('manual'|'auto')` 列,软上限裁剪**只裁非 auto 的一条**,并拒绝删除最后一份自动快照; - `weight_scale` / `visibility` 明确标注为**预留字段、当前不参与打分**。 **端点层** - 写 0 行时返回 `{ok:false, reason:'no-match'}` 且**不写假留痕**; - `judge` / `name` 加**模块级单实例锁与整批 deadline**(120s / 300s),重入直接回 `busy`; - `apply` 校验 `parentId`(跳过并计数)与 `bucketId`(去重); - `assign` 两侧都做存在性检查(会话侧走 `sessionBranchMap()`,因为 `sessionMeta()` 对未知会话会返回伪造的默认对象)。 **前端** - 拖动源改用 ref 在 `onDragStart` 统一记录 —— 修好「撤回 0 项」; - 拖到会话行解析为**其所属枝**;失败不再假报成功; - 钩子清理链式追加(`__lingCleanup`);成员加载失败可重试;只对成功项记账; - 删除约 140 行不可达死代码与两套并存的选中真值。 ### 二、注入感知水位改为按会话 原先水位是**全局键**,后果有二:会话 A 消费过的改动、会话 B 再也看不到;且 `invalidateSession` / `refreshIdleSnapshot` 这类「只重建、没开口」的路径也会消费水位。 改为**渲染与投递分离**: - `snapshot.js` 只读 `branch_log_wm.`、**不再写入**,并把本段渲染到的 `logMaxId` 带出; - `inject.js` 新增 `advanceLogWatermark()`,**只在文本真正要进模型时**推进(四条投递路径),单调不回退。 语义取舍:**宁可重复,不可丢失** —— 只重建的快照若已含改动段,下次重建可能重复呈现,但绝不静默吞掉主人的操作。 ### 三、复盘守门:改动必须让器灵看到 > 「互相复盘不应该被我绕过,动记忆树不能是随意的,应该是慎重严肃的。」—— 主人 2026-09-22 - 侧栏显示**未同步笔数**(金色警示块):自器灵**上次开口**以来累积了多少笔结构改动; - 未同步时**硬拦「退出复盘模式」**,提示「在左边说一句话让它看到」; - 判据链:`branch_log_wm.last`(投递时写入)→ `GET /branch-log/pending` → 侧栏; - **升级后首次运行不再误报**:该键不存在时以当前最大留痕 id 作起点,机制上线前的历史改动不计入「未交代」。 ### 四、留痕显示修正 `nm()` 原先对**一切查不到名字的 id** 都回退成「(已删枝 …)」,而 `branch_log` 的 `before`/`after` 还会装**快照号**(`#9`)与**记忆条目 id**(`026ebd86`)。现按 id 形态分流(真枝名 / `快照 #N` / 已删枝 / 条目),并补齐 `snapshot` / `restore` / `assign` / `delete` 四个 action 的中文措辞。 ### 五、测试 仍为 **29 套**;本轮新增 10 条断言,其中 `kvWrites.length === 0` 是「渲染期不写任何 kv」的判决性断言。 ## 1.4.0 **记忆树:平铺的记忆库长出了结构 —— 分枝 · 归并 · 血缘 · 复盘** ### 一、为什么要有树 旧版记忆库是一张**平铺的表**(以 `(source, conv_id)` 为主键),后果有三: - 召回**没有"离我多近"的概念** —— L1 是全库裸排序,分不出"这轮做的工作"与"三个月前的闲聊"; - **分叉会话是记忆真空** —— 平台 fork 出来的会话既不写入、也不注入(两道闸都按 `parentSession` 直接丢弃); - 无法表达**"这几条属于同一段工作"** —— 没有跨会话的工作线概念,只能靠 `source` 粗分。 1.4.0 引入**记忆树**。关键认知:**树的节点不是知识分类,是"工作线"** —— 同一段对话既有知识属性(讨论了什么)也有经历属性(什么时候、和谁、为了什么),树按**经历**组织,知识通过主题名与连边表达。 ### 二、数据模型(schema v5 → v9) | 表 / 列 | 作用 | |---|---| | `branch` | 枝 / 主脉 / 主干(`id` · `name` · `kind` · `parent_id` · `fork_at` · `weight_scale` · `visibility` · `status`) | | `session_meta.branch_id` | DSH 会话 → 枝 | | `conv_branch` | **非会话源的枝归属覆盖层**(历史网页端 / 导入条目) | | `vein_link` | 枝与枝之间的横向连边(带类型与理由) | | `memory_conflict` | 矛盾标记(不改写内容,只记关系) | | `branch_log` | 结构操作**留痕**(复盘可审计:改了什么、为什么) | | `tree_snapshot` | 树的保存 / 恢复 | **归属解析**两条链并存:DSH 会话走 `session_meta`,历史条目走 `conv_branch` 覆盖层;优先级 `conv_branch` > `session_meta` > 主干。**记忆行不冗余存 branch**,靠 `会话 → 枝` 推导,枝归属变化时无需回写海量记忆行。 ### 三、血缘加权:枝读祖先 检索时按"当前枝 → 目标枝"的**相对位置**取档: | 当前枝 → 目标枝 | 档位 | 权重 | |---|---|---| | 同枝 | `same` | **1.0** | | 目标枝是祖先(含主干) | `ancestor` | **0.7** | | 旁系 / 祖先读后代 | `side` | **0.4** | `score = 基础分 × 血缘系数 × 矛盾系数 × 枝系数` **刻意不连乘**:血缘系数是**单值**,不沿路径累乘 —— 否则 `0.7^n` 会随深度指数失真(五层后 `0.4^5 = 0.010`,枝等于从记忆里消失)。深树的 min-max 路径搜索留作后续。 ### 四、一键生成树:机械分段 + 器灵命名 主干太粗(1593 条)时,用**两阶段**长出结构: 1. **机械分段**(零模型调用):按源分组、组内按时序,相邻间隔超 `gapDays` 或单簇超 `maxSize` 即切 —— 分段是机械活,确定、可复现; 2. **器灵批量命名**:每批若干簇调本机小助手起名,分批推进、可中断; 3. **主人审核**:逐簇可改名字,确认后应用建枝。 名字跟随记忆标题的处置 —— **器灵起 / 主人可改**。名字含「零散」的簇自动归并到同一主脉「零散会话」下(承认它不是工作线,但**保留而非丢弃**)。 ### 五、复盘模式:右栏操作台 **"进入复盘模式"不是一个开关,是"左会话 + 右复盘栏"这个状态。** - **记忆中心《记忆树》始终只读**(看结构、看连边、看矛盾,不做修改); - **右侧栏「器灵 · 复盘」是操作台**:选中节点改名 / 并脉 / 调权重 / 连边;扫疑似重复;模型判定;归并零散枝;快照保存与恢复; - **操作方式**:拖动 = 改归属(move),`Alt` + 拖动 = 只连边(link),`Ctrl` + 点击 = 多选可批量拖,`Ctrl+Z` = 撤回一步; - **拖动有预提示**:悬停目标出现青色虚线框,与琥珀色选中框区分; - **树的保存 / 恢复**:快照只存**结构**(枝 / 归属 / 连边),不存记忆内容;恢复前**自动存一份当前状态**,故"恢复"本身也可撤。 ### 六、注入感知:器灵看得见主人改了什么 人工改树后,`branchLogDigest()` 把**器灵还没看过**的结构改动(水位按自增 id,不重复注入)格式化成中文短句,由 `snapshot.js` 附在注入面末尾: ``` 【主人刚改动了记忆树】 - 把「零散记录」从「主干」下挪到了「零散会话」下 ``` **为什么不做推送**:客户端插件无法往会话注入消息;而复盘对话密集、每说一句就注入一次 ⇒ **感知延迟只有一轮**,体感即实时。器灵不打断主人,只是不漏看。 ### 七、其他修复 - **分叉会话不再是记忆真空**:两道闸由"按 `parentSession` 一律丢弃"改为"**写入所属枝 + 注入血缘链**";seed(继承前缀)由水位 + `isOwnSeq` 双重拦截,父会话历史不会灌进枝; - **归档会话不再被召回**:`selectL1` 与矛盾列表均排除 `archived=1` 的会话(此前仅有界面标记,召回路径**从未过滤**); - **会话级条目标题三级回退**:概述标题 → `dsh_turns_raw` 首条用户消息(压缩为单行、≤40 字)→ 空;**空壳会话不再显示**(既无原文也无概述的会话在数据层过滤掉,而不是在前端显示短 id); - **`{{…}}` 注入面中和**(1.3.1 起)对齐到新的注入段:所有进系统提示的文本统一走 `neutralizeMustache()`。 ### 八、测试 新增 3 套:`tests/branch.test.mjs`(分枝与血缘)、`tests/tree.test.mjs`(主脉 / 并脉防环 / 连边 / 矛盾标记 / 覆盖层)、`tests/autotree.test.mjs`(分段边界 / 命名解析 / 覆盖层只读性),测试套件 26 → **29** 套,全绿。 ### 九、发布前的全面检查 1.4.0 发布前做了一轮全面检查(数据层 / 端点与注入面 / 前端,另加迁移安全实测),发现并在发布前修掉: - **写端点缺少方法校验** —— 宿主路由契约里没有 method 字段,插件守卫也不读它,于是一个 `` 就能触发写操作。已集中声明写端点白名单,非 POST 一律回 `405 method-not-allowed`(列在鉴权之前); - **`restoreTree` 会静默删枝** —— 恢复一份快照会删掉"快照之后新建的枝",而会话仍指向它们,留下**看不见、数得到、权重还满格**的悬空引用。已改为**只把多出来的枝 reparent 到主干**,并在返回值里回报 `detachedToTrunk` 计数; - **注入面泄漏内部 id 与换行** —— `branchLogDigest` 的两个分支漏做名字映射(会把 `vein:` 原样送进系统提示),且未压平换行(可伪造独立行)。已补齐映射、压平并限长,写入侧(`logBranch`)同步净化; - **前端四处字段错读** —— 把数组当计数(渲染成 `[object Object]`)、不校验 `ok` 就宣告成功等; - **补上删枝能力** —— 非空枝默认拒绝并回报影响面(`children` / `sessions` / `convs` / `links`),`force` 时归属回退主干,不留悬空。 **迁移安全性实测**:取 9 月 14 日的老库(7 张表)用当前版本打开,直接升到 v9 —— 四类数据(概述 / 原文 / 会话 / KV)**零丢失**,六张新表与三个新列全部就位,`trunk` 自动建好。 ## 1.3.1 **注入面加固:记忆里的 `{{…}}` 不再能让整个会话失效** ### 一、这颗雷是怎么来的 系统提示模板用 `{{name}}` 占位符做变量插值。宿主的插值实现遇到**未注册的变量名**会直接抛异常,而这一步在 agent 的**每一步**里执行、**外层没有兜底** —— 于是一段文本里只要出现 `{{time}}` 这样的字样,该会话的每一次模型调用都会失败。 而记忆库里装的是**真实对话原文**:导入的历史、网页端抓取的问答,里面完全可能出现 `{{pool}}`、`{{rejected}}`、`{{time}}` 这类写法(模板片段、代码、文档正文)。旧版本把这些文本原样送进系统提示,等于**把"数据"当成"模板"展开** —— 一次概括器搬运、一次深摘要,就足以让一段记忆把某个会话彻底锁死。 ### 二、修复:出口中和(两处) 新增 `neutralizeMustache()`:把相邻的两个 `{` 拆开(插入零宽空格),文本对人和模型**保持原样可读**,且函数幂等。 ```js text.replace(/\{(?=\{)/g, '{\u200b') ``` - 用 **lookahead** 而非 `.replace(/\{\{/g, …)` —— 后者在 `{{{` 上会漏(替换掉前两个 `{` 后,余下的字符又与原第三个 `{` 相邻); - 中和位点两处、覆盖全部注入文本:① **快照成文侧**(`buildSnapshotText` 的 `parts.join`),② **注入最终侧**(`withAnchor`,正文与时间锚各自中和);源码扫描确认返回注入文本的 5 处路径**全部经 `withAnchor`、无旁路**; - 无 `{{` 时直接返回原值,零开销。 ### 三、验证:用宿主真实的渲染函数做对照 | 组 | 载荷 | 结果 | |---|---|---| | **A 未中和** | 未注册名 / 三连占位 / 畸形 `{{}}` / 真实对话里的 `{{pool}}` | **4/6 抛异常** | | | 已注册名(`{{model}}`)、无闭合的裸 `{{` | 通过 —— 前者被**静默替换**成变量值、后者按字面保留,两者仍属有害 | | **B 同批中和后** | 6/6 | 全部通过,且逐字可还原 | | **C 真实链路** | 快照 → 宿主渲染 | 通过,快照内无紧邻 `{{` | | **D 对照** | 把 C 的零宽字符还原后**再送渲染** | **立刻抛** `unknown prompt variable "{{time}}"` | D 组是关键:只证明"中和后不炸"无法排除"这份载荷本来就不致命"。 发布前另做一次**受控爆破**:把一条真实记忆的标题临时改成 `{{time}}{{date}} 排雷验证`(该记录 `hit_count 57`,必然入选注入面)→ 开新会话说话 → **正常回复**;进程外复现同一会话的快照,长度与 live **完全一致(1858 字符)**、零宽字符 2 个、未中和 `{{` **0 个**;验证后靶记录逐字段还原。 ### 四、测试 新增 `tests/mustache.test.mjs`(中和本体 / 两个出口走真实调用 / 源码护栏,含"未中和确实命中宿主正则"的反证),测试套件 25 → **26** 套,全绿。 ## 1.3.0 **记忆标题系统(D2)· 记忆中心可见性(D8)· 导入诚实化(S5)· 小助手地址可配置(S7)· 概述器不再硬截首句** ### 一、标题是记忆的"名字"(D2) 旧行为把首条用户消息**硬截 46 字**,于是 60 条 dsh 概述里 **26 条是半句话**: - 「鱼姬,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」 - 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」 三层改造: 1. **启发式**(零成本、离线可用):剥掉「鱼姬,」「晚上好小鱼,」这类称呼/问候前缀;上限 46 → 58 并在**句读处收尾**而不是硬切;首句是「你好」「下午好」「已测试聊天」这类无信息量句时改取后续有实义的首句;结果若含路径/URL/系统文本则一律弃用(宁可不动,也不产出更糟的名字); 2. **小助手起名**:`POST /memories/retitle` 用本机小助手读会话原文/摘要生成一句真标题 —— 实测「鱼姬,接下来在这里继续开发生长型人格与记忆树模块…」→「**修复审计漏洞并优化记忆库关联查询机制**」; 3. **名字一旦定过就不可覆盖**:`conv_overview` 新增 `title_locked`(schema v3)与 `title_by`(v4)。`POST /memories/rename`(主人手改)与 `POST /memories/retitle`(机器起名)**都上锁** —— 概述器每轮重建、批量重命名都不再覆盖它;要交还机器可解锁。记忆中心卡片新增「改名」「AI 起名」按钮与标记:**三条定名路径(主人手改 / 机器自动 / 来历未记录)统一显示「🔒 已定名」**,定名者只藏在悬浮说明里(「你起的名字…」/「自动起的名字…」)—— 不制造等级感;也不用「暂定」二字(人对一段记忆的概括本就是暂定的、会随阅历改变,但界面要让人确认"它已经落了笔")。用词不写死"小助手"(起名可能走本机小助手,也可能回落当前全局大模型)。悬浮说明点明:**这把锁锁住的是"自动重写",不是主人:手动随时可以再改**。 4. **引擎就近选择(auto)**:`POST /memories/retitle` 默认 `engine:'auto'` —— 优先本机小助手(零成本),探测不到才落到**当前全局大模型**,并把实际用的引擎与原因写回响应(`engine` / `note`)。不静默换引擎:用了哪个看得见。 **实测教训(当轮发现并修正)**:首版把"机器起的名字"设为**不上锁**,结果重启前跑在旧代码里的概述器一轮增量重建,把 AI 标题**覆盖回"首句硬截 46 字"的半句**(库里 title 长度上限实锤 = **46**,正是旧上限)。锁的语义因此定为「**这个名字已经定过,机器不得自动重写**」,而非"只有主人能改"。 ### 二、记忆中心看得见(D8) - **「热度」排序此前是假排序**:`ORDER BY heat DESC`,而 `heat` 列恒为 0(L1 在运行时才算热度、从不落库)⇒ 点了等于没排。改用真实落库的 `hit_count DESC, importance DESC, updated_at DESC`; - **筛选栏带计数**:新增 `GET /memories/sources`,显示为「历史网页端 (1523)」「DSH 会话 (60)」「文件导入 (16)」—— 否则 95% 的库在默认视图里沉底,主人无从知道库里有什么; - 顺手修:`/memories/pin` 与 `/memories/delete` 原写死 `source === 'dsh' ? 'dsh' : 'dsweb'`,会把 `import` 源的置顶/删除误打到 dsweb 上(已改为来源白名单)。 ### 三、导入诚实化(S5) **旧症状:真正的 ChatGPT 导出,界面报「✓ 导入完成」,而对话正文一条都没进来。** - **ChatGPT 原生 mapping 现在真能解析**:支持 `author.role`(`user`/`assistant`)+ `content.parts[]`,**且不再假设根节点叫 `root`** —— ChatGPT 导出的根是 `parent: null` 的随机 uuid 节点,旧实现 `walk('root')` 因此解析出 **0 轮**; - **主链回溯**:有 `current_node` 时沿 `parent` 回溯取主对话链,避免把"重新生成"的多个分支重复入档; - **秒级 epoch 归一**:ChatGPT 的 `create_time` 是**秒**(浮点)、DeepSeek 的是毫秒 —— 旧实现一律当毫秒,于是 ChatGPT 导出的时间全落到 **1970**。现按数量级判定(>1e11 毫秒 / >1e8 秒); - **字段别名补齐**:`create_time` / `update_time` / `conversation_id`; - **`degraded` 覆盖 `mapping` 分支**:旧判定只看 `messages` 数组,所以"mapping 树解析出 0 轮"被当成**成功**。现在只要"声称有内容却解析不出任何轮次"就标记 degraded,**并带原因**; - **界面如实说明**:有壳无肉时不再显示「✓ 导入完成」,改为「⚠ 导入不完全:… 其中 N 条只有标题、没有对话正文」并给出原因 —— 旧文案把"解析不出对话"说成「降级为轻量」,让人以为一切正常; - 导入标题也接入 D2 的启发式(剥称呼前缀、句读处收尾),不再硬截 46 字。 ### 四、小助手地址可配置(S7) 小助手(本机小模型)的地址此前**硬编码在代码里**(`ASSISTANT_DEFAULT`),发布版还得靠发布脚本做字符串替换才不带私有地址 —— 换台机器、换个端口就得改代码。 现在**三级覆盖**,逐字段就近取值: 1. **界面配置**(最高优先):记忆中心 → 接入历史 → 「小助手地址」填地址与模型名 → 「保存地址」;旁边「测试连通」当场探活。写进 `settings.json` 的 `assistant` 段(`POST /assistant/config` 只接受 `baseUrl` / `model` 两个键,不开放任意设置写入); 2. **环境变量**:`DSH_LING_ASSISTANT` / `DSH_LING_ASSISTANT_MODEL`(部署级); 3. **内置默认**:`http://127.0.0.1:11434/v1` + `qwen3.5:9b` —— loopback,**公开仓库里不再有任何私有网段地址**。 - `GET /assistant/config` 如实回报**来源**(界面配置 / 环境变量 / 内置默认),界面照实显示"这个地址是哪来的"; - 生效面:`/memories/retitle`、`/dsweb/summary/preview`、`/dsweb/summary/run` 全部改走三级解析; - 发布脚本 `publish-sync.mjs` 的替换表**清空**(比对/复制两侧的机制保留,将来若再有"源与发布副本故意不同"的合法需求只需加一条)。 ### 五、测试 新增 `tests/retitle.test.mjs`(启发式 / 模型输出清洗 / 上锁后概述器不覆盖 / 排序 / 来源计数)与 `tests/assistant.test.mjs`(三级覆盖 / 默认值中性化护栏 / 探测失败路径),测试套件 23 → **25** 套,全绿;`importfile` 套新增 6 组 S5 断言(ChatGPT 形态 / 主链回溯 / 秒级时间 / degraded 判定与原因 / 汇总)。 ## 1.2.2 **守卫加固 ③④ · 局域网不再自动信任 · 人格补丁字段白名单** ### 一、来源栅栏收紧(加固 ③) 1.2.1 的来源栅栏把**本机所有 LAN 地址无条件并入受信集合** —— 于是"同网段任何设备 + 按同一算法算出的 cookie 名"即可通过,而当时那把锁只校验 cookie **名字**、不校验值,等于把档案摆在了网段里。 - **本机 LAN 地址不再自动信任**:受信来源只剩 loopback(`127.0.0.0/8` / `::1` / `localhost`)与你在 `guard.trustedHosts` 里**显式声明**的 authority; - 需要局域网直连时,打开 `guard.allowLan = true`(默认 `false`),或把该地址写进 `guard.trustedHosts`(支持 `1.2.3.4` 与 `1.2.3.4:3080` 两种粒度); - **远端(隧道 / 反向代理)行为不变**:通道把请求改写为 loopback 形态后再发往本机,走的仍是恒受信的 loopback 分支; - 新增 4 组断言 + 1 条源码护栏(旧写法 `[...lanAddresses(), ...trustedHosts]` 不得复活)。 ### 二、人格补丁字段白名单(加固 ④) `POST /persona` 原先把请求体**原样**深合并进 settings,于是 `{"guard":{"enforce":false}}` 足以持久化关停守卫(且被判定为"非人格补丁",连定型门都不会触发);`POST /import` 导入的外来档案同理。 - 补丁只接受 `persona` / `styles` 两棵子树及其白名单字段(17 + 2 个键),其余键**丢弃并如实回报** `ignored: [...]` 且写一行日志 —— 不是静默吞掉; - 两条外部输入直通 settings 的路径(`/persona` 与 `/import`)全部收口; - 防"白名单误杀"的断言以**合法字段是否仍能走到定型门**为准:若被误杀,补丁会变空、定型门不触发,反而返回成功 —— 这是本条最容易反向失败的地方。 ### 三、小助手默认地址 "补摘要"默认调用本机小助手(Ollama 风格的 `/v1` 接口)。 - 默认地址改为 `http://127.0.0.1:11434/v1`(Ollama 默认端口)—— 谁都能在本机跑一个小模型直接接上; - **没有本机小助手也照样能用**:补摘要仍可在界面上选择主模型来完成; - 把地址做成运行时配置项(免改源码)排入下一版。 ### 四、测试 23 套 ## 1.2.1 **接口守卫加固 · 习惯候选更克制** ### 一、接口守卫加固(① 来源校验 + ② cookie 名字对撞) 原本 `/api/dsh-ling/*` 只判断"cookie 名字形如 `dsh-auth-…`",等于门框上画了把锁。现在接上 DSH 自己的锁: - **来源栅栏**:Host 必须是 loopback / 本机 LAN 地址 / 你显式声明的 `guard.trustedHosts`;`Sec-Fetch-Site: cross-site` 直接拒;带 `Origin` 时其 host 必须等于请求 Host; - **名字对撞**:期望 cookie 名 = `dsh-auth-` + base64url(sha256(规范化后的 Host))—— 与 DSH 派生同法,故**本地与远端登录都无行为变化**,被拒的是"随手编 `dsh-auth-x=` 的脚本"与"恶意页面的跨站请求"; - 拒绝返回 **403 + reason**(`untrusted-host` / `cross-site` / `origin-mismatch` / `no-cookie` / `empty-cookie` …)并写一行日志,便于排查; - **逃生开关**:`settings.guard.enforce = false` 或环境变量 `DSH_LING_GUARD=off`(误判时不会把人锁在门外); - 新模块 `lib/host/guard.js`(纯函数,可单测)+ 第 23 套测试(23 组断言,含 5 条源码护栏)。 > 未做:校验 cookie 值的签名(需读 DSH 凭据库、跨版本易碎)。因此**本机原生进程仍可调用**接口,挡住的浏览器侧伪造与跨站。 ### 二、习惯待确认队列上限 5 - 待确认已有 5 条时,**直接驳回**新的生成请求(`reason: 'pending-full'`),并在面板与工具里说明原因 —— 不是静默丢弃,也不是无限排队; - 面板标题显示 `(N/5 条待确认)`,满时追加一行提示; - `settings.habits.pendingMax` 可调;第 17 套新增 7 组断言(含"被驳回的提议没有偷偷入队")。 ### 三、习惯候选阈值收紧 - 同类纠正的认定由「≥3 次且跨 ≥2 会话」收紧为「**≥4 次且跨 ≥3 会话**」; - 语义仍在两侧钉住:旧阈值下样本仍判定为模式、新默认下"3 次/2 会话"不再算模式,而"4 次/3 会话"照常命中。 ### 四、测试 23 套 ## 1.2.0 **记忆质量:网页端补摘要 · L1 摘要加权与命中记录 · L1 行压缩 · 归档语义收紧** ### 一、归档语义收紧(不再自动删数据) - **归档即停更**:会话归档后不再刷新它的开场记忆条目,**原文一律保留**;该会话一旦有新对话即自动**复活**并重建条目; - **取消自动清理** —— 归档不再删除任何概述;清理只保留人工入口:`node tools/clean-archived.mjs`(默认只列清单,`--write` 才动手,动手前自动 `VACUUM INTO` 备份),或在 DSH「设置 → 归档会话」里直接删; - 新增单测第 21 套(归档清理 · 人工)。 ### 二、L1 质量:摘要加权 + 命中记录 - 新增 `memory.summaryBonus`(默认 `0.25`):**只有真摘要**的行加分,`标题 — N 条消息` 这类自动计数摘要不算; - **L1 命中记录**:首次定稿快照时记录命中,`hit_count` 从此有真实含义(此前恒为 0,热度公式里的"频次"腿长期失效);重建/失效/空闲刷新都不重复计数。 ### 三、网页端历史补摘要(新) - 新模块 `lib/host/dsweb-summary.js` + CLI `tools/backfill-dsweb-summary.mjs`(默认干跑;`--list` / `--write` / `--only-hit` / `--min-turns` / `--all`); - 界面:记忆中心 →「概览与数据」新增卡片 —— **候选预览** / **双引擎选择**(本机小助手零成本 · 当前全局大模型)/ 进度轮询 / 重开面板自动续看; - **绝不静默切换引擎**:本机小助手探测失败时明确报错,由你显式改选,不替你花钱; - 首轮实测:候选 469 条 → **466 条全部成功、零成本、20.4 分钟**; - 端点修复:本机小助手需走 Ollama 原生 `/api/chat` + `think:false`(OpenAI 兼容端点关不掉思考,会把 token 预算吃光导致正文为空); - 新增单测第 22 套(候选排序 / 正文组装与预算 / 清洗 / 探测失败路径 / 重试)。 ### 四、L1 行压缩(两段结构 + 允许缺省) - 有摘要 → `标题 —— 摘要第一句`(第一句按中文标点切;超 **110 字**时硬截并补 `…`); - 无摘要 → 只出标题,**不截**(信息量优先); - `l1MaxLineChars`(180)降级为"病态长标题兜底";新增配置 `memory.l1SummaryChars`(默认 110); - 实测:同一记忆库下 L1 八条 **1218 字 → 1046 字**。 ### 五、界面文案 - 聊天记录库路径缺失时的提示改为**指路式**(可在本卡片填,或先在上方「网页端历史增量扫描」栏填一次,路径会被复用)。 ### 六、测试 - 新增第 21、22 套 → **共 22 套**。 ## 1.1.0 **人格成长层:规矩 / 习惯二分 · 习惯生成器 · 长会话空闲刷新 · 注入面瘦身 · 代词** ### 一、规矩 / 习惯二分(注入面与面板同步) - 原「惯例」一栏拆成两类:**规矩**(你的指令 —— 可直接增删,一句话即写;上限 12 条)与**习惯**(她长出来的 —— 只能"提议 → 你点头",上限 8 条);既有条目全部迁入规矩,零歧义; - 注入面随之分两段:`[规矩](用户的指令,可直接追加)` / `[习惯](你自己长的,新增需用户确认)`; - 每条规矩留痕 `{文本, 原话, 时间, 会话}` —— "经你同意"是可核查的事实,不是她的自称; - **习惯不可直达**:会话内工具、语料建议、点踩建议三条入口全部走"提议 → 确认";删除 / 改写习惯需**手术门**(亲手敲承诺句),界面在未解锁时不显示删除入口。 ### 二、会话内工具 - `rule_add`:把你说的话记为一条规矩 —— **必须带你的原句**(没有原话写不进去); - `habit_propose`:她提出一条习惯候选(入待确认队列);`habit_resolve`:她对你提的习惯表态(接受 / 换说法 / 婉拒); - 工具注册改为**三级兜底 + 重试**(直取 → 子 ctx → 每 1.5s 重试),失败也写留痕(`tools.registered`),不再"静默没有工具"。 ### 三、习惯生成器(从相处里长出来) - **通道 A(零模型)**:扫描历史里的重复纠正,同一模式 **≥3 次且跨 ≥2 个会话**才成为候选,并附证据(出现在几个会话 / 共几次); - **通道 B(一次调用)**:「✨ 让她回想最近的相处」读 **200 条会话概述 + 800 条你的原话**(实测约 3.5 万字),一次最多提 5 条;**分轮轮询** —— 游标持久化,读完一圈回到最新,面板显示「第 K 轮 · 覆盖 X / 共 Y」; - **默认推理档 `off`**:实测 `low` 档只产出思考、正文为空且慢十倍(21s vs 2s);空回复或报错时自动以 `off` + 裁短材料重试一次; - 每次运行留诊断(`habits.reflect.last`):目标模型、材料字数、每次尝试的耗时与返回块类型;目标模型始终是**你当前选定的模型**。 ### 四、长会话空闲刷新 - 长跑会话在**空闲边界**比对记忆版本:有变化且距上次刷新 ≥10 分钟 → 只重建**开场记忆**(不重排人格、不打断运行中);刷新前先跑本会话的增量概述,让"刚结束这一轮"也能进入; - 内容判等(文本没变就不替换、不计数)+ 留痕 `/state.idleRefresh`; - 修掉"重建丢关键词上下文"(关键词加成最高 +1.8,远大于热度项,会让排序悄悄漂移)。 ### 五、注入面瘦身 - 身份句不再重复宿主已声明的平台名、模式块由两行合并为一行、语气标签缩短 → **每个模式 −46 字**; - 开场记忆行:标题 / 摘要去重 + **单行上限 180 字**(可配)→ 同样预算下**从 3 条装到 8 条**,总字数反而减少; - 来源标识修正:DSH 会话 id 一律以 `session-` 开头,原来取前 8 位会让所有行都显示 `session-`(等于无法溯源)→ 改为可区分的短 id。 ### 六、代词(第三人称) - 新增 `persona.pronoun`,默认 **她**;预设 **她 / 他 / TA / 它**,另有「自定义」(≤6 字); - 跟随范围:三条 LLM 提示词(习惯回想 / 语气归纳 / 诞生仪式)、取材标签、面板文案; - **`祂` 刻意不进预设**(中文里专指神祇,放进预设等于替用户宣称神性),自定义可填; - 属契约类:定型后修改需手术门(界面随锁禁用)。 ### 七、时间戳归一 - 写入侧统一存 ISO;读取侧容错解析(`"1789006881011.0"` 这类浮点串按 epoch 毫秒处理); - 新增工具 `tools/repair-timestamps.mjs`(默认干跑,`--write` 落库,写入前自动 `VACUUM INTO` 备份); - 已修复存量脏值:原文层 74 行 + 概述层 15 行 —— 修复前这些行的"新近度"恒为 1.0(永不衰减),长期霸占开场记忆前排。 ### 八、测试 - 共 **20 套**(新增:时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器 / 会话内工具 / 开场记忆行),`npm test` 一条命令跑完。 ## 1.0.3 **行为调整:循环外模型调用跟随当前选择** - **深摘要等循环外调用不再写死模型**:深摘要、诞生仪式、语气归纳、自述起草统一经 `llmOnce` 发起,现在一律使用当前选择(`agentDefaultModel.currentSelection()`)的 `provider` / `model` —— 也就是你此刻选定的那个模型;读不到时才退到兜底值,并在日志里明确标记"本次不是跟随当前选择",不静默替你指定; - 循环外调用保持**低推理档**(`effort: 'low'`):它只做摘要与归纳,不需要高等级推理,与模式的推理档位无关; - 单测补齐(`tests/deep.test.mjs`):捕获真实 `llm.stream` 请求,断言发出的 `provider` / `model` 等于当前选择、不等于兜底值、档位恒为 `low`,以及不可读时 `followed: false`; - 仓库地址整理:README 的 `git clone` 与 `package.json` 的 `repository` / `homepage` / `bugs` 由旧名 `dsh-ling` 更新为 **`DSH-Ling`**(旧地址仍 301 可用)。 ## 1.0.2 **行为调整:模式只同步推理等级,不改模型** - **模式切换不再修改模型**:工作 / 生活模式只同步**推理等级**(工作 = `max`、生活 = `low`),模型一律沿用你当前选定的那个。平台 API(`sessionController.selectModel` 与 `agentDefaultModel.saveSelection`)要求 `provider` / `model` **必填**(只有 `reasoningEffort` 可选),因此实现方式是"**先读当前选择 → 原样带回 → 只替换档位**";**读不到当前模型时放弃同步档位,而不是替你指定模型**; - 界面与文档口径同步:提示语由"平台默认模型"改为"推理等级";`DESIGN.md` 里模式相关的 6 处描述统一为"档位",默认映射只保留 `effort`; - 新增 `.gitattributes`(`* text=auto eol=lf`,图片与压缩包标记二进制),消除 Windows `core.autocrlf` 造成的行尾假差异。 ## 1.0.1 **文档与仓库治理**(无功能变更) - `LICENSE`:Apache-2.0 附录的版权署名由官方占位补为 `Copyright 2026 NightPainters`; - `.gitignore`:补齐永不入库的数据类型 —— `settings.json`(人格档案/承诺句)、`*.dshling.json`(导出的记忆包)、`*.jsonl.zstd`(会话转储)、`.env*`、`logs/`、`*.tmp`; - `DESIGN.md`:修掉空标题,修正已过期的 schema 说明(v1 → 现为 v2),补"实现以代码为准"的口径说明; - 版本号改为**单一来源**:`lib/index.js` 不再硬编码,改为读 `package.json`(`/health` 与 `/state` 返回的就是这里); - 首版发布后的自检补齐:`tools/check-publish.mjs` 在上传前实跑(文件类型/体积/绝对路径/密钥特征)。 ## 1.0.0 **首次公开发布** - **三层记忆**:L0 人格档案(每次对话恒定注入)/ L1 开场记忆(从历史里按相关度挑选的概述,有预算)/ L2 会话内滚动; - **三条接入通道**:网页端聊天记录库增量扫描 · 本机 DSH 会话扫描 · ChatGPT / 通用 JSON·JSONL 导出文件导入;每次导入留一笔账(导入记录); - **深摘要**:长会话逐条深摘,短会话以原文片段参与,后台串行、进度可见; - **诞生仪式**:按"生活与情感优先"加权抽样 → 自述 / 名字 / 语气建议 / 相处观察,**全部草稿,人审后才落盘**; - **人格定型锁**:契约类字段(名字 / 称呼 / 底线 / 世界观)改动需**亲手敲承诺句**(禁粘贴),进变更记录可回滚;自性类字段(语气 / 风格 / 自述 / 惯例)锁定下可自由生长; - **被动时间锚**:每次开口前注入 `[现在] 日期 周X 时间 · 时段` 与距上次对话的间隔,称呼跟随用户设定的 userTitle;命中纪念日追加问候; - **工作 / 生活双模式**:同一份人格档案,两套称呼、语气、规则与记忆权重; - **成长回路**:点踩 → 反馈队列 → 惯例建议 → 采纳入档;「让她读记忆、归纳语气」一键成长; - **全本地**:记忆库为本地 SQLite,插件不上传任何数据; 13 套测试(人格组装 / 冻结门 / 记忆库 / L1 选择 / 导出合并 / 文件导入 / 导入记录 / 深摘要 / 诞生仪式 / 语气 / 时间锚 / 捕获差分 / 契约形状)全绿。