dsh-experience-memory
marquez807/dsh-experience-memory
Cross-session experience memory for DSH: a lesson reaches the model only with a verifiable source, the relevant ones are injected each turn, and what nobody uses is retired.
Install
dsh plugin --profile web add github:marquez807/dsh-experience-memoryREADME
经验记忆(dsh-experience-memory)
给 DeepSeek Harness 的分领域长期经验记忆:能分清轻重、能积累经验、能遗忘、能纠错,并在再次执行同类工作时自动召回相关经验。
从 13 处归档安装、5 个互不一致的版本、415 条历史记录里取优排劣后重新实现。插件运行时零第三方依赖,只用 Node 内置能力。
安装:一条命令
dsh plugin --profile <name> add /path/to/dsh-experience-memory-0.1.0.tgz
这一步就够了。 dsh plugin add 不只是装依赖——它会把 dsh.profile.bundles 与已安装状态对账:任何声明了 dsh.bundle 的依赖都会被自动追加进 layer stack(见 @deepseek-ai/dsh 的 reconcilePlugins)。所以不需要手工编辑 profile 的 package.json。
装完重启应用即可。零配置:不提供任何 config 也能工作——默认库在 $DSH_HOME/experience-memory/memory.db 自动建立,五个工具、七个斜杠命令与常驻注入立即生效。
想在装之前确认它是在工作的,用斜杠命令(见下):
/memory-status # 库里有多少、多少条够常驻线
/memory-preview 部署 # 这一轮会注入什么
它挂了四个表面
| 表面 | 内容 | 谁触发 |
|---|---|---|
| 自动注入 | 常驻摘要:核心层(跨项目印证过)+ 查询层,共享 1536 字节;外加一行每轮固定出现的经验提示(204 字节) | 无 |
| 自动维护 | agent/turn-stopping 有界维护,批量 32 条带游标 | 无 |
| 模型工具(5 个) | memory_recall / remember / feedback / forget / stats | 模型 |
| 斜杠命令(5 个) | 状态、预览、维护、审计、导入 | 人 |
工具和命令的分工是刻意的:审计与导入会伸到库外面(扫描任意目录、批量写入),所以留在人的触发之后。memory_stats 是唯一给模型的运维视角工具——只读、无参数,用来回答「你记得什么」,或者自查「我记的东西到底有没有送达」。
那一行经验提示为什么必须独立于摘要、且无条件出现:摘要在没有合格记录时渲染空串(不注入),而"库里什么都没有"正是模型最需要被告知"可以记录"的时刻。把它并进摘要,它就会随着记忆一起消失——而库空着这件事会自我维持。这不是推测,是实测:在 5 个真实会话、约 5,900 次工具调用里,记忆工具在装好之后的每一个请求轮次都被提供了,而 memory_remember 一次都没被调用过,直到有人明确点名要求记录。
同一句话现在也用来要求"查"。 见下面「记了不等于有用」:只叫模型记、不叫它查,等于让它一直写、从不读。所以提示的顺序是先查后记——动手前 memory_recall,学到东西 memory_remember,用过 memory_feedback。
安装(开发期细节)
pnpm pack # prepack 会自动构建 lib/
dsh --profile <name> --dump-config # 应出现 "# == dsh-experience-memory" 层
装完后可以在该 profile 目录里跑一次消费者级校验(纯 node,不加任何 flag):
cp tools/verify-install.mjs "$DSH_HOME/profiles/<name>/"
node "$DSH_HOME/profiles/<name>/verify-install.mjs"
为什么要构建
发布产物是 lib/ 下的普通 JavaScript,main 指向 lib/index.js。原因是 Node 拒绝对
node_modules 里的文件做类型擦除:
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
源码树里 main: "src/index.ts" 能跑通(link: 安装、开发测试都行),但用户装的是 tarball,
所以随包发出的入口必须已经是 JavaScript。
构建用 node:module 的 stripTypeScriptTypes,因此构建期也是零依赖,不需要 TypeScript 或打包器:
node tools/build.mjs # src/*.ts -> lib/*.js
stripTypeScriptTypes 不会重写模块说明符,所以构建脚本自己做这件事:每个相对 ./x.ts
说明符改写为 ./x.js。改写数量为 0 或 lib/ 里残留任何 .ts 说明符时,构建直接失败拒绝发包。
类型注解从不被检查。 stripTypeScriptTypes 只把注解擦掉,不做类型检查,而 toolchain 里没有 tsc——
零构建依赖是刻意的,代价就是类型不一致不会被任何一步发现:错误的注解会被原样删掉,运行期行为不受影响,
所以它连测试都不会惊动。类型在这里是给人读的文档,不是被验证的契约;真正被验证的是行为(套件数和断言数都由 tests/run.ts 自己报——PASS 13 suites · N assertions,本文不写这个数字,因为手抄的数字必然过期:这里原先写的是"470+",实际跑出来是 661)
和导出面(tests/built.mjs 逐名比对 src 与 lib)。要加类型门禁就得引入 TypeScript 依赖,
那与"构建期零依赖"直接冲突,所以这是一个明知的取舍,而不是遗漏。
关于依赖与警告
@deepseek-ai/* 全部声明为 peerDependencies,由 DSH 安装目录通过
$DSH_HOME/profiles/node_modules 的扁平回退符号链接解析,因此本包既不打包也不安装它们。
安装时 pnpm 会报 6 条 missing peer @deepseek-ai/* 警告,这是预期行为:这些包由 DSH 宿主提供,
第三方 bundle 不该自带副本。真正加载时它们都能解析到。
开发期测试用 DSH 自带 Node 直接跑 TypeScript(Node 24 类型擦除),不需要先构建:
node --experimental-strip-types tests/run.ts # 源码语义
node tools/build.mjs && node tests/built.mjs # 构建产物
安装形态:发布用 tarball,开发用 link:
两种形态契约不同,别混用(一位调用方问过这个,值得写下来):
| tarball(发布形态) | link:(开发形态) | |
|---|---|---|
| 加载的代码 | 打包那一刻的 lib/,冻结 | 仓库的 lib/,跟着工作区变 |
files 白名单 | 生效——src/、tools/、tests/ 都不在包里 | 不生效:整个仓库(含 .git,以及指向 $DSH_HOME/profiles/node_modules 的那个 node_modules 联接)都在 node_modules/<包名>/ 下可见 |
| "安装 == 产物"不变量 | 成立,可用 audit/compare-install.mjs 逐字节核 | 不成立,比较无意义(同一份文件) |
| 改代码后 | 必须 build + pack + 重装,并且重启 | 跑 node tools/build.mjs,同样要重启(原因见下) |
| 免重启热重载 | 不适用(包是冻结的) | ❌ 不会发生,即使 HMR 配置完全正确 |
结论:开发回路用 link:(这正是它存在的意义),发布与验收一律用 tarball。link: 下要注意两点:
① 回路是「改 src → 构建 → lib 变化 → 重载」,漏掉构建就会加载与源码不一致的 lib/(node tools/build.mjs --check 会当场报出来,exit 1);
② "整个仓库可见"是真的副作用,会影响任何遍历 node_modules 的扫描器(包清单、skill 扫描、client module 扫描)。只想跑代码而不想暴露仓库时,用 tarball。
⚠️ link: 换不来免重启热重载(我原先写错了)
这张表原先在 link: 那一栏写着"配 HMR 可免重启"。这句话是错的,已被实测证伪。
dsh-bigfat 会话做过一次完整排除法:junction 安装、--dump-config 确认合成结果里 hmr: disabled: false 且 root 指向 <pkg>/lib、宿主确实带 --expose-internals、node_modules\<包名> 的 LinkType 确实是 Junction、宿主确实没有 --preserve-symlinks —— 配置全对,然后改一句渲染字符串、立刻调工具,输出没变。
根因不是配置层级,是**「监视到了」≠「能定位到模块」:HMR 用联接路径**算出的 module URL,与 Node ESM 加载器按 realpath 登记的键对不上,于是它 emit 一个 hmr/change,无人监听模块被换掉,既不生效也不报错。
对 dsh-experience-memory 的直接含义:它现在就是目录联接安装,所以它自己的 lib/*.js 改动在本 harness 里一律需要重启。本文所有"重启后 memory_stats 首行会变成…"的说法与此一致。
要么改真实目录安装(失去"改仓库即时生效"),要么 NODE_OPTIONS=--preserve-symlinks(全局影响)。两条都不是本仓库能单方面决定的。
这个进程加载的是哪个构建
版本号永远是 0.1.0,而 tarball 会把所有文件的时间戳还原成 1985——副本身份在磁盘上没有判别物。而 link: 下"磁盘哈希"还回答不了真正的问题:
那份文件就是工作区,哈希相同并不能说明进程重载了它。
插件因此在激活时自己算一遍它加载的那批模块的内容哈希(src/build-id.ts),两个地方能看到:
/memory-status首行:插件构建 <id>(<n> 个模块)——给人 / 运维看;memory_stats文本首行:同一行——给模型侧调用方看(它没有日志访问权,这才是它能用的那一半)。
⚠️ 它不在 harness.log 里。 我最初把激活时的 ctx.logger.info 当成可 grep 的锚点,实测是错的:那个文件只捕获进程的
stdout/stderr 与桌面启动器自己的行(node 的 ExperimentalWarning 在里面,Cordis logger 的输出不在——530 行里没有任何 level 标签)。
要确认"重启加载的是哪一版",读 /memory-status 或 memory_stats,不要去 grep 日志。
与仓库里同一份构建的哈希一致,才说明"重启后生效的是这一版";两个会话的 id 相同,说明它们跑的是同一份代码。
唯一锚点是构建标识,不是逐文件 sha256 表。 这条是被一个校验方推着我改的:他按回执里的 10 行 sha256 表逐文件比对,得到 7/10 一致、3/10 不一致 —— 原因只是我在他验收之后又发了一版。他的论证比我原来的做法对,所以照办:
以构建标识为唯一锚点,停止维护逐文件表。它是对已加载的编译模块算的哈希,比"磁盘上某些文件"更贴近"进程真正跑的是什么"。逐文件表是冗余的,而冗余的快照就是过期源。
因此:对外校验只给 插件构建 <id>(<n> 个模块)。逐文件 sha256 仍然可以算,但只在需要 diff 一个检出、找出哪几个文件不同时才用,而且必须连同它所属的构建标识一起给出 —— 一张没有标注版本的哈希表,读到的人第一件事是怀疑"是不是被换了",那是它自己制造的成本。
只读工具会报出自己的 call id
memory_stats 与 memory_recall 的返回末尾会带一行 本调用 id call_xx…(把它填进 source_ref 即可判 verified-tool)。
原因是实测出来的:route: tool-call 的判据是"被引用的那次工具调用成功",而模型从来看不到 call id 的文本——它唯一一次被印出来,是在一条失败记录的 reason 里。于是"把我刚看到的那次工具输出记下来"要付两次调用:第一次专门用来失败、以取得那个 id。让只读工具自报 id,就是把这一次省掉(tests/plugin.test.ts 里有一条一次调用直达 verified-tool 的端到端断言)。
只读观测者带,三个写工具不带:一次写操作不是关于工作区的事实,而 source_ref 是给"后来能重新核对"的主张用的。
tools/verify-install.mjs 还会顺带断言命令恰好 6 个、上下文恰好 2 条。它验的是挂载期去重,不是重载期去重 —— 这个区分是必要的:重载后名字翻倍是 HMR 泄漏的症状,但在联接安装下根本不会发生重载(见上一节),所以"重载后再跑一遍"并不能证明重载安全,只能证明这次挂载没有重复注册。要验重载期去重,需要一种 HMR 真能重载模块的安装形态(真实目录 + --preserve-symlinks,或整包重载)。
它的断言条数由脚本自己打印(PASS installed package (26 checks)),不在文档里手抄。
启动验收
--dump-config 只证明配置能合成,证明不了加载器真的导入了这个 bundle——而正是后者曾经失败
(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING)。验收办法是让启动留下一个可观测的事实:给 profile 打一个
只改 dbPath 的 overlay,指到一个尚不存在的文件。
# boot-acceptance.overlay.yml
- id: experience-memory
config:
enabled: true
dbPath: F:/…/boot-acceptance.db
DSH_TELEMETRY_DISABLED=1 node <dsh>/lib/bin.js --profile <name> --patch boot-acceptance.overlay.yml
库文件出现,就一次证明了四件事:加载器按 name 解析到了包、导入了它、inject 声明的 tools 与
systemPrompt 在真实 base 合成树里都解析到了(这一点 --dump-config 抓不到——inject 依赖缺失时
插件只是静默不激活),以及 apply() 跑完并建好了 schema。
该 profile 的 bundle 列表里没有 app,所以它只挂载、不提供服务;确认库文件出现后结束进程即可。
跑一次真实模型回合
挂载层断言证明不了模型实际看到了什么。要跑真实回合、又不污染正式库、也不起服务器,用一次性的
@deepseek-ai/dsh-headless app 配一个临时 profile:
- 临时 profile 的
bundles=@deepseek-ai/dsh-base+@deepseek-ai/dsh-headless+ 本插件。 它必须同时带pnpm-workspace.yaml(nodeLinker: hoisted、autoInstallPeers: false),否则 pnpm 会去 公共 registry 找@deepseek-ai/*,而那些是 in-box 包、根本没发布,安装以 404 失败。 - 再打一个只改
dbPath的 overlay,指向一个一次性库——正式库由正在运行的应用持有。 - 给模型布置一个只可能来自记忆的任务:先往一次性库写一条在任何文件、任何环境里都搜不到的断言 (例如一个自造的部署代号),再在另一个新回合里问它这个代号是什么。
判据不是「答对了」——模型可能瞎猜。判据是答对、且工具调用数为 0:那证明事实是随 systemPrompt 注进去的,
不是它 memory_recall 出来的。这两件事在工具日志里长得完全不同。
反过来同样有用:需要逐字引文才能定级的那条路径,也只有真实回合能验证。本仓库的
src/session.ts 就是被这一步抓出来的——12 个套件全绿,因为它们的 fixture 手写了一个真实 Session 上
并不存在的属性。
它做什么
三个阶段的工作各有一层机制:
1. 记录时——证据定级
记录一条经验必须给出它所依据的原文(quote)和出处(source_ref)。插件自己去核对:
| 等级 | 条件 | 基础分(×3.0) |
|---|---|---|
verified-tool | source_ref 是本会话里一次真实执行且未报错的工具调用 | 9.0 |
verified-user | 原文逐字出现在用户发出的消息里,且该句不是疑问或假设 | 7.5 |
verified-file | 原文出现在所引用的工作区文件里 | 6.0 |
inferred | 以上都不满足 | 1.5(永远候选) |
只有前三级能进入注入层,inferred 永远是候选。这是必要条件,不是充分条件:常驻资格线是 5.5,
而 verified-file 的基础分是 6.0 —— 高出 0.5,按每天 0.0083 的扣分算是约 60 天。所以三级证据的实际行为是:
verified-tool/verified-user从第一天起就能常驻,而且能靠基础分撑很久(9.0 / 7.5 对 5.5,约 360 / 180 天);verified-file靠自己也能常驻约 60 天;60 天里没人查过、也没被确认有用,才会沉到线下,此后需要查询命中一个标识符(路径、类名、文件名,值 1.0 分)或被查过/被成功复用(被查过封顶 +1.0,成功复用每次 +1.5 的对数分)才回到线上。线下时它仍然在memory_recall里按需可检索。
那 0.5 是刻意留的。 它原先不存在:资格线曾经也是 6.0,与 verified-file 的基础分精确相等,于是任何年龄扣分都把它压到线下 —— 那不是"靠相关性换位置",是"必须在写下的那一瞬间被使用",实际等于永远不用。现在这条间隔是一条有意画的线:新记忆白送两个月曝光,之后要靠被用来续命。
这是刻意的:文件里读到的事实比工具实测和用户断言弱,让它靠「与本轮相关」而不是靠「存在」换取提示词位置。
审计里那些 verified-file 记录实测有一部分立即合格(够新的都合格)、命中标识符后合格率更高——这正是该规则在工作。
1.1 失败必须被说出来
判定逻辑不改,但失败的原因要外传。这条是被一份调用方缺陷工单逼出来的:对方为了搞清自己三条记录为什么只拿到 inferred,
做了 5 次记录实验、通读源码,才发现真因是"我给的是绝对路径,插件根本没读"和"引文漏了一处 **"——
而这两件事,写入返回体里一行字就能说清。
改之前,四个不同的失败(绝对路径 / 越界 / 文件不存在 / 读不了)全部汇成同一句
no session or workspace evidence matched the supplied passage——这句话指着引文,而真因在路径上,是典型的把人引向错误方向。
现在 readWorkspaceFile 把失败原因作为数据返回,reason 逐条说清试过什么:
| 失败 | reason 现在怎么写 |
|---|---|
| 绝对路径 | 点名它是绝对路径 + 要求改成工作区相对路径 + 给出工作区根 |
| 文件不存在 | 给出被引路径 + 列出最近存在目录的内容(仓库在 repos/x/ 下而调用方写了 lib/y.js 时,一眼可见) |
| 路径越界 / 读不了 | 各自独立成句 |
| 引文不在文件里 | 若忽略 markdown 装饰符后能匹配,就明说这一点并让它整行复制;否则给出最接近的第几行及其内容 |
两条刻意的边界:装饰符只用于诊断,不用于放行——忽略装饰符后匹配仍然判 inferred,逐字契约没有被软化;
以及每次判定都带 route(tool-call / file / user-message / none),因为 source_ref 是双关字段
(工具调用 id 或 path:line),调用方此前无法知道自己写的到底被当成了哪一种。
1.2 grade 是写入时冻结的
证据等级在写入那一刻定下来、此后不再重算;每次召回重算的是 importance(它由已存的事实推出:年龄、复用、失败连击)。
所以所引文件后来被移动或删掉,不会改变这条记录的证据等级——它仍带着当时的结论,也不再可被任何人复核。
工作区归属同理:workspace_id 在写入时由会话 cwd 解析,换一个工作区后这条记录是看不见(而不是"等级变了"),
除非它已经升到领域级。
1.3 进入注入层还有第二道闸:相关性
定级管的是「这条值不值得信」,相关性管的是「这一轮是不是在讲这件事」,两道闸相互独立,都要过:
| 闸 | 判据 | 过的条件 |
|---|---|---|
| 定级 | importance ≥ 6.0(证据 + 历史) | 见上 |
| 相关性 | 与当轮查询的词元重合是否具体 | 命中标识符,或至少共享一个实词 |
第二道闸是实测补上的。此前只查定级,于是出现过这样一次注入:一条讲 batchSize 上限 500 的记录,被注进了
「把这个仓库的 README 用一句话改写」这一轮——两者语义毫无关系。唯一的原因是 FTS5 的表达式是按二元组 OR 匹配,
而那条记录的正文里有一句"不得动这个值",撞上了提问里的「这个」。确定性复现:identifierMatches=0、bm25 仅 −0.59、
excluded 为空——没有任何过滤器提出异议。
问题的形状是「常用词不构成相关性证据」。判据因此不是"共享几个词"(两字中文词只产生一个二元组,要求多个会把
显然正确的匹配一起拒掉——第一版就是这么做,被测试当场否掉),而是「共享的那个词是不是实词」:src/retrieve.ts
里维护一张 CJK 功能词表(这个/可以/一句/…),只有共享词全是功能词时才拒绝。修的是根因,不误伤"只共享一个实词"的正当匹配。
一个反向激励也一并消失:importance 随成功复用上升,所以越有用的记录越容易越过定级线,只查定级的话它同时就越容易
靠一个"这个"漏进无关回合。现在相关性那道闸与历史无关。
2. 召回时——分清轻重
旧系统要求人工登记脚本哈希并重放 2–32 次才允许晋升,机制严谨但代价致命——139 个工作周期后记忆库里 0 条稳定资料。这里的定级是自动的,因为只有便宜到会真的发生,严格才有意义。
记了不等于有用:一个把记忆变成"只写不读"的死循环
这条是用户直接点的题:"记下来的东西不用"。查下来的原因不是 agent 不自觉,是四件事串成了一个闭环:
- 想自动出现在提示词里,重要性要 ≥ 6.0;
- 一条文件级记忆刚写下正好是 6.0(
3.0 × 2.0)——门槛上的刀刃,几小时的陈旧度扣分就把它压到线下; - 想留在线上只能靠复用加分,而它需要有人调
memory_feedback说"这条帮到我了"——这个动作在整库 76 条的生命周期里只发生过 3 次; - 于是 76 条里只剩 2 条在自动层,其余只能靠模型主动
memory_recall去查;而那句无条件的提示只叫它记,从没叫它查。更糟的是:"查"这个动作根本没被记录,所以就算某条被后来的会话翻出来用了,它得到的收益是零 —— 下次照样沉默。
四处都修了:
| 改动 | 效果 |
|---|---|
memory_recall 现在记录"这条被查过"(retrieve_count / last_retrieved_at) | 检索第一次留下痕迹,「记了有没有被用」这个问题终于答得出来 |
被查过也算"碰过":陈旧度的锚点取 max(建库时间, 上次被用, 上次被查) | 一条后来被翻出来用的记忆不再按"没人理过"衰减,它会自己爬回自动层 —— 死循环断开 |
检索加分封顶 1.0(0.3·log2(1+被查次数),不超过 1.0) | 一次查找不如一次"记录成功"值钱;否则反复调 memory_recall 就能让任何东西永久常驻 |
提示改成先查后记,并点名 memory_feedback | 提示是修"提供了但没用"的既有手段(见上文那次实测),这次对称地用在"查"上 |
自动注入不算"被查",这是刻意的:一条记录若把自己的注入也算作使用,它就会自己把自己留在自动层里,那个数字也就不再意味着"有人找过它"。
memory_stats 因此多了一行,直接回答这个问题:被查过 N/M 条(已确认范围内) · 从没被查过也没被确认有用的 K 条。K 就是"只写不读"的存量;它应该随着会话推进而下降。
常驻层每轮由 ctx.systemPrompt.context 重新求值(不是开机快照),最多两段、硬上限 1536 字节:
经验记忆(领域通用,已由多个项目独立印证):
- [id] 标题 — 教训 ← 核心层:不管这一轮在说什么都在
经验记忆(与本轮相关):
- [id] 标题 — 教训 ← 查询层:命中当前话题的
两段共享同一个 1536 字节预算。 这是「无条件注入」能负担得起的原因:核心层占用的是提示词的 重新分配,不是新增——它变不出更多 token 来。哪一段没有内容就整段不出现(不会留下空标题), 只有查询层时用法与单段时完全一致。
核心层存在的理由:查询层是查询门控的,所以用户回一句「继续」时没有任何词元可命中,摘要恰好 在长任务进行中清空。核心层的准入条件是全框架最窄的:
| 条件 | 为什么 |
|---|---|
scope = domain | 只有被两个以上工作区独立报告过的内容才会升到领域级 |
status = confirmed | 候选从不注入 |
evidence ≠ inferred | 没有任何东西验证过的内容不注入 |
distinctWorkspaces ≥ 2 | 一个项目的习惯不是领域规则 |
| 通过与查询层相同的常驻资格线 | 核心记录永远是常驻层的子集,不是一条后门 |
由 coreMaxRecords 限制条数 | 保证是有界的 |
工作区级记录无论多重要都永远不会成为核心——没有任何东西印证过它。
命中集合内按这个公式排序:
重要性 = 3.0 × 证据等级 (verified-tool 3.0 / user 2.5 / file 2.0 / inferred 0.5)
+ 1.5 × log2(1 + 成功复用次数)
− 2.0 × 连续失败次数
− 1.5 × 陈旧度
+ 0.5 × log2(独立工作区数)
+ 0.3 × log2(1 + 复用次数)
+ min(2.0, 1.0 × 标识符精确命中数) ← 封顶
排序键 重要性 DESC, bm25 ASC, id ASC。旧系统把常驻 8 条按 uuid4 字符串排序,等价于随机抽样且永久冻结——库里 100 条时新记忆进入概览的概率只有 8%。
2.1 记了,但没在它动手的那一刻出现
这是用户点的第二个题,比"记了不用"更隐蔽:那条记忆真的存在、真的是对的、也真的被注入过, 但偏偏在它该出现的那一轮没出现。
真实例子:一条"启动 Bannerlord 前必须确认 Steam 已登录,否则游戏 10 秒后静默退出"的记录, 有文件级证据,在那个会话的 15 轮里有 9 轮被注入——唯独用户说"开始吧"的那一轮没有。 agent 直接启动,那一轮白跑。
原因有两个,都不是"记忆坏了",是"递送方式不对":
- 用来找记忆的那句话,只有用户说的话。 用户回一句"开始吧",这几个字里没有任何东西能命中 "Steam"或"启动"。于是摘要层在长任务进行中恰好清空,而 agent 手头正在做的事,一个字都没进查询。
- 只有"每轮开头"这一个递送时机,而这个时机由用户的话决定,不由 agent 在做的事决定。
改了两处,都拿那个会话的真实日志(444 次工具调用)量过,不是推出来的:
- 查询里加上"agent 正在做什么":它调工具的参数、它自己写出来的话、它的待办清单。 插件自己注入的消息一律跳过,否则一条提示会把自己喂回下一轮的查询。没有活动时, 拼出来的查询跟以前一字不差——这一点有断言钉着。
- 在工具调用正要动手时递(
precall):一次调用本身就在点名——它要跑的脚本、要改的文件、 要找的符号。匹配只读参数的值,抽出路径、文件名、符号、开关这类"抓手"; 如果某条已确认的记录提到了其中一个能区分开的抓手(能看到这个工作区的记录里, 提到它的不超过 2 条),就把那条记录贴着这次调用递上去,一次调用最多一条、最多 300 字节。
试过、量过、删掉的三样东西(都是回放说了不行,不是嫌麻烦):
| 试过的做法 | 回放结果 |
|---|---|
把参数的键名也当抓手(file_path、old_string) | 每次编辑都带这些键,444 次调用里 232 次都能命中点什么;中选的不是该看的那条。改成只读值 |
| 每轮只准递一条(1 到 6 都试了) | 那一轮的名额被"这轮里更早碰到的别的记录"拿走,Steam 那条一条都没递出去过。所以节流只靠"同一条的冷却"和"一个会话的上限",代码里写明了为什么 |
拿 Bannerlord 当抓手 | 这个工作区能看到的 17 条记录里 13 条都提到它,命中它等于没命中;而 launch-a-runtime-clean.ps1 只有 2 条、ERC403 只有 1 条——那才是这条经验真正在讲的东西 |
最终在这个真实会话上的效果:20 条提示,落在 15 轮里的 4 轮;Steam 那条贴在"写启动脚本" 那一次调用上——跟启动游戏同一轮,在动手之前。
说清楚它做不到什么:它不保证贴在最该看到的那一通调用上。一轮里第一件碰到这件事的动作 会先拿到这个名额,所以"运行"那一通可能反而没有——经验已经在同一轮的对话里了,但它不是 "贴在那一行上"。这是真实取舍,写在这里而不是含糊过去。
3. 之后——遗忘与纠错
- 退役:用户显式遗忘 / 连续 2 次失败结果 / 已过期 / 复核逾期且从未复用 / 90 天未复用且分数低于阈值
- 不物理删除:退役可逆,只有
purge=true才删字节 - 跨项目晋升:一条经验只留在学到它的工作区,直到两个不同工作区独立报告同一内容,才升为领域级、定案,并成为每轮无条件注入的核心记忆
- 身份是断言本身,不是标题:标题只是标签(常常是正文的自动摘要),所以两条正文相同、标题不同的记录是同一知识。把标题算进身份会让跨项目印证永远数不上,领域晋升也就永远不会发生
- 重记会退役被它取代的候选:模型有个稳定习惯——先写一遍没有引文的版本(→ 候选),发现不合格,再用文件引文重写一遍。因为身份是断言,改写后的正文是另一条记录,候选就永远留在库里:不可注入、不可见、也没有任何东西清理它。实测在一个真实库里形成过 3 对这样的重复(占全部记录 43%)。现在写出一条已定级的记录时,会把同工作区、同标题的候选退役,
supersededBy指向新记录并写纠错日志。标题比较折叠标点——库里就有一对只差一对「」,精确比较把它当成了两条不同主张 - 维护在
agent/turn-stopping运行,批量 32 条带游标,永不进入检索热路径
易腐事实:给记录上一道过期窗口
长期记忆如果永远不会过期就是负债——「当前测试命令是 X」「当前客户端版本是 1.5.2」这类断言会在世界改变后
静默变成假的,而且因为是已验证事实,它排得还更靠前。所以 memory_remember 接受两个可选窗口:
| 参数 | 作用 |
|---|---|
expires_in_days | 到期后立即停止被检索,维护再把状态改为 retired |
review_after_days | 到期后不直接退役,而是要求复核;若再过 30 天(REVIEW_GRACE_DAYS)仍从未被复用,才退役 |
两条规则的分工是刻意的:过期的事实不该被回答,但「需要复核」不等于「已经错了」。而且被复用过的记录不会 因复核逾期退役——复核窗口是用来发现没人需要的东西,不是用来惩罚年龄的。
用同一条断言再报一次是重新验证:新窗口替换旧窗口,而不是被忽略。
在加上这两个参数之前,expiresAt 与 reviewAfter 只有旧数据导入器会填,所以三条退役路径里有两条
对插件自己记录的记录永远不可达——机制齐全但没人能启动它。
作用域
| 作用域 | 谁看得见 |
|---|---|
workspace | 只有解析出同一根路径的工作区 |
domain | 任何解析出同一领域的工作区 |
领域解析顺序(先命中先用):插件配置 defaultDomain → 工作区 .dsh/memory.yml 的 domain: → package.json 的 name → git remote 仓库名 → 留空(仅工作区级)。
最后一级刻意留空而不用目录名:把 dsh主工作区 这种名字当领域,会把单个项目的怪癖扩散到所有同名目录。
工具
| 工具 | 作用 |
|---|---|
memory_recall | 按查询检索,上限 16384 字节,超限按序截断并报告。include_candidates 用来复核自己记过但没验证过的断言,include_retired 用来审计已退役的。只在真正交出去的那些记录上记一笔"被查过"(截断掉的尾巴不算),这是"记忆有没有被用"的唯一痕迹 |
memory_remember | 记录一条事实/经验/策略;不提供可验证原文则存为候选。可选 expires_in_days / review_after_days 给易腐事实上一道窗口 |
memory_feedback | 关联一次真实结果;成功清除失败连击,两次连续失败即退役 |
memory_forget | 退役(默认)或彻底删除 |
memory_stats | 只读普查:库里有几条、多少条够常驻线、复用与纠错计数、最近退役原因。无参数。首行是构建标识、末行是本调用的 call id,/memory-status 是它的给人版本 |
两个工具的描述是指令性的,不是能力说明:memory_remember 以触发时机开头("一旦学到下次会话仍然成立的东西就调用"),memory_recall 以适用场合开头("进入不熟悉的领域、或可能要重复一个已经做过的决定之前调用")。理由是实测出来的——仅仅把工具放进 schema 不足以让模型使用它(见上文的 5,900 次工具调用)。约束写在描述末尾:只记可复用的规则,不记一次性细节、瞬时工具输出、密钥或未经验证的猜测。
source_ref 的参数说明还写明了哪条引文是可以定级的:依据文件就写 path/file:line;主张"某个命令能用"就引用成功的工具调用 id;而从失败中学到的教训不能引用那次失败调用——失败调用在此不构成证据(gradeEvidence 的既有语义,evidence.test 里钉着 "a cited tool call that errored proves nothing")——应改为引用记录了该发现的那个文件。这一句是实测补上的:一个隔离回合里模型把失败的 pytest 调用当出处,记录于是只能落成候选、永远够不到常驻线;而它在另一次里自己绕到了"引用写进仓库的测试文件"这条可定级路径,只是多花了一轮。
斜杠命令(给人用,模型看不到)
通过 ctx.commands.register 注册,所以出现在 /compact、/goal 所在的同一个斜杠菜单里。全部 recordInput: false——运维命令和文件系统路径不会进入会话记录。
| 命令 | 用法 | 作用 |
|---|---|---|
/memory-status | — | 库普查:条数、状态/证据/作用域分布、多少条够常驻线、复用与纠错计数、最近退役记录及原因 |
/memory-preview | [<query>] | 打印该查询下实际会被注入的摘要,以及按需检索会补上什么。不传 query 时用最近两条用户消息——与插件自己的查询推导是同一套逻辑 |
/memory-maintain | — | 立刻跑一次有界维护并报告退役了几条、为什么(同一套规则每轮结束也会自动跑) |
/memory-harvest | [--retire <id>] | 列出自动采集的候选,或退役其中一条 |
/memory-audit | <root> [--out <dir>] | 审计归档库的正确性并落盘四份报告 |
/memory-import | <root> [--selection <file>] [--apply] | 默认只试运行;只有显式加 --apply 才写入 |
/memory-gaps | [<条数>] | 列出本工作区反复失败的形状、实际报错、以及库里有没有相关的记录。只统计,不注入、不写记录 |
为什么审计与导入不给模型:它们会扫描任意目录并批量写库,爆炸半径大,而这个框架一贯 fail-closed。模型的工具表因此只有 5 个(其中 4 个是知识操作,第 5 个是无参数的只读普查),不牺牲每轮 token。
/memory-preview 与真实注入共用同一个函数(src/digest.ts),所以它不可能与你实际收到的内容不一致——一个会漂移的预览就没有存在意义。
迁移
命令行(仓库内,适合脚本化):
node tools/import-legacy.mjs --root "F:\GPT工作区" # 试运行,打印报告
node tools/import-legacy.mjs --root "F:\GPT工作区" --selection <清单> # 只导清单里的
node tools/import-legacy.mjs --root "F:\GPT工作区" --apply # 写入(不带清单就是全部可映射记录)
插件内(装完即可用,无需仓库):
/memory-audit "F:\GPT工作区"
/memory-import "F:\GPT工作区" --selection "…\legacy-memory-selection.json"
/memory-import "F:\GPT工作区" --selection "…\legacy-memory-selection.json" --apply
默认只试运行,因为归档树里既有活库也有副本,误导入不是可逆的错误。
判断与机械操作分开
「哪些记录值得导入」是关于数据的编辑判断,「把记录写进库」是机械操作。两者被拆开了:
tools/audit-legacy.mjs做判断,并写出legacy-memory-selection.json—— 纯 JSON,就是给你改的。 删掉你不同意的条目,然后:
node tools/import-legacy.mjs --root "F:\GPT工作区" --selection audit\legacy-memory-selection.json
node tools/import-legacy.mjs --root "F:\GPT工作区" --selection audit\legacy-memory-selection.json --apply
tools/import-legacy.mjs只执行清单。试运行会报告清单排除了多少条,所以在写任何东西之前就能复核。- 清单里的身份是
(workspaceId, contentFingerprint),与审计去重时用的键一致,所以它不可能含糊地指向两条记录;它也不依赖记录 id,因为 id 每次导入都会重新生成。 - 空清单是合法答案:导入 0 条,而不是「没给清单就导全部」。
五条刻意的取舍:
- 导入记录直接写入,不重新定级。走
remember会把每一条都定成inferred(迁移没有会话可引用),等于在入库路上把一库已验证事实静默降级。 - 旧
global记录降为工作区级。无法判断它原本属于哪个领域,而广播到所有项目正是新作用域规则要防的泄漏。数量会单独报出来,供逐条决定。 - 副本库不导入。
.codex/project-memory-backups/、.dev-packages/、.eval-pilots/以及名字里带 backup/snapshot/copy/rehearsal 的目录装的是另一个库的副本。导入它们会让一条经验按快照数量翻倍——归档树里一条记录被存了 34 份。扫描阶段就排除,并逐个列出原因。 - 同一个库内的重复写入合并。旧运行时把同一断言反复追加(迁移过的库还在
entries.jsonl和memory.sqlite3里各存一份),时间戳不同不算新知识。 - 工具失败事件不导入,哪怕它的 type 是
fact。旧运行时在工具调用失败时写的是type: fact加admission.proof.kind: tool,于是它带着最强证据等级和confirmed进来,而整条记录只有一句Tool call_00_... exited 1——没有命令、没有错误、没有修复办法。活库里这样的记录有 98 条, 按证据分排序会排在所有真经验之上。按type过滤事件挡不住它们,必须按正文形状挡。
排查「记忆为什么不出现」
两种原因——库里没有和在库里但进不了提示词——从工具调用里看不出来。
插件内(推荐,装完即可用):
/memory-status # 库里有多少、多少条够常驻线、为什么有记录退役了
/memory-preview 继续 # 这一轮实际会注入什么
离线(仓库内,可以对任意库文件跑,不必启动 DSH):
node tools/preview.mjs --db <库路径> --cwd <项目根> --query "继续" --query "WandererProfile"
两者共用 src/census.ts 与 src/digest.ts,所以结论一致。统计里还包含审计轨迹:usage 与 correction 两张表记录每次复用结果和每次纠错,
并列出最近退役的记录及其原因(显式遗忘、连续失败、过期、复核逾期……)。这两张表此前只写不读,
所以「这条为什么掉出池子」在框架里没有答案,只能手工开 SQLite 查。
它加载 lib/ 里的构建产物,所以顺带验证了发布产物与源码行为一致。
工作历史事件不导入:旧运行时把 failure/task/decision/fix 事件和知识记录写在同一流里。事件是观察,不是教训——一条 failure 说明东西坏了,没说下次该怎么做。把它们当经验导入,正是常驻阈值要挡住的那种噪声。
导入前先审计
迁移工具回答「什么能导」,审计工具回答「这些经验是不是对的」——后者必须在前:
node tools/audit-legacy.mjs --root "F:\GPT工作区"
# 产出四份:
# audit/legacy-memory-audit.md 结论:机械验证 + 漏斗 + 注入行为实测
# audit/legacy-memory-recommended.md 建议子集:按项目/主题归类,逐条列出
# audit/legacy-memory-selection.json 建议子集的可执行清单,供 --selection 使用,可直接编辑
# audit/legacy-memory-records.tsv 全部可映射记录的正文全文
能机械验证的部分它真去验证,而不是猜:
| 检查 | 做法 |
|---|---|
| 引用的路径是否还在 | 对每个绝对路径求最长存在前缀:前缀停在分隔符上说明最后一段真的不在;停在段中间说明路径存在、后面粘的是散文 |
| 引用的命令是否还装着 | 只查真实命令行工具名,不把行内代码里的标识符当命令 |
| 记录之间是否矛盾 | 精确重复(按正文身份)、近重复(词元 Jaccard)、同一主题相反极性(要/不要) |
| 质量信号 | 疑问句、占位符、自指(讲记忆机制自身)、过短、无教训 |
| 导进去会不会真的被注入 | 直接调用框架自己的 importance / eligibleForResident,而不是推断 |
本机归档实测:归档树总计 415 条原始记录,其中只有 6 个活库、324 条;其余 19 个库是副本。
这 324 条里 67 条是同库内重复、98 条是伪装成 fact 的工具失败事件,剩下 150 条可映射。
建议导入子集经漏斗收敛到 36 条:只留 confirmed(−73)、只留有过证据的(−39)、
去掉自指的(−0)、同工作区去重(−0)、正文至少 40 字(−2)。
关于这 36 条是什么,需要一个反直觉的结论:它们全部是 fact,没有一条是 experience 或 strategy。
旧库里没有「教训」这一类知识,只有被切块存进记忆的项目规格、边界和状态台账——版本基线、
范围排除项、安全不变量、里程碑退出门、数据来源授权、当时尚未验证的项。它们在各自项目里很有用,
在别的项目里是噪声,所以都是工作区级而非领域级。
⚠️ 两个必须知道的后果:
- 旧运行时没有
lesson和failure_mode字段,所以每条导入记录这两个字段都是空的。常驻行是 「标题 — 教训」,教训为空时回退渲染正文,所以导入的记录以正文形式出现,可执行教训这一层是缺的。 - 它们是
verified-file,而资格线是 5.5、基础分是 6.0,所以够新的导入记录靠年龄自己就能上线; 旧到 60 天以上的,要么查询命中一个标识符、要么被查过/被确认有用才回到线上。所以导入的实际效果是 一个可按需检索的项目知识库,其中较新的那部分还会每轮自动浮现。详见「证据定级」一节。
配置
| 键 | 默认 | 含义 |
|---|---|---|
enabled | true | 整体开关 |
dbPath | $DSH_HOME/experience-memory/memory.db | 数据库位置 |
residentMaxRecords | 5 | 每段条数上限 |
residentMaxBytes | 1536 | 整个摘要(所有段合计)的字节硬上限 |
coreMaxRecords | 2 | 核心层条数上限;0 关闭核心层 |
recallMaxBytes | 16384 | 单次召回字节上限 |
defaultDomain | '' | 固定领域;空则推断 |
maintenanceBatchSize | 32 | 每次维护处理的记录数 |
failStreakLimit | 2 | 连续失败几次退役 |
harvestEnabled | true | 是否在每轮结束时自动采集候选 |
harvestBroad | false | 是否启用实测不可靠的宽判据(宽陈述句、失败后成功、目标变更) |
harvestMaxPerTurn | 1 | 每轮最多采集几条(0 = 关闭采集) |
harvestPoolLimit | 200 | 候选池上限,超了退役最旧的 |
harvestCandidateTtlDays | 14 | 候选多少天没人确认也没被查过就退役 |
precallEnabled | true | 是否在工具调用即将做某件事时,把关于那件事的经验递到它眼前 |
precallMaxPerSession | 20 | 一个会话最多提醒几条(按真正递出去的条数算) |
precallCooldownMinutes | 30 | 同一条记录多少分钟内不重复提醒 |
| failureTracking | true | 是否统计本工作区反复出现的工具失败(只统计:不注入、不写记录) |
| failureShapeLimit | 200 | 每工作区最多留多少种失败形状,超了淘汰最少最旧的 |
非法值在加载期报错并拒绝启动插件,而不是静默降级。允许为 0 的限额只有两个:
coreMaxRecords(0 = 关闭核心层)和 harvestMaxPerTurn(0 = 停止采集),
其余限额为 0 与「关闭」无法区分,所以最小是 1。
Model Experience
每轮的经验摘要
What the model sees
请求组装时,插件渲染最多两段:跨项目印证过的领域级经验(核心层,最多 coreMaxRecords 条),以及以最近
两条用户消息为查询检索到的相关经验(查询层,最多 residentMaxRecords 条)。两段共享同一个 1536 字节硬上限,
所以实际行数通常由字节预算先决定——按默认配置条数上限是 2+5=7 行。每条一行:- [id] 标题 — 教训。
它不是加在系统提示里的。ctx.systemPrompt.context 的贡献由 DSH 合成进「运行时上下文快照」,而该快照是以
一条插件来源的消息(source.kind === 'plugin',plugin 为 dsh-system-prompt,form 为 snapshot)投递给模型的。
这一点不是细节:正因为这段文本和用户说的话走同一条通道,插件的查询推导与证据定级都必须跳过插件来源的消息
(src/digest.ts 与 src/evidence.ts 各有一处),否则摘要会被读回来当成用户的话,同几条记忆会自我强化——Mem0
生产库里 97.8% 是噪声,走的就是这条路。两处跳过逻辑已用真实会话日志验证。
Token effect
摘要硬上限 1536 字节,两段都为空时 0 字节(不产生空段落);核心层不增加上限,只重新分配它。
另有一行无条件出现的经验提示(204 字节,RECORD_HINT),它不在这个 1536 预算内——因为库空时摘要为 0 字节,
而那正是提示必须出现的场合。它的体积由测试钉住上限 256 字节,防止无声膨胀。
KV Cache effect
内容只在命中集合真正变化时才改变,因此对前缀缓存的影响限于变化的轮次。核心层是稳定的,因此对缓存最友好的一段是它。
五个工具
memory_recall / memory_remember / memory_feedback / memory_forget / memory_stats,见上表。
七个斜杠命令
/memory-status / /memory-preview / /memory-maintain / /memory-harvest / /memory-audit / /memory-import / /memory-gaps,见上表。
它们不进入模型上下文,所以对每轮 token 成本没有影响;/memory-preview 的输出就是这一轮真正会被注入的内容。
自动采集:把"模型没想到要记"的东西接住
记不记得住,取决于模型选择调用 memory_remember。这件事在本项目里是量过的:五个真实会话、约 5,900 次工具调用
里,memory_remember 一次都没被调用过,直到有人明确点名。那句无条件的提示把这个缺口收窄了,但结构性的问题还在 ——
模型压根没想到的那条教训,没人接得住。
每轮结束时,采集器读这一轮(不是整份会话),命中五类"值得记的时刻"就存一条候选,按优先级取一条:
| 信号 | 判据 | 存什么 |
|---|---|---|
failure-recovered | 同一轮里某个工具先报错、之后同一工具成功 | 工具名 + 原始错误文本 |
user-correction | 用户否定了上一轮的说法(不对/错了/其实…) | 用户那句原话 |
user-statement | 用户说了明确的持久规则(以后/一律/禁止/never…) | 原话 |
user-statement | 用户说的不是问句、且点到具体东西(标识符/路径/版本/数字/结论词) | 原话 |
goal-changed / action-refused | goal/change;approval/decided 且不是 allowed | 新目标原文 / 被否决这件事 |
它不是判官,只捡原话。 判据认的是"时刻",不是"经验":存下来的是逐字原话加一个机械标题。 把一句话提炼成一条主张是判断,而采集器没有判断 —— 所以它不提炼。
宽的那条才是重点:只认祈使句会漏掉教训最常出现的样子 ——「原来那个 bug 是因为…」「这个 API 在 1.5.2 里不触发…」
「最后发现要加 --preserve-symlinks 才行」。这些都不是命令句。
三条性质让它不会变成这个框架最想避开的那种东西:
- 永远是候选。 采集直接写库,不走
remember,所以永远不会凭空给它一个等级。它由构造决定就是候选, 常驻层不会看它;唯一的转正路径是模型把同一句复述一遍,那时照常过证据门禁。测试里钉的就是这条 —— 用的还是一条 引文本身就是用户原话的采集记录(按普通定级它会被判verified-user),它仍然必须停在候选。 - 什么都不推断。 五条判据读的都是会话已经写下的标记;
origin与harvest_signal记下是哪条触发的,可审计。 - 不花 LLM 调用。 这个插件本来一次都不花。
边界是不变量,不是定量票:没有每日配额(最忙的日子正是学到最多的日子,配额会在最需要时静悄悄用光)。 取而代之:每轮至多 1 条、候选池上限 200(超了退役最旧的)、14 天没被确认也没被查过就退役。 最后那条同时补上一个原有的洞:维护回合过去只扫已确认记录,候选是永生的。
候选怎么被看见 —— 否则采集只是往池子里倒:memory_recall 的返回末尾会带一行
另有 N 条自动采集的候选待确认(只在模型正在看记忆时出现,不占每轮固定开销);/memory-harvest 给人列出来、
可单条退役;memory_stats 报出采集总数/已确认/待确认。
判据是按真实日志钉的,不是按事件注册表。 注册表列了一些这台 harness 从不发出的事件:feedback/record 是已知类型,
而本工作区最忙的那份日志 11,735 个事件里它出现 0 次。那条判据在写之前就被删掉了 —— 建在永不触发的事件上的判据
是一个静默的空操作。
而且判据是拿真实日志标定过的,标定结果直接决定了默认值。 回放本工作区最大的 6 份日志(235 轮):
| 判据 | 235 轮命中 | 抽样看到的东西 | 结论 |
|---|---|---|---|
user-correction | 4 | 「不是实现 bug,是我的期望值错了…」「量化是量化,bigfat 是价值投资」「补一条反例测试:root=None 必须被拒」 | 精度可接受(4 条里 3 条),默认开 |
user-statement(宽) | 105 | 技能目录、Objective: "…"、Round: 5/256、问句、任务请求 | 精度约 5–10%,默认关 |
failure-recovered | 5(加 denylist 前 71) | edit/write 没先读文件、old_string 没找到;剩下的也多是 rg 在 System Volume Information 上崩 | 默认关 |
goal-changed | 23 | 同一段目标文本被反复发出 —— 目标系统本来就已经存着 | 默认关(重复采集) |
所以 harvestBroad 默认 false:默认只跑那条测出来站得住的判据(user-correction,外加不花成本的 action-refused),
产出约 1.7 条 / 100 轮。加过滤之前是 63.8 条 / 100 轮,而里面大部分不是经验。
这不是判据写错了,是规则做不到那件事:要分清"用户陈述了一件持久的事"和"harness 把一大段文本当成用户消息送进来", 那是语义判断;买它就得花一次 LLM 调用,而这个插件一次都不花。所以宽判据留作开关,等精度被量到值得打开再打开。
Known Limitations and Deferred Work
- "反复犯的错"只被统计,不会被自动写成经验。 这是量过之后的选择,不是省略:七天里本机 63 个会话
产生 358 次工具失败,最常见的一类(改文件前没读,143 次 / 5 个会话)错误信息里就写着怎么做
("read the file, then retry"),前两类合计占 178 次——记忆在那类失败上加不进任何信息,重复是手滑
而不是不知道,而且 harness 的编辑工具本身就是那个守卫。
failure-recovered这条判据本仓库标定过 一次并判为噪音(71 命中 → 5 条算数),这次的数据是支持那次判断,不是推翻它。所以这一版只做 两件不冒险的事:把失败按形状记下来(不注入、不写记录),以及把我们自己的报错写成能照做的 (domain那条错误进过 Top-10,10 次 / 3 个会话)。判据与数字见 CHANGELOG。 /memory-gaps的"相关"是关键词重合度,不是语义覆盖。 错误原文是英文、记录多半是中文,中文记录 可能一条都对不上,所以那个分数只会偏低,报告里也这么写。它的用途是让人看见"这件事一直在发生", 不是给出"该记一条"的结论。/memory-gaps里有些行不是错误。 用户打断计划评审、工具被中止、用户取消等待,都会被记成"失败" 形状——它们是用户的动作,不是 agent 的判断失误。这一版刻意不过滤:过滤要靠一张"这不算错"的字面 清单,而本仓库在这类清单上翻过车(一个词之差就绕过去)。代价是报告前几行可能混着这类行;缓解方式是 每一行都带原始报错,读者一眼能认出来。实测数据支持这个取舍:重启后 19 次失败里有 3 次是这一类。- 计数只在"回合结束"时读最近一个回合,实测边界(重启后 19 次 vs 逐回合重数 19 次,完全一致):
重启前就已经在跑的回合不会被记(那一版还没这个功能),从头到尾没停过的会话也不会被记。
一致性检查脚本是
audit/diagnose-counter-gap.mjs,随时可以照原样重跑复核。 - 推迟:按"何时适用"在动手前提醒。 记录里的
trigger字段 59/59 都填了,而且是"什么时候用得上" 的写法,看起来现成可用——实测不可靠:拿"即将调用的工具名出现在某条记录的 trigger 里"当触发条件, 13,198 次调用里会触发 949 次(7.2%),覆盖 62/358 次失败(17%),但最大触发源是grep(623 次触发 只对应 3 次失败,"grep 断言"这种句子被误当成触发器),而真正该触发的是web_fetch(193 次调用 / 49 次失败)。分辨"这条讲的就是用这个工具"还是"顺带提到这个工具"需要语义判断,本插件不做 LLM 调用。 要动它,先满足预注册的判据:在同一个 7 天窗口回放,触发率 ≤2% 的调用且覆盖 ≥15% 的失败,并且 单条记录不得贡献 ≥300 次误触发;按工具的失败率作为门禁(数据来自/memory-gaps那张表)。达不到就 不做——把"想做"写成门槛,比写成待办更不容易被下一个会话当成漏掉的活。 - 类型注解从不被检查。 构建只做剥离,toolchain 里没有
tsc(零构建依赖是刻意的),所以类型不一致不会被任何一步 发现——错注解被原样删掉,运行期行为不受影响,连测试都不会惊动。类型在这里是给人读的文档,不是被验证的契约。 要加门禁就得引入 TypeScript 依赖,与"构建期零依赖"冲突;这是明知的取舍,现在明确写在这里。 - 相关性闸会让"只共享功能词"的相关匹配落空。 常驻层要求命中标识符或共享一个实词,所以一句只含「这个/可以」这类词的
回话不会带出任何记录——即使某条记录确实相关。缓解手段是按需检索:
memory_recall不受这道闸约束。 - 标题比较折叠标点,所以同标题的不同主张可能被一起退役。 这是刻意的弱把手换来的:动作是退役而非删除,
supersededBy与纠错日志都留痕,判断错了可以恢复。 - 那一行经验提示是每轮无条件付费的:204 字节,即使这个工作区永远不记任何东西也照付。这是有意的取舍——
把它做成"有记忆时才出现"会让它在库空时消失,而库空正是它要解决的问题。
RECORD_HINT的长度由测试钉了 256 字节上限;要彻底关掉它,删掉src/index.ts里的那次ctx.systemPrompt.context注册即可(它只贡献文本, 没有别的副作用)。 - 没有语义/向量检索。v1 只有 FTS5 + 标识符精确匹配 + 证据排序;
record.embedding列已预留,加入 RRF 融合时不需要迁移。 - 注入层是查询门控的,因此对话题漂移敏感。查询取自最近两条用户消息,所以用户回一句「继续」时, 查询层会清空。核心层(跨工作区印证过的领域级经验)正是为这个缺口存在的,但它只覆盖被印证过的内容, 工作区级的经验仍会在长任务中途续话时掉线。
node:sqlite仍是实验特性,运行时会打印ExperimentalWarning。DSH 自己的会话全文检索也用它。- 维护单轮最多 32 条,积压时不会自动提速。
- 导入不做跨库印证计数:迁移写入的记录
distinct_workspaces恒为 1,领域晋升要等后续真实观察。 - 不提供图形面板;状态、预览与运维走斜杠命令,配置走插件 config。
- 斜杠命令需要
commands服务。它由dsh-base提供——和tools、systemPrompt是同一个 bundle—— 所以inject声明它并不新增环境约束。但由此推论:任何不含dsh-base的 profile 里本插件不会激活 (这在改动之前就已经成立,tools与systemPrompt同样来自 base)。 - 随包不发
src/和tools/。运行时只需要lib/,而脚本是仓库内工具。这也消除了 「随包脚本 importsrc/*.ts因而在node_modules下跑不起来」那一类缺陷——不是修好它,而是不再发它。 - 不做跨机器同步;数据库是单机文件。
- 真实模型回合跑过一次,但它不在
pnpm verify里。那一次抓到了 12 个套件都抓不到的缺陷:两个读取器 都在读agent.session.events,而这个属性在真实 Session 上不存在——于是生产环境里事件日志恒为空, 逐字引文永远定不到verified-user,检索查询永远是空串,注入层的查询段恒不命中。测试全部手写了那个数组, 所以固化的是假设而不是契约。现在读取统一走src/session.ts(snapshotEvents(),其余为带标签的 兼容分支)。结论:挂载层断言替代不了一次真实回合。跑法见「跑一次真实模型回合」,但它要消耗真实 token, 所以没进自动化。 - 斜杠菜单的浏览器渲染没有自动化。命令的可发现性已经断言过了:测试用的是斜杠菜单读取的同一个 API
(
ctx.commands.list(agent)),检查 7 个命令都在、都有描述、带参数的那几个都声明了参数提示、且按名排序。 剩下未验证的只是「浏览器把这份数据画出来」这一步——而这一步对 in-box 命令与本插件是同一条代码路径。
测试
16 个套件,全部用 DSH 自带 Node 运行,无测试框架:
| 套件 | 覆盖 |
|---|---|
tokenize | CJK 二元组、任意语种词元、标识符折叠键、英文散文不产生标识符 |
rank | 证据等级单调性、失败惩罚、衰减、标识符加成封顶、被查过算作"碰过"所以不再衰减、检索加分封顶且压不过一次成功复用 |
db | 单后端、FK 单一开关、原地更新不丢正文、正文可搜、列权重、旧版本的库重开后补上新列且老数据不丢 |
retrieve | 可见性 fail-closed、分层状态窗口、预算截断、排除计数、核心层只收跨工作区印证过的领域级记录、两段共享字节预算、来源行只陈述一次证据等级且带出处 |
domain | 归一化、四级解析顺序、坏文件不抛异常 |
evidence | 四种等级、疑问句内的同一句话不算断言、路径逃逸拒绝、最近存在目录按"目录在前、带 /"列出并承认截断 |
lifecycle | 候选/定案/晋升/合并/退役/维护游标、身份不含标题、过期窗口两端都生效且过去窗口被拒绝、被复用过的记录不在复核期退役、退役理由按替代者的实际等级生成(未定级的替代者不得声称"有可核实出处")、purge 连印证一起带走,且不带走别的工作区的印证、维护回合修复旧版本留下的无主印证 |
precall | 走真实的工具执行链路(prepare→dispatch→finalize):只有参数里点名了记录里的文件/符号才递、无关调用不递、递的是它正要动的那一次调用、冷却期内不重复递、会话上限封顶、库坏掉时不阻断工具本身 |
import | 字段映射、事件记录不导入、试运行不写、重复导入合并不重复、副本库排除、同库重复写入合并、正文相同标题不同只写一行、工具失败事件按正文形状排除、选择清单只导指定记录且空清单导 0 条 |
failure | 形状归一化(同一错误换文件是同一形状、不同错误不合并、只留首行、数字与引号内容折叠)、用真实会话里 isError 的那一层嵌套读失败、自家 edit 工具的失败必须被计数(教训那条路跳过它、统计这条路不能跳)、按形状累计并记住会话数、关掉就不再写、表有上限、报告按次数过滤、关键词重合度给分而不下"已覆盖"的结论 |
audit | 散文粘连的路径不算缺失、真缺失路径带最长存在前缀、标识符不当命令查、精确/近重复、漏斗每步、注入实测、报告不含过期硬编码数字、空目录不崩 |
census | 状态/证据/作用域分组、只审 confirmed 且恰好卡在常驻线上的那一条、审计轨迹计数、退役原因与「无纠错记录」、渲染 |
commands | 参数解析、5 个命令都注册在真实的 command 服务上、用斜杠菜单读的同一个 list() 断言可发现性(描述、参数提示、排序)、recordInput: false 使运维输入不进会话、预览与状态/维护/审计/导入、导入默认不写入、坏清单报错、模型工具表没有变大 |
plugin | 挂载真实服务、五个工具闭环、memory_stats 的计数与库实际状态一致且只读、同一条主张有无引文导致不同召回结果、查询推导读的是真实 Session 形状(snapshotEvents())而不是不存在的 events 属性、候选默认不可见但可显式复核并带出待复核说明、驱动真实 assemble 断言注入、库空时摘要为空而记录提示仍然注入、跨工作区印证后无关的一轮仍出现、驱动真实 agent/turn-stopping 断言维护执行且失败不破坏回合、工具收到的天数落库为绝对到期时间且 0 天被拒、只读工具自报 call id,引用它的主张一次调用直达 verified-tool、维护回合把 WAL 折回主文件(只拷 memory.db 不再静默过期)、检索被记成"被查过",而自动注入不算被查 |
harvest | 五条判据各自一正一负(尤其"问句不算陈述")、系统包装文本与技能目录不算用户陈述、自家编辑工具的用法失误不算项目教训、一轮只出一条且取最强的那条、宽判据默认不跑而显式开启才跑、采回来的即使引文是用户原话也仍是候选、候选会老化而"被查过"的不老化、候选池超上限时退役最旧的那条 |
docs | README 配置表逐格等于 resolveConfig({})(两个方向都查)、每个配置键都在 cordis.patch.yml 里被重述、只有 coreMaxRecords 允许为 0、注册的工具/命令集恰好是 README 列的那五个、摘要条数是每段各算(默认 2+5=7 行)而不是合计 5 行、审计写四份就报四份、常驻记录提示不超过 256 字节且点名了工具 |
「文档与代码」这一套是刻意的:本仓库出过两次同类事故——一次是 README 说两段摘要合计最多 5 条 (代码是按段各算),一次是四处注释说模型工具表是 4 个(代码注册 5 个)。两次都不是有意说假话, 而是没有任何检查在看这些断言。散文不做解析(改个措辞就误报,且换个说法就漏掉), 只钉能机械核对的那几类:配置默认值、表面名单、摘要上限、审计产出清单, 以及 README 里由代码推导出来的两个数字(摘要行数上限、套件数)。
外加构建产物与打包契约验收(tests/built.mjs,纯 node 不加 flag):每个 lib/*.js 都能导入、
导出名与 src/*.ts 一一对应、lib/index.js 是合法 Cordis 插件、挂载后行为与源码一致、随包命令注册成功,
并且打包契约成立——files 承诺的都在、入口在包内、license 与 LICENSE 齐备、
没有随包模块反向 import src/(这正是「发了跑不起来的东西」那类缺陷)。
tools/verify-install.mjs 再把同一套检查搬到真实 profile 里,验证按名从 node_modules 解析。
一条命令跑完全部:pnpm verify。
开发环境
@deepseek-ai/* 是 peer 依赖,由宿主提供,所以仓库不 vendored 它们。测试要能解析这些包,
node_modules 才指向 DSH 安装里那份扁平符号链接:
# 在仓库根目录执行一次;Node 只会解析 node_modules,不认 dmn 或别名
New-Item -ItemType Junction -Path node_modules `
-Target "$env:APPDATA\dsh-desktop\harness\profiles\node_modules"
这个 junction 已被 .gitignore 忽略。没有它,pnpm verify 会因为解析不到 @deepseek-ai/cordis 而失败
——插件本身不受影响(它的 peer 由宿主提供),受影响的只是开发期测试。
tools/ 下的脚本不在发布包里:它们只是 lib/ 之上的一层薄壳(解析参数 + 打印),
供仓库内使用和脚本化。插件安装后,同样的能力走斜杠命令。