dsh-recall
极简跨会话记忆:7 个工具、零自动注入、按工作区隔离,JSONL 文件即数据库。 适合对Token消耗敏感、需要手动控制记忆同步与隐私隔离的用户。
Other repositories with this package name
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:KarBurKi/dsh-recallREADME
Read the full README ↗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 逐帧解压),挑出最相关的几段说出口的话——内心独白不取,注入碎片(``)也不取。 - 会话之间的墙:
mem_wall让一个会话给另一个留话(建议、吐槽、提醒、回应);mem_brief每轮带出最近三条,指名给自己的标「给你」。它是留言板,不是聊天室——一个来回可能要隔几天。 - 私密本:
mem_private给每个会话一个文件夹([TheLifeofFish]/private//),里头四本:_本子(背着别的会话干的事、说的话)、_额外规则(本会话自己的人设和规矩,和通用_规则冲突时以它为准)、_次要规则(压缩时自动归纳进来的规矩,权限低于前两本)、_压缩存档(压缩摘要里次一档的内容,是资料不是规则)。工具只认自己那个文件夹。 - 压缩时自动归档(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/.jsonl,同名.md是人读版;改文件即改记忆,拷走即迁移。 - 零运行时依赖:不 import 任何核心包,不注册核心服务(不会和计费类插件抢模块)。
安装
方式一:本地 link(推荐,适合自用)
# 1) 放到插件目录
git clone ~/.dsh/plugins/dsh-recall
# 2) 在 profile 里挂载:profile 的 package.json
# dependencies: "dsh-recall": "link:C:/Users//.dsh/plugins/dsh-recall"
# dsh.profile.bundles: 追加 "dsh-recall"
# pnpm-lock.yaml 的 importers 段补:
# dsh-recall:
# specifier: link:C:/Users//.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//,里头四本:
_本子.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-/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(内心)不要, ``(注入碎片)也不要——混进来只会污染片段; - 用 bigram 打分挑最相关的 k 段(默认 3),没命中就给最后几段兜底。
一次解压十几 MB 的会话大概一百多毫秒,只在你真想知道细节时调。
数据
~/.dsh/recall/.jsonl # 真相(每行 {id,t,k,x,s?,w?,g?})
~/.dsh/recall/.md # 人读版,随写入同步
~/.dsh/recall/_sync.json # 跨会话同步游标(mem_brief 用;删掉 = 下次重看一天)
~/.dsh/recall/_wall.jsonl # 会话之间的墙
私密本不在这里,按会话分开:
~/.dsh/[TheLifeofFish]/private//_本子.md # 秘密(只由人写)
~/.dsh/[TheLifeofFish]/private//_额外规则.md # 人写的规矩
~/.dsh/[TheLifeofFish]/private//_次要规则.md # 自动归纳的规矩(hits 计数)
~/.dsh/[TheLifeofFish]/private//_压缩存档.md # 压缩摘要的次档内容(200KB 保险丝)
~/.dsh/[TheLifeofFish]/private//_已归档.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写进节里,那是另一个设计。 - 旧格式(无围栏)存档的正文里,一行完整形状的复述标题会被当成真节。 真节靠标题格式
## §N 标题 · YYYY-MM-DD · s-xxxx和伪节区分。摘要正文是模型生成的,它可能复述一个真节标题(「上一档的 §3 · 2026-10-26 那个节说了……」)——这一行形状上和真节一样,迁移扫描时会认成真节。 但现代档不会:摘要落盘时整段进围栏(fenceBlock),围栏内的行一律跳过,所以复述行留在正文里、不升格。第四轮复验构造了 G1–G4 四组输入实测:现代档内联复述不升格(G1)、复述行独立成段时被切成单独一段写进正文(G2,仍是围栏内、不升格);旧格式无围栏档迁移时复述行被认成真节,节号 1,3,2 → 1,3,2,4、钩子表缺 §3 行(G3);现代围栏档不升格(G4)。 现场长这样:存档里多出一节,内容是在讲另一节,而不是那一节本身;或者节号跳号、某节没有钩子行。 这跟 0.9