DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

KarBurKi /

KarBurKi/dsh-recall

Verified

极简跨会话记忆:3 个工具、零自动注入、按工作区隔离,JSONL 文件即数据库。

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@508ee8af

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,同一条再来会被当「归档过了」跳过,永久丢失 没有任何记账 落一节记账,写明「切不出有信息量的段」并把原文带进去

第十一到第十三个洞(语种判定,同一条判据被挑了三次):

  1. 先是「假名一票否决」→ 中文规矩引用一句日文就被误伤(3.2)。
  2. 改成「分母用 \p{L} 所有字母」→ 希腊字母、全角字母进来了(3.4 修好),但纯日文的汉字占比 0.23 又过了 0.2 的线(日文条目回到规则层)。
  3. 最终:分母 \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),必须:

  1. 重跑 tmp-test/fix-093.mjs 那 16 条
  2. 至少再跑一轮独立复验

不许只看单测绿就交。 五轮里每一轮作者都觉得可以收,每一轮都被挑出东西——这个模式本身就说明「作者觉得可以」不作数。

诊断日志去哪儿看(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。

—/ 5

No ratings yet

Verified DSH bundle

Commit 508ee8af801b

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout