DeepSeek Harness 插件

Marquez807/dsh-experience-memory

Star 数 ★ 0 分类 记忆 收录于 2026-09-21

跨会话经验记忆:只有带可核实出处的经验才会到达模型,相关的内容每轮注入,没人用的会被退役。

安装

# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)

dsh plugin --profile web add github:Marquez807/dsh-experience-memory

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWEDERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yamlallowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。

README

给 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/dshreconcilePlugins)。所以不需要手工编辑 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:modulestripTypeScriptTypes,因此构建期也是零依赖,不需要 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 逐名比对 srclib)。要加类型门禁就得引入 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:(这正是它存在的意义),发布与验收一律用 tarballlink: 下要注意两点: ① 回路是「改 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-internalsnode_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-statusmemory_stats,不要去 grep 日志。

与仓库里同一份构建的哈希一致,才说明"重启后生效的是这一版";两个会话的 id 相同,说明它们跑的是同一份代码。

唯一锚点是构建标识,不是逐文件 sha256 表。 这条是被一个校验方推着我改的:他按回执里的 10 行 sha256 表逐文件比对,得到 7/10 一致、3/10 不一致 —— 原因只是我在他验收之后又发了一版。他的论证比我原来的做法对,所以照办:

构建标识为唯一锚点,停止维护逐文件表。它是对已加载的编译模块算的哈希,比"磁盘上某些文件"更贴近"进程真正跑的是什么"。逐文件表是冗余的,而冗余的快照就是过期源

因此:对外校验只给 插件构建 <id>(<n> 个模块)。逐文件 sha256 仍然可以算,但只在需要 diff 一个检出、找出哪几个文件不同时才用,而且必须连同它所属的构建标识一起给出 —— 一张没有标注版本的哈希表,读到的人第一件事是怀疑"是不是被换了",那是它自己制造的成本。

只读工具会报出自己的 call id

memory_statsmemory_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 声明的 toolssystemPrompt真实 base 合成树里都解析到了(这一点 --dump-config 抓不到——inject 依赖缺失时 插件只是静默不激活),以及 apply() 跑完并建好了 schema。

该 profile 的 bundle 列表里没有 app,所以它只挂载、不提供服务;确认库文件出现后结束进程即可。

跑一次真实模型回合

挂载层断言证明不了模型实际看到了什么。要跑真实回合、又不污染正式库、也不起服务器,用一次性的 @deepseek-ai/dsh-headless app 配一个临时 profile:

  1. 临时 profile 的 bundles = @deepseek-ai/dsh-base + @deepseek-ai/dsh-headless + 本插件。 它必须同时带 pnpm-workspace.yamlnodeLinker: hoistedautoInstallPeers: false),否则 pnpm 会去 公共 registry 找 @deepseek-ai/*,而那些是 in-box 包、根本没发布,安装以 404 失败。
  2. 再打一个只改 dbPath 的 overlay,指向一个一次性库——正式库由正在运行的应用持有。
  3. 给模型布置一个只可能来自记忆的任务:先往一次性库写一条在任何文件、任何环境里都搜不到的断言 (例如一个自造的部署代号),再在另一个新回合里问它这个代号是什么。

判据不是「答对了」——模型可能瞎猜。判据是答对、且工具调用数为 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,逐字契约没有被软化; 以及每次判定都带 routetool-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 不自觉,是四件事串成了一个闭环:

  1. 想自动出现在提示词里,重要性要 ≥ 6.0;
  2. 一条文件级记忆刚写下正好是 6.03.0 × 2.0)——门槛上的刀刃,几小时的陈旧度扣分就把它压到线下;
  3. 想留在线上只能靠复用加分,而它需要有人调 memory_feedback 说"这条帮到我了"——这个动作在整库 76 条的生命周期里只发生过 3 次
  4. 于是 76 条里只剩 2 条在自动层,其余只能靠模型主动 memory_recall 去查;而那句无条件的提示只叫它记,从没叫它查。更糟的是:"查"这个动作根本没被记录,所以就算某条被后来的会话翻出来用了,它得到的收益是零 —— 下次照样沉默。

四处都修了:

改动 效果
memory_recall 现在记录"这条被查过"(retrieve_count / last_retrieved_at 检索第一次留下痕迹,「记了有没有被用」这个问题终于答得出来
被查过也算"碰过":陈旧度的锚点取 max(建库时间, 上次被用, 上次被查) 一条后来被翻出来用的记忆不再按"没人理过"衰减,它会自己爬回自动层 —— 死循环断开
检索加分封顶 1.00.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 直接启动,那一轮白跑。

原因有两个,都不是"记忆坏了",是"递送方式不对":

  1. 用来找记忆的那句话,只有用户说的话。 用户回一句"开始吧",这几个字里没有任何东西能命中 "Steam"或"启动"。于是摘要层在长任务进行中恰好清空,而 agent 手头正在做的事,一个字都没进查询。
  2. 只有"每轮开头"这一个递送时机,而这个时机由用户的话决定,不由 agent 在做的事决定。

改了两处,都拿那个会话的真实日志(444 次工具调用)量过,不是推出来的:

  • 查询里加上"agent 正在做什么":它调工具的参数、它自己写出来的话、它的待办清单。 插件自己注入的消息一律跳过,否则一条提示会把自己喂回下一轮的查询。没有活动时, 拼出来的查询跟以前一字不差——这一点有断言钉着。
  • 在工具调用正要动手时递(precall:一次调用本身就在点名——它要跑的脚本、要改的文件、 要找的符号。匹配只读参数的,抽出路径、文件名、符号、开关这类"抓手"; 如果某条已确认的记录提到了其中一个能区分开的抓手(能看到这个工作区的记录里, 提到它的不超过 2 条),就把那条记录贴着这次调用递上去,一次调用最多一条、最多 300 字节。

试过、量过、删掉的三样东西(都是回放说了不行,不是嫌麻烦):

试过的做法 回放结果
把参数的键名也当抓手(file_pathold_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)仍从未被复用,才退役

两条规则的分工是刻意的:过期的事实不该被回答,但「需要复核」不等于「已经错了」。而且被复用过的记录不会 因复核逾期退役——复核窗口是用来发现没人需要的东西,不是用来惩罚年龄的。

用同一条断言再报一次是重新验证:新窗口替换旧窗口,而不是被忽略。

在加上这两个参数之前,expiresAtreviewAfter 只有旧数据导入器会填,所以三条退役路径里有两条 对插件自己记录的记录永远不可达——机制齐全但没人能启动它。

作用域

作用域 谁看得见
workspace 只有解析出同一根路径的工作区
domain 任何解析出同一领域的工作区

领域解析顺序(先命中先用):插件配置 defaultDomain → 工作区 .dsh/memory.ymldomain:package.jsonname → 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.jsonlmemory.sqlite3 里各存一份),时间戳不同不算新知识。
  • 工具失败事件不导入,哪怕它的 type 是 fact。旧运行时在工具调用失败时写的是 type: factadmission.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.tssrc/digest.ts,所以结论一致。统计里还包含审计轨迹usagecorrection 两张表记录每次复用结果和每次纠错, 并列出最近退役的记录及其原因(显式遗忘、连续失败、过期、复核逾期……)。这两张表此前只写不读, 所以「这条为什么掉出池子」在框架里没有答案,只能手工开 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,没有一条是 experiencestrategy 旧库里没有「教训」这一类知识,只有被切块存进记忆的项目规格、边界和状态台账——版本基线、 范围排除项、安全不变量、里程碑退出门、数据来源授权、当时尚未验证的项。它们在各自项目里很有用, 在别的项目里是噪声,所以都是工作区级而非领域级。

⚠️ 两个必须知道的后果:

  1. 旧运行时没有 lessonfailure_mode 字段,所以每条导入记录这两个字段都是空的。常驻行是 「标题 — 教训」,教训为空时回退渲染正文,所以导入的记录以正文形式出现,可执行教训这一层是缺的。
  2. 它们是 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.tssrc/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/changeapproval/decided 且不是 allowed 新目标原文 / 被否决这件事

它不是判官,只捡原话。 判据认的是"时刻",不是"经验":存下来的是逐字原话加一个机械标题。 把一句话提炼成一条主张是判断,而采集器没有判断 —— 所以它不提炼。

宽的那条才是重点:只认祈使句会漏掉教训最常出现的样子 ——「原来那个 bug 是因为…」「这个 API 在 1.5.2 里不触发…」 「最后发现要加 --preserve-symlinks 才行」。这些都不是命令句。

三条性质让它不会变成这个框架最想避开的那种东西:

  1. 永远是候选。 采集直接写库,不走 remember,所以永远不会凭空给它一个等级。它由构造决定就是候选, 常驻层不会看它;唯一的转正路径是模型把同一句复述一遍,那时照常过证据门禁。测试里钉的就是这条 —— 用的还是一条 引文本身就是用户原话的采集记录(按普通定级它会被判 verified-user),它仍然必须停在候选。
  2. 什么都不推断。 五条判据读的都是会话已经写下的标记;originharvest_signal 记下是哪条触发的,可审计。
  3. 不花 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 没找到;剩下的也多是 rgSystem 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 提供——和 toolssystemPrompt 是同一个 bundle—— 所以 inject 声明它并不新增环境约束。但由此推论:任何不含 dsh-base 的 profile 里本插件不会激活 (这在改动之前就已经成立,toolssystemPrompt 同样来自 base)。
  • 随包不发 src/tools/。运行时只需要 lib/,而脚本是仓库内工具。这也消除了 「随包脚本 import src/*.ts 因而在 node_modules 下跑不起来」那一类缺陷——不是修好它,而是不再发它。
  • 不做跨机器同步;数据库是单机文件。
  • 真实模型回合跑过一次,但它不在 pnpm verify。那一次抓到了 12 个套件都抓不到的缺陷:两个读取器 都在读 agent.session.events,而这个属性在真实 Session 上不存在——于是生产环境里事件日志恒为空, 逐字引文永远定不到 verified-user,检索查询永远是空串,注入层的查询段恒不命中。测试全部手写了那个数组, 所以固化的是假设而不是契约。现在读取统一走 src/session.tssnapshotEvents(),其余为带标签的 兼容分支)。结论:挂载层断言替代不了一次真实回合。跑法见「跑一次真实模型回合」,但它要消耗真实 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 真实的工具执行链路preparedispatchfinalize):只有参数里点名了记录里的文件/符号才递、无关调用不递、递的是它正要动的那一次调用、冷却期内不重复递、会话上限封顶、库坏掉时不阻断工具本身
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 承诺的都在、入口在包内、licenseLICENSE 齐备、 没有随包模块反向 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/ 之上的一层薄壳(解析参数 + 打印), 供仓库内使用和脚本化。插件安装后,同样的能力走斜杠命令。

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。