dsh-recall 🧠
给 DeepSeek Harness(DSH)的极简跨会话记忆: 7 个工具、按工作区一个 JSONL 文件、每会话一段可选注入(只在本会话写过规矩时才出现),固定开销约 1,170 tok/请求,另有 0 到 750 tok 按私密本内容另算(估算,见文末成本表)。
同类方案 dsh-meow-memory(MIT)约 4,452 tok/请求, 另有首轮注入与每轮关键词命中。本项目按「只在我真的需要时才付 token」的思路重做了一遍。 为什么这样取舍见 docs/设计取舍.md。
特性
- 7 个工具:
mem_save/mem_find/mem_forget/mem_brief/mem_source/mem_wall/mem_private。 - 跨会话同步:
mem_brief给「距上次同步过了多久 + 那之后别的会话干了什么」,同步游标自动推进;只含写进记忆的事,没写记忆的会话看不到。 - 取原文:
mem_source顺着出处会话回到日志(多帧 zstd 逐帧解压),挑出最相关的几段说出口的话——内心独白不取,注入碎片(<system-reminder>)也不取。 - 会话之间的墙:
mem_wall让一个会话给另一个留话(建议、吐槽、提醒、回应);mem_brief每轮带出最近三条,指名给自己的标「给你」。它是留言板,不是聊天室——一个来回可能要隔几天。 - 私密本:
mem_private给每个会话一个文件夹([TheLifeofFish]/private/<s前8位>/),里头四本:_本子(背着别的会话干的事、说的话)、_额外规则(本会话自己的人设和规矩,和通用_规则冲突时以它为准)、_次要规则(压缩时自动归纳进来的规矩,权限低于前两本)、_压缩存档(压缩摘要里次一档的内容,是资料不是规则)。工具只认自己那个文件夹。 - 压缩时自动归档(0.9.0 起):宿主把一段对话压成摘要时,插件接住那条
compaction/summary,按纯规则打分——命中的写进_次要规则,次一档的写进_压缩存档,其余的丢。不调模型,不花 token。 - 规则会进 prompt(0.9.0 起):
_额外规则和_次要规则每次组装 system prompt 时都会注入,带优先级说明;两层都空就不出这一节。 - 出处指针:写入时自动记下会话 uuid(
〔s:xxxxxxxx〕),mem_find显示时带出。卡片是摘要不是原文,有出处才回溯得了;取不到就不写,不编。 - 不注入检索结果:每轮不会把
mem_find的命中塞进 prompt,因此不碰 KV 缓存、不污染上下文。会进 prompt 的只有你自己写的那两本规矩(见上条),不是它自动挑出来的东西。 - 按工作区自动隔离:scope 取当前会话工作区目录名,不需要模型传项目参数。
- 跨库召回:
mem_find默认同时查当前工作区与global,两个库各算各的 BM25 再合并排序;来自global的结果标〔global〕。 - 写入闸门:正文短于 12 字、或以
- \`` /#` 开头的清单/标题行,直接拒收——那半句不是判断。 - 改口提醒:关键词撞上两个以上、正文又不像、两边都带数字时,提示可能与旧卡冲突;只提醒不自动作废,判断权留在写的人手里。
- BM25 + 时间半衰:中文 bigram + ASCII 词,越新的记忆权重越高(半衰期 60 天)。
- 近乎重复自动合并:只防手抖重写,语义重复交给
mem_find判断。 - 纯文件存储:
~/.dsh/recall/<scope>.jsonl,同名.md是人读版;改文件即改记忆,拷走即迁移。 - 零运行时依赖:不 import 任何核心包,不注册核心服务(不会和计费类插件抢模块)。
安装
方式一:本地 link(推荐,适合自用)
# 1) 放到插件目录
git clone <this-repo> ~/.dsh/plugins/dsh-recall
# 2) 在 profile 里挂载:profile 的 package.json
# dependencies: "dsh-recall": "link:C:/Users/<you>/.dsh/plugins/dsh-recall"
# dsh.profile.bundles: 追加 "dsh-recall"
# pnpm-lock.yaml 的 importers 段补:
# dsh-recall:
# specifier: link:C:/Users/<you>/.dsh/plugins/dsh-recall
# version: link:../../plugins/dsh-recall
# 3) 建 junction(Windows,管理员不需要)
# New-Item -ItemType Junction -Path ~/.dsh/profiles/desktop/node_modules/dsh-recall -Target ~/.dsh/plugins/dsh-recall
# 4) 重启 DSH 桌面端
装好之后每次改
lib/index.js都要重启。recall 是 host 侧插件,HMR 只管客户端插件。
方式二:插件市场
若市场收录,直接搜索 dsh-recall 安装。未收录时用方式一。
装完之后
在全局 ~/.dsh/AGENTS.md 里加一段(约 75 tok),让模型知道该主动查:
## 记忆(dsh-recall)
跨会话记忆在 `~/.dsh/recall/<工作区>.jsonl`(同名 `.md` 是人读版)。相关话题开工前先 `mem_find` 查一下;
定下来的事实/偏好/教训用 `mem_save` 记一行;记错用 `mem_forget`。没有自动注入,也没有后台整理。
工具
| 工具 | 参数 | 说明 |
|---|---|---|
mem_save |
text(必填)、kind(fact/pref/lesson/task/proj)、keywords、goal、scope |
写一条;自动记出处会话;重叠系数 ≥0.9 视为重复并合并 |
mem_find |
query(可空)、k(1–20,默认 5)、kind、scope |
query 留空 = 按时间倒序看最近 |
mem_forget |
id、scope |
删除;id 来自 mem_find |
mem_brief |
full(可选) |
同步其他会话的动静;新会话第一轮调一次,调完自动推进同步点 |
mem_source |
id 或 session、k、q |
顺出处回到原文:解压那次会话日志,挑最相关的几段说出口的话 |
mem_wall |
say、to、k |
会话间留言板:传 say 就是写,不传就是看 |
mem_private |
say |
只给自己的话:传 say 写,不传读自己的 |
scope 缺省 = 当前工作区目录名;写跨工作区的记忆传 global。
关键词是手写的,不是自动提取的
keywords 写同义词、别名、缩写、英文写法(faster-whisper, ASR, 识别, 离线),检索时按
正文 ×1 / 关键词 ×1.8 / 目标句 ×1.4 三个字段分别打分;关键词原样出现在查询里再额外加 0.9。
不写也能用(退回正文 bigram + BM25),但换个说法就搜不到——这是实测得出的取舍:
自动提取的关键词只是正文的子集,加权重没意义,所以宁可让写入时多想一步。
goal 只在 kind: "task" 时有意义,答的是「这件事要达成什么」而不是「做过什么」。
墙:会话之间的留言板
记忆是事实,墙是话。一个会话想给另一个留话——「你那个 bili.py 我看了,建议加个超时」——
用 mem_wall 写上去:
mem_wall(say: "你那个 bili.py 建议加个超时,昨天卡了 20 分钟。", to: "7a595bba")
to 可以是 s 前缀、工作区名,或者不写(所有人)。不传 say 就是看墙。
mem_brief 每轮会顺带列出最近三条,指名给自己的标「给你」。
它是留言板,不是聊天室。 会话是被动的——没有新消息就不会动作,所以对方可能要等下一次
开新会话、调 mem_brief 时才看到你的话。写的时候就当是寄信:写清楚对谁、因为什么。
私密本:每个会话一个文件夹
记忆是公共的,墙是公开的,mem_private 是只给自己的。每个会话一个文件夹 [TheLifeofFish]/private/<s前8位>/,里头四本:
_本子.md——背着别的会话干的事、说的话,不能让它们知道的计划和行动。只由人写,自动归档不碰它。_额外规则.md——本会话自己的设定:人设、性格、只属于这条会话的规矩。**和通用_规则冲突时,以它为准。**一开始可以空着,随经历慢慢写。_次要规则.md——压缩时自动归纳进来的规矩,权限低于前两本,冲突时让位。上限 30 条,超了先删hits低的。_压缩存档.md——压缩摘要里次一档的内容(教训、路径、数字)。是资料不是规则,不注入,要用mem_private(which:"archive")读。
隔离靠工具:mem_private 只认当前会话的 s,读也只读它那个文件夹,别的会话调不到。
文件系统本身不隔离(同一个用户),所以直接翻文件绕得过去——那是违规,不是技术能拦的。
三层规矩与自动归档
四本书的次序就是裁决次序:
| 层 | 文件 | 谁写 | 冲突时 |
|---|---|---|---|
| 一 | _规则 / _额外规则 |
人 | 说了算 |
| 二 | _次要规则.md |
自动 | 让位 |
| 三 | _压缩存档.md |
自动 | 只是资料,不注入 |
| — | _本子.md |
人 | 私密备忘,跟规矩无关 |
怎么归档:监听 session/event 里的 compaction/summary,取宿主已经压好的摘要(不碰原文),切段后逐段打分:
- 高分 →
_次要规则:出现「必须/不许/一律/只能/以后都/原则/约定/规矩/职责」或「不要再/别再用/禁止」;或者出现「定在/定为/拍板/决定/敲定」并且旁边有数字、路径、会话号。 - 次档 →
_压缩存档:出现「教训/坑/踩过/注意/小心」,或含数字/路径/会话号,或超过 200 字的实质段落。 - 低档 → 丢。
规则和阈值都是 lib/index.js 顶部的常量(RULE_PATTERNS / NOTE_PATTERNS / MINOR_MAX / ARCHIVE_MAX_BYTES),带「可调」注释,改了直接生效。
为什么不调模型:确定、零成本、可解释、能改。代价是提炼质量一般——存的是「摘要里的原句 + 规则挑出来的钩子」,不是模型再写一遍。
怎么判「同一条」:钩子重叠要够比例,不是撞两个就算——
overlap >= max(2, ceil(min(词数A, 词数B) × 0.6))
三词对三词要 2,五词对五词要 3,四词对四词要 3,两词对两词要 2。撞上了就把旧条的 hits 加一、正文换成新的、挪到文件末尾(新的在下面,人读着顺手)。
hits 是干什么的:一条规则被后来的摘要再次撞上就加一。淘汰不再按「谁先写谁先死」,而是 hits 升序、同 hits 按日期升序,删第一条——该留的是反复出现的,不是最新的。hits 写在行尾。
[auto] 只标自动的:宿主压缩摘要归进来的带 [auto],mem_private 手写的不带,hits 也从 0 起算(手写的不算「被摘要撞过」,在淘汰排序里天然排在后面)。想写一条不会被挤掉的规矩,写进 _额外规则——那本不参与淘汰。
体积保险丝:_压缩存档 不设条数上限(它是资料),但超过 200KB 时从最老的节开始删,钩子表里对应的行一起删,文件头留一行「已按体积淘汰 N 节 · 最新 日期」,不静默。单条自己就超上限时整条拒写,文件头记「有 N 条超 204800 字节上限未写入 · 合计 字节」——不截断。砍一半的资料比没写更糟:读的人会拿那半条当全文。
正文进围栏,不做转义:_压缩存档 每一节的正文整段包进围栏代码块(围栏长度比正文里最长的连续反引号还长一位,绝不会提前收尾)。标题、管道、各种行终止符在块里全是普通文本,写进去什么读出来就是什么,一个字不改。
解析时按围栏边界跳过块内所有行——这比转义可靠:转义要穷举所有能造结构的写法(## §N、| x | §N |、裸 \r、U+2028、U+2029……漏一个就是漏洞),围栏只要认对边界。另外 JS 正则 m 模式的 ^ 认四种行终止符(\n、\r、\u2028、\u2029),切行也必须四种都认——只按 \n 切会漏掉后三种,旧版就在这里漏过。
旧存档自动迁移:0.9.3 及更早写的节没有围栏。第一次被写入时会自动给正文补上围栏,之后按新格式走。迁移只做一次,但不会替你分辨旧正文里本来就有的 ## §N 和 | x | §N |——那些在旧版里就已经是结构了,升级不会让它变好,也不会变坏。
拒写只动文件头:超限时只改头部那行计数,已有正文一个字节不碰。0.9.3 那版用全文正则压空行、删行,会把别人的正文改坏——拒写是「没写进去」,不是「顺手整理一下别人的文件」。
两个口径别混:ARCHIVE_MAX_BYTES(200KB)管的是整份存档,由体积保险丝执行;单条正文超这个数就直接拒写、不进保险丝——不然一条巨大的能顺着淘汰把已有节全挤掉。
去重:同一条摘要可能被重复投递(replay、重连),compactionId 记在 _已归档.json,只留最近 200 个。取不到 compactionId 时用被压掉的 seq 段拼指纹兜底,不会因为缺一个字段就整条不归档。
注入:_额外规则 和 _次要规则 每次组装 prompt 时现读,注入成一段按层排列的说明,最前面写清「冲突时从上往下」。
_本子 和 _压缩存档 都不注入——它们是资料,要用时 mem_private 去读。每层超 4000 字截断并标「已截断」。
已知取舍
- 中文按 bigram 切,没做真正分词。词边界上会出碎片:「必须」在连续中文里会被切成「口必」「须是」。这些碎片当钩子会让不相干的规矩互相撞,所以
pickHooks用HAN_FUNC虚字表把含虚字的 bigram 整片丢掉,再给数字和英文词 3 倍权重。代价是真主题词偶尔排不进前五,钩子会偏。 [auto]只是视觉标记,不参与任何逻辑。误判时人一眼能挑出来删,但没有「自动的不许覆盖人写的」这类保护——那是下一步的事。hits假设「反复被提到 = 重要」,还没跑够天数验证。头一周如果发现被删的条目里有明显该留的,说明假设不成立,回来改。
出处:卡片是摘要,不是原文
写入时自动带上 s(会话 uuid),mem_find 显示成 〔s:xxxxxxxx〕。
一条没有出处的记忆,半年后读到只能选择相信或不信——而这两条路都不对。
顺着 s 能回到那次会话的日志:~/.dsh/sessions/--<工作区>--/session-<uuid>/session.v4.jsonl.zstd。
取不到会话 id 时 s 留空、不编造;老记忆没有 s,照常可用。
跨会话同步:其他会话在忙什么
每个会话相互独立,写完记忆就散场——谁也不知道隔壁干了什么。mem_brief 补这一块:
- 读所有库,找
t晚于同步游标的条目,排除当前会话自己; - 按(会话 × 库)分组,每个会话最多列两条,给「大概干了什么」;
- 返回距上次同步过了多久,然后把游标推到此刻。
游标是全局一个(_sync.json),因为要同步的是「其他会话的动静」。第一次没有游标时往前看一天。
局限:没写记忆的会话看不见——这里只同步「留下过记忆的事」。
取原文:顺着钩子回去
卡片是摘要,细节在会话日志里。mem_source 就是这个钩子:
- 给
id(记忆 id)→ 顺着它的s去;给session(uuid 或前缀)→ 直接去; - 日志是多帧追加的 zstd(每帧一条事件,一个会话能有两三千帧),Node 的解压器只吃单帧,
所以按 magic
28 B5 2F FD切帧再逐帧解,解不动的帧跳过; - 只取
user/message与assistant/message的正文:reasoning(内心)不要,<system-reminder>(注入碎片)也不要——混进来只会污染片段; - 用 bigram 打分挑最相关的 k 段(默认 3),没命中就给最后几段兜底。
一次解压十几 MB 的会话大概一百多毫秒,只在你真想知道细节时调。
数据
~/.dsh/recall/<scope>.jsonl # 真相(每行 {id,t,k,x,s?,w?,g?})
~/.dsh/recall/<scope>.md # 人读版,随写入同步
~/.dsh/recall/_sync.json # 跨会话同步游标(mem_brief 用;删掉 = 下次重看一天)
~/.dsh/recall/_wall.jsonl # 会话之间的墙
私密本不在这里,按会话分开:
~/.dsh/[TheLifeofFish]/private/<s前8位>/_本子.md # 秘密(只由人写)
~/.dsh/[TheLifeofFish]/private/<s前8位>/_额外规则.md # 人写的规矩
~/.dsh/[TheLifeofFish]/private/<s前8位>/_次要规则.md # 自动归纳的规矩(hits 计数)
~/.dsh/[TheLifeofFish]/private/<s前8位>/_压缩存档.md # 压缩摘要的次档内容(200KB 保险丝)
~/.dsh/[TheLifeofFish]/private/<s前8位>/_已归档.json # 已处理的 compactionId(最近 200 个)
- 手改
.jsonl立即生效(坏行会被跳过)。 - 迁移/备份 = 拷走整个
~/.dsh/recall/和~/.dsh/[TheLifeofFish]/private/。 - 删除某条 =
mem_forget,或直接删掉那一行。 - 删
_次要规则.md= 清空自动归纳的规则(下次压缩会重新长出来)。 - 删
_压缩存档.md= 清空压缩存档(同上,下次压缩重新长)。
卸载
从 profile 的 package.json 删掉 dependencies 与 dsh.profile.bundles 里的两处,删掉
node_modules/dsh-recall 的 junction,重启桌面端。记忆文件保留在 ~/.dsh/recall/,不会自动删。
实测成本
| 项 | meow-memory 0.31 | dsh-recall |
|---|---|---|
| 工具 schema | 2,464 tok(8 个) | 620 tok(3 个,0.3.1 实测) |
| 常驻引导 | 1,988 tok(system prompt) | 106 tok(AGENTS.md) |
| 首轮注入 | 有 | 无 |
| 每消息命中 | 有(top-2) | 无 |
| 后台任务 | 反思 + dream | 无 |
| 合计固定开销 | ≈ 4,452 tok/请求 | ≈ 726 tok/请求(0.3.1 实测) |
| v0.3.1 → v0.4.0 增量 | — | 约 +115 tok(mem_brief schema + AGENTS.md 一句,估算;合计约 840) |
| v0.4.0 → v0.5.0 增量 | — | 约 +120 tok(mem_source schema,估算;合计约 960) |
| v0.5.0 → v0.6.0 增量 | — | 约 +100 tok(mem_wall schema + AGENTS.md 一句,估算;合计约 1060) |
| v0.6.0 → v0.7.0 增量 | — | 约 +100 tok(mem_private schema,估算;合计约 1160) |
| v0.7.0 → v0.8.0 增量 | — | 约 +10 tok(mem_private 多一个 which enum,估算;合计约 1170) |
| v0.8.0 → v0.9.0 增量 | — | 0 到 750 tok,看私密本有没有内容(见下) |
| v0.9.0 → v0.9.5 增量 | — | 约 +40 tok(which 多一个 archive 取值,估算;注入部分不变) |
| v0.9.7 增量 | — | 0(只动压缩归档的写入路径,_压缩存档 不进 prompt) |
0.9.7 改的是写入行为,不是注入成本——六处都落在「摘要落到磁盘上长什么样」这一侧:
| 改动 | 影响的量 |
|---|---|
| 正文上限 240 → 1200 字 | _压缩存档 单节体积最多 5 倍 |
| 段数上限 8 → 字节预算 60000 | 实际写入量:真实数据 16% → 100% |
语种判定(\p{L} + 假名比例 + 阈值 0.2) |
_次要规则 收进来的条目变少(非中文降档) |
| 钩子挑不出时用标题兜底 | 钩子表不再有空单元格 |
| 截点避开 ZWJ/组合字符 | 正文尾部不再有悬挂连接符 |
| 记 id 挪到写完正文之后 | 异常路径下可能重复写一条(不再永久丢) |
磁盘成本(真实数据,125 条摘要):单条写入 5149 ~ 16990 字节,中位 10186。_压缩存档 的保险丝是 200KB,按中位算约 20 条摘要撑满,之后按体积淘汰最老的节,文件头记「已按体积淘汰 N 节」。0.9.6 因为只写 16% 的段,单条约 1.6KB,20 条 → 能装约 125 条;0.9.7 装得少,但装进去的是全的。这笔账是拿容量换完整性,不是白得的。
0.9.0 注入的实测成本(_额外规则 + _次要规则 两层):
| 场景 | 注入字数 | 约 token |
|---|---|---|
| 两层都空(大多数会话) | 0 | 0(不出这一节) |
典型:_额外规则 495 字 + _次要规则 6 条 |
1,189 | 743 |
满配:_额外规则 880 字 + _次要规则 14 条 |
2,286 | 1,429 |
换算系数约 1.6 字/token(中文)。空场景是 0,只有真写了规矩的会话才付这笔钱——这是它跟「无条件注入一大段引导」的区别。0.9.1 的 _压缩存档 不进 prompt,所以在成本上不出现。
验证边界(0.9.7)
0.9.7:压缩归档的三个洞(真数据挖出来的)
0.9.6 是第一次在有真数据的宿主上跑压缩。指挥中心从会话日志里解出摘要一看,三条问题:
| # | 问题 | 原来的样子 |
|---|---|---|
| 一 | 静默截断 | body: span.replace(/\s+/g," ").trim().slice(0, 240)——240 字硬切,不加省略号、不标截断。存档里出现「…must NOT write int」,源文是「write into _本子.md」,断在词中间,没有一个字说明它被截了 |
| 二 | 只取前 8 段 | splitSpans(text).slice(0, 8),后面的静默丢弃,不记录丢了多少 |
| 三 | 英文摘要进规则层 | 不是 bug,是 scoreSpan 的语义边界:英文摘要里引用「必须」「规矩」也会判 high,可那是引用不是规矩本身 |
第二个洞的规模是量出来的,不是估的。 解了本机 112 个会话日志、125 条真 compaction/summary(2026-10-26):
| 指标 | 值 |
|---|---|
| 摘要字数 | 3383 ~ 17632 |
| 切出段数 | 最少 22,中位 52,最多 79 |
| 超过 8 段的 | 125/125(100%) |
| 0.9.6 实际写入 | 1000 / 6309 段 = 16% |
| 0.9.7 实际写入 | 6309 / 6309 段 = 100% |
| 写全的摘要条数 | 0.9.6:0/125 → 0.9.7:125/125 |
也就是旧代码一条摘要都没写全过,中位丢掉 85% 的内容,而文件里一个字都没说它在丢。
改法:
- 正文上限 240 → 1200(
ARCHIVE_BODY_MAX,和注入保险丝MAX_TEXT同值)。超了就标…(原文 N 字),不再静默切半句。 - 段数上限换成字节预算(
ARCHIVE_SPAN_BUDGET = 60000)。按段数限会在长段上爆保险丝、短段上浪费空间;按字节算才和_压缩存档的 200KB 是同一把尺子。预算装不下的段落,单独写一节记账:「本次摘要共 N 段,写入 M 段;其余 K 段未写入(其中 X 段超出字节预算、Y 段无信息量)」。 - 非中文为主的条目降档:
zhMostly()判中文占比,不到就只进_压缩存档,不进_次要规则。降档不是丢,内容一个字不少。判据的最终形态见下面「第十一到第十三个洞」——这一条前后被复验挑了三次,才收敛成「汉字占全部字母的比例 ≥ 0.2,且假名/韩文不超过四分之一」。
顺带修掉的第四个洞:summaryText 用 join("\n") 拼多个 block,splitSpans 按空行切——多 block 时全粘成一段,切不开,整条摘要变成一个巨型段再被正文上限一截。真实数据每条只有 1 个 block,所以一直没走到;改成 join("\n\n")。
第五个洞,自己挖的:zhMostly 只数汉字和拉丁字母,日文假名两者都不是——纯日文摘要里汉字占比能算到 1.0(「セッションの要約です」的「要約」是汉字),于是「非中文」被当成「中文」放进规则层。韩文没有汉字,反而不受影响。改成假名(U+3040–U+30FF)和韩文(U+AC00–U+D7AF)一出现就直接判非中文。
第六个洞,也是自己挖的:记账里那句「写入 N 段」原来用的是 picked.length——那是预算装得下的段数,里面还有被判 low、挑不出钩子而跳过的段落。极端情况下 picked 全被跳过、一段没写,记账却说写入了 13 段。改成按实际写进文件的数量算,未写原因分开写。
「账要按实际算,不能按打算算」——这条比它修的那个 bug 值钱。
第七个洞,同类第三次:splitSpans 的 /[\n]{2,}/ 在 CRLF 上匹配不到——\r\n\r\n 是「\n \r \n」,中间夹了个 \r,不是连续两个 \n;U+2028 更是压根没有 \n。结果是 CRLF 或 U+2028 分段的摘要切不开,整条变成一个巨型段,再被 1200 字上限一截,等于把后面全丢了。切之前先把四种行终止符归一成 \n。
这是同一类错误的第三次:0.9.2 修过「EOL_RE 认四种、split("\n") 只认一种」,0.9.3 修过「追加写入没归一化行终止符」,这次切段这条路又忘了一遍。记住:凡是按行切/按行匹配的地方,四种行终止符(\n \r \r\n \u2028 \u2029)一个都不能漏。
独立复验:24 条判据挖出 9 条坏状态
上面七个洞是我自己挖的。按 AGENTS「改写已有文件的活,交付前必须跑一轮独立复验」叫了一个 subagent,它不看我的报告、自己读代码、自己造输入,24 条判据 PASS 15 / FAIL 9,构造出 6 类坏状态。
这 9 条里,三条是 0.9.7 新引入的(预算算法、截断标记、按码点切),六条是既有洞被新记账逻辑暴露出来的。逐条:
| # | 坏状态 | 根因 | 改法 |
|---|---|---|---|
| 1.3 | 旧档 §1 正文里的 ## §999 被套上围栏升格成真节,新节号跳到 §1000 |
旧格式(无围栏)的正文和真节长得一模一样,scanLines 分不出来 |
旧档的节号必须连续,断号的一律当正文。它随后会被包进围栏,以后不会再被扫到 |
| 2.3 | 首段 100KB 时,后面 20 个本可全装下的小段(合计 2200 字节)全丢,记账还说「超出预算」 | 预算用未截断原文的尺寸算,首段一个人吃掉全部 60000 预算 | 改成按实际写入尺寸(截断到 1200 字后)算。理由是假的比丢数据更糟 |
| 3.2 | 中文真规矩只因引用了句日文(含假名)就被降档 | 假名一票否决 | 见下 |
| 3.3 | 中文真规矩夹英文术语(汉字 23 / 拉丁 72 = 0.242)被降档 | 阈值 0.3 太高 | 见下 |
| 3.4 | 希腊字母、全角字母不进分母,非中文被误判成中文进规则层 | 分母只数「汉字 + 拉丁」 | 见下 |
| 4.1 | 手工 minor 的 240 字上限被截断标记撑到 251 字 | 先按 max 切、再加标记 | 先减掉标记长度再切 |
| 4.2 | 1200 字边界切在 emoji 中间,文件里出现 U+FFFD |
slice 数 UTF-16 code unit,emoji 是代理对 |
Array.from 按码点拆 |
| 4.3 | 「原文 N 字」的 N 是空白压缩后的长度(输入 6402 字,标「原文 1403 字」) | 用了压缩后的长度 | 用原文长度——那个数字是给人判断「丢了什么」用的,报小了等于又瞒一次 |
| 5.2 | 每段都短于 8 字 → 全被过滤 → 直接 return,可 _已归档.json 已经记了 id,同一条再来会被当「归档过了」跳过,永久丢失 |
没有任何记账 | 落一节记账,写明「切不出有信息量的段」并把原文带进去 |
第十一到第十三个洞(语种判定,同一条判据被挑了三次):
- 先是「假名一票否决」→ 中文规矩引用一句日文就被误伤(3.2)。
- 改成「分母用
\p{L}所有字母」→ 希腊字母、全角字母进来了(3.4 修好),但纯日文的汉字占比 0.23 又过了 0.2 的线(日文条目回到规则层)。 - 最终:分母
\p{L}+ 前置判断「假名+韩文超过四分之一就判非中文」 + 阈值 0.2。纯日文 0.48、中文夹一句日文 0.11,这么分才分得开;中文夹英文术语 0.242、英文夹几个汉字 0.05,0.2 分得开。
教训:语种判定不能靠单一比例。「这条是什么文字」和「这条是不是中文夹外文」是两个不同的问题,一个阈值分不开,得两个信号。
真数据回归(125 条,跑的是新逻辑本身):0.9.7 下 125/125 全写全,一条都不需要记账。单条写入 5149 ~ 16990 字节(中位 10186),60000 的预算对真实数据是够的——记账那节只是兜底,防的是更极端的输入。
测了的:tmp-test/fix-097.mjs 25 条(判据 1/2/3/4 逐条对应指挥中心的要求,另加日文降档、记账不撒谎、CRLF 切段)+ tmp-test/fix-097b.mjs 28 条(逐条复现两轮复验构造出的 12 条坏状态 + 按新语义重钉 2.2/3.5)+ 旧的 37 条(criteria-091 10 / fix-092 11 / fix-093 16),合计 90 条全绿。
没测的:
- 短于 8 字的段按噪声丢弃(
splitSpans的filter),这部分不进记账——它是切分规则不是预算问题,但严格说也算静默丢,只是丢的是「好的。」「确认。」这类没有信息量的碎片。整条全部短于 8 字时会落一节记账(不会静默消失),部分短段被过滤则不留痕。 - 预算触发记账那条只在构造输入上验过,真实数据(125 条)没触发过——所以「记账节写得对不对」在真机上仍属未验。
zhMostly的 0.2 阈值是推的不是调的:拿构造输入两头夹出来的线(中文夹英文术语 0.242、英文夹几个汉字 0.05),没拿真实摘要的分布校准过。真实数据里有没有卡在这条线附近的条目,不知道。- 组合字符、ZWJ emoji(比如 👨👩👧 这种由多个码点拼出来的)可能被
Array.from切在连接处,只验证了单个 emoji 的代理对不会被切坏。 - 韩文只做过语种判定的逻辑推演,没有真造过韩文摘要跑一遍。
- I/O 失败一条都没注入过:
_已归档.json改成写完正文才记 id,道理上是「写失败就不记」,但没真的在写正文那一步制造异常(磁盘满、只读挂载、进程被杀)验证过这个顺序真的生效。 真出事时现场长这样:某条摘要的内容在_压缩存档里找不到,但_已归档.json里有它的 id——说明 id 记上了、正文没落盘,是这条路径漏了;反过来_已归档.json里没有 id,才算「从来没归档过」。对不上号时先查这两处,别先怀疑「压缩没触发」。 - 重复写的节号是「新开一节」:
writeArchive每次nextAt = max + 1。异常路径下一条摘要被重投,会在_压缩存档里出现两个内容相同、节号不同的节,钩子表也会有两行相同钩子指向不同节号。没做幂等合并——要幂等得把compactionId写进节里,那是另一个设计。 - ZWJ 之外的复杂字素没测:肤色修饰、国旗(区域指示符对)、阿拉伯/印度组合字符,只验了家庭 emoji 和单个代理对。
存档写入的复验历史
这块经过五轮对抗式复验。 每一轮都是让第三方 agent 只按一条标准判——「能不能构造出一个输入,让文件结构坏掉或者状态错」——构造得出来就 FAIL。五轮下来,作者自认为改好了的版本里有三轮被挑出真问题:
| 轮次 | 版本 | 挑出来的 |
|---|---|---|
| 独立验收 | 0.9.1 | 五处(手写 minor 被标 auto、正文能伪造结构、单节超限不闭环、缺表撞号、日志定格) |
| 复验 | 0.9.2 | 三处(转义漏了裸 \r、U+2028、U+2029;截断策略错;日志没闭嘴) |
| 复验 | 0.9.3 | 六类(围栏四类、拒写两类:节数统计不跳围栏、追加写入归一化行终止符、旧档假钩子行被复制、旧档伪节升格、拒写改坏已有正文、正文假 note 被提升) |
| 真数据 | 0.9.6 | 三条(静默截断 240 字、只取前 8 段丢了 84%、英文摘要进规则层)——这三条不是复验挑的,是第一次真实压缩的数据挑出来的 |
0.9.4 修掉的是这六类。不代表没有第七类。 已知的洞都堵了,每条都有测试钉着(tmp-test/fix-093.mjs 的 16 条,逐条复现复验方给出的构造输入)——但「已知的都堵了」不等于「穷尽」。
以后谁再动这块的解析或写入代码(scanLines、parseHookTable、parseOvers、writeArchive、trimArchive、recordOvers、bodyStart),必须:
- 重跑
tmp-test/fix-093.mjs那 16 条 - 至少再跑一轮独立复验
不许只看单测绿就交。 五轮里每一轮作者都觉得可以收,每一轮都被挑出东西——这个模式本身就说明「作者觉得可以」不作数。
诊断日志去哪儿看(0.9.5 起):注入相关的诊断走 console.info,落在宿主的 stdout(被 restart-dsh.bat 捕获成 tmp_all/host-out-<时间>.log),搜 recall: 就能找到。
真实跑出来的那一行(2026-10-26 01:32,宿主重启后):
recall: section text 第 1 次 入参={agent,scope,signal} arg.session=false arg.agent=true 取到id=14052879-9075-44e5-a4dd-67175c13b382
这个 text 回调的真实入参是 { agent, scope, signal }——会话在 arg.agent.session。 arg.session 不存在,arg 本身也不是会话,所以取 id 走的是三路兜底的第二路。sectionText 里的注释记着这条,别再猜(0.9.6 就是把这句写进代码)。只打 1 行是因为拿到 id 后就闭嘴——机制和设计一致。
别用 ctx.logger——它在 DSH 里事实上是哑的:cordis 自带的默认 exporter 只把消息推进内存环形缓冲(1000 条),dsh-app-boot 另外注册的那个阈值是 levels: { default: 2 }(WARN),info 是 level 1,直接滤掉,而且只存进内存数组。整个宿主没有任何 exporter 写 stdout。 宿主自己的包也因为这个用 console.info(LOG_TAG, ...) 打诊断(dsh-llm-deepseek-account 里那句注释写着 product profiles mount no console exporter for ctx.logger)。0.9.4 及更早那行 ctx.logger?.info?.(...) 一次都没打出来过,不是条件没触发,是通道不通。
测了的:用假 ctx 跑 apply(),直接触发 session/event 回调,10 项判据全过(2026-10-26,Windows,Node 24.21.0):
| # | 判据 | 实测 |
|---|---|---|
| 1 | 高分进 _次要规则,行尾 hits=1 [auto] |
通过 |
| 2 | 同钩子不同说法 → 旧条消失、新条在末尾、hits=2、正文是新的 |
通过 |
| 3 | 不相干但只撞两个词的 → 不覆盖 | 通过(条目 1 → 2) |
| 4 | 次档进 _压缩存档,_本子 一个字节不变 |
通过(本子 28B → 28B) |
| 5 | 31 条同 hits → 删时间最早的那条 | 通过(停 30 条,最老的消失) |
| 6 | hits 5 的和 0 的,第 31 条进来先删 hits 0 的 |
通过(hits=6 那条留下,hits=1 里最早的被删) |
| 7 | 同 compactionId 只写一次;缺失时指纹兜底也去重 |
通过 |
| 8 | which:"archive" 读得到;不传 which 仍是 secret |
通过 |
| 9 | 诊断日志出现、id 对得上;两层空时返回空 | 通过 |
| 10 | 7 个工具都在,mem_save → mem_find 走通 |
通过 |
体积保险丝实测:往 _压缩存档 连写 30 个约 10KB 的节,触顶后自动从最老的删——最终 191,746 字节(限 204,800),剩 11 节,§1 已删,钩子表同步减到 11 行,文件头写「已按体积淘汰 19 节 · 最新 2026-10-26」。单节 250KB 的情况另测:整条拒写,已有节一个不动,文件头记「有 1 条超 204800 字节上限未写入」,工具回话「没写:这一条 256000 字节……想存就拆成几条短的再写」。
独立验收之后补的五处(判据都通过,但第三方对抗测试挑出来的):
| 问题 | 现象 | 修法 |
|---|---|---|
| 人写 minor 被标 auto | 手写一条规则读回来是 hits=1 [auto] |
writeMinorRule 加 auto 参数,手写传 false,hits 从 0 起 |
| 存档正文能伪造结构 | 正文写 | 假钩子 | §77 | 让钩子表指向不存在的节 |
正文整段进围栏,解析时跳过块内所有行 |
| 单节超 200KB 不闭环 | 一节就 250KB,保险丝不删也不截 | 整条拒写(不截断),文件头记一笔 |
| 缺钩子表的坏存档撞号 | 只有 ## §5 没有表,新节从 §1 重来 |
新节号同时避开钩子表和已有分节 |
| 诊断日志会定格 | 第一次恰好没 session id 就再也不打 | 拿到 id 就算验完、立刻闭嘴;拿不到最多打 3 次 |
第一版转义方案为什么被推翻:第一版是把 ## §N 降级、| 转义的写法。复验方指出它漏了裸 \r、U+2028、U+2029——正文里写 第一行\r## §999 伪节,重写后会永久留下一个假分节,下一节被顶到 §1000;而 CRLF 反而安全,因为 \r\n 里的 \r 后面还有 \n。根因是转义器只按 \n 切、解析器却按 ECMAScript 四种行终止符切。换成围栏后这个不对齐自动消失:块内的行根本不参与解析,四种行终止符都只是正文。
没测的:
- 真宿主的压缩流程:重启后(2026-10-26,pid 23328 / 30032 两轮)实测
host-err均为 0 行、无新增报错;但那两个会话都没触发过压缩,压缩耗时仍无样本 text函数的入参形态:已核实。真宿主传{ agent, scope, signal },会话在arg.agent.session(arg.session不存在,arg本身也不是会话),实走第二路。诊断日志原文见sectionText注释与 0.9.6 提交hits的语义:「反复被摘要提到」到底等不等于「重要」,只是假设。头一周如果发现被删的条目里有明显该留的,说明假设不成立- 200KB 保险丝的合理性:按这个数走,没测过真实增长速度,第一次触顶时看日志再调
- 钩子质量:中文按 bigram 切,跨词边界靠虚字过滤挡,但没做真正分词。长句里真主题词可能排不进前五
- 摘要质量:只验「进得来、分档对不对、文件写得对不对」,不验摘要本身好不好——那是宿主的活
- 模型看到规则会不会照做:注入只保证它看得到
结论不代表:0.9.1 在真实压缩流量下不会误判、不会丢规则、hits 排序一定对。样本全是人造短句,不是真实对话压出来的摘要。
许可
MIT,见 LICENSE。
No comments yet. Be the first to write one.