DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

Marquez807 /

Marquez807/dsh-experience-memory

Verified

Cross-session experience memory for DSH: a lesson reaches the model only with a verifiable source, relevant ones are injected each turn, and what nobody uses is retired.

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@8e78d86b

经验记忆(@marquez807/dsh-experience-memory)

简体中文 · English

给 DeepSeek Harness 的分领域长期经验记忆:分得清轻重、攒得下经验、忘得掉过期、纠得了错,并在再次执行同类工作时自动召回相关经验。

  • 每轮固定只花 204 字节:一行提示,除此之外只在真有相关经验时才注入内容。
  • 运行时零第三方依赖,只用 Node 内置能力;存储是单个 SQLite 文件。
  • 五个模型工具、七个斜杠命令,零配置可用。

目录

想做什么 去哪一节
先装上,并确认它真的在工作 快速开始
搞清它靠什么机制记住东西 它做什么
查有哪些工具、哪些斜杠命令、有哪些配置项 工具 · 斜杠命令 · 配置
看它在模型眼里长什么样 模型的体验
知道它做不到什么 已知限制与推迟的事
从旧记忆库搬数据进来 迁移
想改代码、编译、跑测试 docs/DEVELOPING.md

快速开始

装(把路径换成你手上的 tarball):

dsh plugin --profile <name> add /path/to/dsh-experience-memory.tgz

包名是 @marquez807/dsh-experience-memory(带 scope),这是故意的。 npm 上另有一个同名的 dsh-experience-memory 属于别人:桌面版按包名解析依赖,所以只要按名字装,就会装到那一家 (它缺数据库二进制,一加载就崩,整个后台起不来、所有第三方插件停用——2026-09-23 真实发生过)。 带 scope 之后,按名字装只会得到"没有这个包"的明确报错,不会再静默装成别人的东西。 本仓库同时是 private: true:不发 npm,安装只走本仓库 Release 的 tarball。 判断手上是哪一份,看 package.json 里的 repository 是不是 Marquez807/dsh-experience-memory。

这一步就够了。 dsh plugin add 不只是装依赖——它会把 dsh.profile.bundles 与已安装状态对账:任何声明了 dsh.bundle 的依赖都会被自动追加进 layer stack(见 @deepseek-ai/dsh 的 reconcilePlugins)。所以不需要手工编辑 profile 的 package.json。

装完重启应用即可。零配置:不提供任何 config 也能工作——默认库在 $DSH_HOME/experience-memory/memory.db 自动建立,五个工具、七个斜杠命令与常驻注入立即生效。

重启后先看一眼启动日志那一行(这一行是刻意加的,来由见「已知限制」里那次事故):

experience-memory: store <路径> — <记录数> records, <已确认数> confirmed, <带锚点数> anchored

确认 store <路径> 是不是你预期的那个库。"空库"和"开错库"从外面看一模一样(都是"什么都查不到"),所以这一行把路径和条数说明白——库开错了就看得出来,不会静默地什么都不告诉你。三个数字随库变化,多少都不用管;要警觉的是路径不对,或者 0 records。

想在装之前/装完之后确认它是在工作的,用斜杠命令:

/memory-status          # 库里有多少、多少条够常驻线
/memory-preview 部署     # 这一轮实际会注入什么

还有一条只在维护轮次里跑的自检:某条记录引的那个文件如果已经不在了(被删、被改名),维护会把这条记录标上 needs_review 并写明缺的是哪个文件。只标记、不拒绝——文件可能是"以后才创建"的。想现在就跑一遍,用仓库里的 tools/provenance-audit.mjs。

它挂了四个表面

表面 内容 谁触发
自动注入 常驻摘要:核心层(跨项目印证过)+ 查询层,共享 1536 字节;外加一行每轮固定出现的经验提示(204 字节) 无
自动维护 agent/turn-stopping 有界维护,批量 32 条带游标 无
模型工具(5 个) memory_recall / memory_remember / memory_feedback / memory_forget / memory_stats 模型
斜杠命令(7 个) 状态、预览、维护、审计、导入、采集复核、反复失败 人

工具和命令的分工是刻意的:审计与导入会伸到库外面(扫描任意目录、批量写入),所以留在人的触发之后。memory_stats 是唯一给模型的运维视角工具——只读、无参数,用来回答「你记得什么」,或者自查「我记的东西到底有没有送达」。

那一行经验提示为什么必须独立于摘要、且无条件出现:摘要在没有合格记录时渲染空串(不注入),而"库里什么都没有"正是模型最需要被告知"可以记录"的时刻。把它并进摘要,它就会随着记忆一起消失——而库空着这件事会自我维持。这不是推测,是实测:在 5 个真实会话、约 5,900 次工具调用里,记忆工具在装好之后的每一个请求轮次都被提供了,而 memory_remember 一次都没被调用过,直到有人明确点名要求记录。

同一句话现在也用来要求"查"。 见下面「记了不等于有用」:只叫模型记、不叫它查,等于让它一直写、从不读。所以提示的顺序是先查后记——动手前 memory_recall,学到东西 memory_remember,用过 memory_feedback。

它做什么

三个阶段各有一层机制,外加两条把机制串起来的经验:记了不等于有用,以及记了,但没在它动手的那一刻出现。

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 条稳定资料。这里的定级是自动的,因为只有便宜到会真的发生,严格才有意义。

常驻层每轮由 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 记了不等于有用:一个把记忆变成"只写不读"的死循环

这条是用户直接点的题:"记下来的东西不用"。查下来的原因不是 agent 不自觉,是四件事串成了一个闭环:

  1. 想自动出现在提示词里,重要性要 ≥ 6.0;
  2. 一条文件级记忆刚写下正好是 6.0(3.0 × 2.0)——门槛上的刀刃,几小时的陈旧度扣分就把它压到线下;
  3. 想留在线上只能靠复用加分,而它需要有人调 memory_feedback 说"这条帮到我了"——这个动作在整库 76 条的生命周期里只发生过 3 次;
  4. 于是 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 就是"只写不读"的存量;它应该随着会话推进而下降。

2.2 记了,但没在它动手的那一刻出现

这是用户点的第二个题,比"记了不用"更隐蔽:那条记忆真的存在、真的是对的、也真的被注入过, 但偏偏在它该出现的那一轮没出现。

真实例子:一条"启动 Bannerlord 前必须确认 Steam 已登录,否则游戏 10 秒后静默退出"的记录, 有文件级证据,在那个会话的 15 轮里有 9 轮被注入——唯独用户说"开始吧"的那一轮没有。 agent 直接启动,那一轮白跑。

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

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

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

  • 查询里加上"agent 正在做什么":它调工具的参数、它自己写出来的话、它的待办清单。 插件自己注入的消息一律跳过,否则一条提示会把自己喂回下一轮的查询。没有活动时, 拼出来的查询跟以前一字不差——这一点有断言钉着。
  • 在工具调用正要动手时递(precall):由记录自己声明它适用于哪些调用——写记忆时 填 recall_for,取值是三选一的事实:path:<文件名>(这次调用要点名这个文件)、 tool:<工具名>(这次调用就是这个工具)、command:<命令里的词>(命令行里出现这个词)。 调用满足其中一条就递那条记录,一次调用最多一条、最多 300 字节;没声明的记录不会在动手前 出现,只进每轮摘要、只被 memory_recall 搜到。

为什么不再"调用里有什么就跟记录撞什么":那套判据在 15,383 次真实调用上投了 57% 的调用,抽 47 条人工逐条看只有 5 条真的相关(10.6%),而且 68.7% 的投递命中的那个词 只出现在记录的正文 body 里、记录自己讲的规则(trigger/failure_mode/lesson)里根本 没提它。把门槛收紧到能去掉噪声,25 条人工标注场景的召回又掉到个位数——而且最松的配置下也 只有 14 条的正解记录进得了候选,正解压根没被选进来,改排序救不回来。所以这不是调参能救 的:"两个词撞上"推不出"这条经验适用于这次调用"。 完整实测与失败路径见 docs/DELIVERY-GAPS.md 第十二、十三节。

旧机制试过、量过、删掉的三样东西(都是回放说了不行,不是嫌麻烦。留下是为了说明 "为什么不是那样做的"——这三样属于已经被替换掉的那套词匹配):

试过的做法 回放结果
把参数的键名也当抓手(file_path、old_string) 每次编辑都带这些键,444 次调用里 232 次都能命中点什么;中选的不是该看的那条。改成只读值
每轮只准递一条(1 到 6 都试了) 那一轮的名额被"这轮里更早碰到的别的记录"拿走,Steam 那条一条都没递出去过。所以节流只靠"同一条的冷却"和"一个会话的上限",代码里写明了为什么
拿 Bannerlord 当抓手 这个工作区能看到的 17 条记录里 13 条都提到它,命中它等于没命中;而 launch-a-runtime-clean.ps1 只有 2 条、ERC403 只有 1 条——那才是这条经验真正在讲的东西

旧机制在那个真实会话上的效果是 20 条提示,落在 15 轮里的 4 轮(Steam 那条贴在"写启动脚本" 那一次调用上——跟启动游戏同一轮,在动手之前)。这是被替换掉的那套机制的数字,留着是当 对照:新机制的对应口径是「投递率 1.18%、单条记录最大误触发 83 次」(见下文「它到底有没有用」), 两者根本不是同一个量——旧的发得多而无关,新的发得少而都是记录点名过的。

说清楚它做不到什么:它不保证贴在最该看到的那一通调用上。一轮里第一件碰到这件事的动作 会先拿到这个名额,所以"运行"那一通可能反而没有——经验已经在同一轮的对话里了,但它不是 "贴在那一行上"。这是真实取舍,写在这里而不是含糊过去。

3. 之后——遗忘与纠错

  • 退役:用户显式遗忘 / 连续 2 次失败结果 / 已过期 / 复核逾期且从未复用 / 90 天未复用且分数低于阈值
  • 不物理删除:退役可逆,只有 purge=true 才删字节
  • 跨项目晋升:一条经验只留在学到它的工作区,直到两个不同工作区独立报告同一内容,才升为领域级、定案,并成为每轮无条件注入的核心记忆
  • 身份是断言本身,不是标题:标题只是标签(常常是正文的自动摘要),所以两条正文相同、标题不同的记录是同一知识。把标题算进身份会让跨项目印证永远数不上,领域晋升也就永远不会发生
  • 重记会退役被它取代的候选:模型有个稳定习惯——先写一遍没有引文的版本(→ 候选),发现不合格,再用文件引文重写一遍。因为身份是断言,改写后的正文是另一条记录,候选就永远留在库里:不可注入、不可见、也没有任何东西清理它。实测在一个真实库里形成过 3 对这样的重复(占全部记录 43%)。现在写出一条已定级的记录时,会把同工作区、同标题的候选退役,supersededBy 指向新记录并写纠错日志。标题比较折叠标点——库里就有一对只差一对「」,精确比较把它当成了两条不同主张
  • 维护在 agent/turn-stopping 运行,批量 32 条带游标,永不进入检索热路径

3.1 易腐事实:给记录上一道过期窗口

长期记忆如果永远不会过期就是负债——「当前测试命令是 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主工作区 这种名字当领域,会把单个项目的怪癖扩散到所有同名目录。

给某个模式关掉记忆(例如"模型测试模式")

模式(agent preset)自己关不掉这个插件:插件装在 profile 层,模式里的 disabled 只对它自己声明的那几行生效。 所以开关在插件这边,按模式 id 关(disabledPresets,默认空)。被列进去的模式,会话拿到的是:

关掉的东西 为什么
经验摘要注入 这是"记忆"最直接的表现,模型看到它就不再是"裸的"
「先查后记」提示 它是在告诉模型"有记忆可用",测试模式不该被这么提示
动手前提醒 同上,而且它会把历史经验塞进某一次工具调用
候选采集 + 失败统计 不记录:测试会话的回合不该进库
五个 memory_* 工具的行为 被调用时直接拒绝并说明原因(工具表本身由模式自己隐藏,见下)

判定读的是会话头里的 agentPreset,并且会看 agent-preset/selected 事件——所以"先在标准模式、再切到测试模式" 的会话也算测试模式(只看头会漏,只看事件则漏掉所有没切换过的会话,两头都读才对)。

工具表怎么消失:模式里挂一个本地小插件调 tools.restrict({deny})——工具注册表只允许作用域内的限制 (全局限制会把所有 agent 的工具都蒙掉,所以 API 直接拒绝),而模式正好是一个作用域。两道防线是独立的: 配置那层管"不注入不记录",模式那层管"工具表里没有"。

维护仍然会跑:它是库的卫生工作(过期、淘汰),任何会话都看不见它。跳过它只会让"无记忆模式"悄悄让全库停止老化。

工具

工具 作用
memory_recall 按查询检索,上限 16384 字节,超限按序截断并报告。include_candidates 用来复核自己记过但没验证过的断言,include_retired 用来审计已退役的。只在真正交出去的那些记录上记一笔"被查过"(截断掉的尾巴不算),这是"记忆有没有被用"的唯一痕迹
memory_remember 记录一条事实/经验/策略;不提供可验证原文则存为候选。可选 expires_in_days / review_after_days 给易腐事实上一道窗口;可选 recall_for 声明这条记录适用哪些调用,填了才会在动手前递出去(见下)
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 调用当出处,记录于是只能落成候选、永远够不到常驻线;而它在另一次里自己绕到了"引用写进仓库的测试文件"这条可定级路径,只是多花了一轮。

quote 还有一条硬要求:引文本身要说出那条规则(一条规则、一个顺序、一个值、一条报错),不是"作者当时正在读的那一段"。依据就是模型自己的判断——一条讲部署 vault 的主张配上一句"怎么在本地起服务"的引文,它的回答是 "the cited evidence does not match the claim",然后把整条记录丢掉。verified-file 只能证明"这句话在那个文件里",证明不了"这句话讲的就是这个主张";后者是语义判断,本框架刻意不做模型调用。所以退路也写在参数说明里:找不到这样的句子就写 inferred 那一档、并说明缺什么。

recall_for 决定它会不会在动手前递到眼前,取值是三选一的事实:

取值 含义 什么时候用
path:<文件名> 这次调用要点名这个文件 教训是关于某个文件/某类文件的
tool:<工具名> 这次调用就是这个工具 教训是关于怎么用某个工具的
command:<命令里的词> 命令行里出现这个词 教训是关于某条命令的

没填就不会在动手前出现——只进每轮摘要、只被 memory_recall 搜到。这是刻意的取舍,代价与理由见上面「召回时——分清轻重」那一节:不是"填不填"的选择题,是没声明就等于没有动手前这一层。

斜杠命令(给人用,模型看不到)

通过 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),所以它不可能与你实际收到的内容不一致——一个会漂移的预览就没有存在意义。

配置

键 默认 含义
enabled true 整体开关
dbPath $DSH_HOME/experience-memory/memory.db 数据库位置
residentMaxRecords 5 每段条数上限
residentMaxBytes 1536 整个摘要(所有段合计)的字节硬上限
coreMaxRecords 2 核心层条数上限;0 关闭核心层
standingMaxRecords 3 常驻规矩条数上限;0 关闭常驻层
standingMaxBytes 768 常驻规矩那一段自己的字节上限(整份摘要仍受 residentMaxBytes 约束)。按实测每行最多 240 字节、标签 49 字节,这一段装得下约 3 条短规矩或 2 条长规矩
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 每工作区最多留多少种失败形状,超了淘汰最少最旧的
disabledPresets [] 哪些模式完全没有记忆(按模式的 id 填)。列进去的模式:不注入、不提醒、不采集、不统计,工具被调用时直接拒绝
anchorCostTable true 声明锚点时先查它"有多贵":一个锚点如果在真实调用里命中太多次,丢掉它并告诉你(记录照写,摘要与检索不变)。防的是"一条记录吃掉整个工作区的提示预算"
anchorCostMaxHits 300 命中多少次算"太常见"。默认与预注册的单记录门槛同值(<300),一个数字两处必须一致
effectWeight 0 实测出来的"删除效果"(删掉这条记录、结果变不变)在排名里占多大权重。默认 0=一点都不占:效果照记、照显示,但不改排名。只有 docs/GROWTH.md G5 的对照实验通过(同字节预算下按决策损失保留比按复用次数保留赢 ≥5 个百分点)才该改它
decisionLossRetirement false 是否允许"实测证明不影响结果"的记录因此退役。默认关;且只有真的测过(effect 不为空)的记录才可能被这条规则退休——没测过不等于没用
guardHints true 记录写的是不可撤销的动作(删库/清空/覆盖)时,memory_remember 的返回里附上一条由框架算出来的提示:真库路径是哪个、可丢弃范围在哪,好让规则写成"默认拒绝 + 永不动这个文件",而不是写成"路径含某个词才放行"这种会被真库路径自己满足的清单。只提议、绝不写入——记录正文一字不改

非法值在加载期报错并拒绝启动插件,而不是静默降级。允许为 0 的限额只有三个: coreMaxRecords(0 = 关闭核心层)、standingMaxRecords(0 = 关闭常驻层)和 harvestMaxPerTurn(0 = 停止采集), 其余限额为 0 与「关闭」无法区分,所以最小是 1。

模型的体验(Model Experience)

每轮的经验摘要

请求组装时,插件渲染最多三段:常驻规矩(写记录时标了 standing 的那些,最多 standingMaxRecords 条, 不管这一轮在聊什么都会出现;它自己还有 standingMaxBytes 的字节上限)、跨项目印证过的领域级经验 (核心层,最多 coreMaxRecords 条),以及以最近两条用户消息为查询检索到的相关经验 (查询层,最多 residentMaxRecords 条)。三段共享同一个 1536 字节硬上限, 所以实际行数通常由字节预算先决定——按默认配置条数上限是 3+2+5=10 行。每条一行:- [id] 标题 — 教训。 常驻层被它自己的字节上限挡住时,摘要里会**写明"另有 N 条常驻规矩未列出"**并把该调哪个配置说出来——这一层承诺的是"每轮都在",所以不能悄悄少一条。

它不是加在系统提示里的。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

内容只在命中集合真正变化时才改变,因此对前缀缓存的影响限于变化的轮次。核心层是稳定的,因此对缓存最友好的一段是它。

它到底有没有用:两次对照实验

前面各节讲的是机制。这一节只回答一个问题:它有没有真的防住错。

做法是两组各跑 N 次真实模型回合:同一个仓库、同一个任务,一组库里有那条经验、一组没有。 结论只看产物(文件内容、文件位置),不看模型自己说了什么。

场景一:配置里该写什么(docs/DELIVERY-GAPS.md 第十九、二十一节)

一条只在用户说过的那句话里的约定:部署配置必须先写 vault 路径、再写 namespace, 顺序不能反。仓库任何文件都没有这件事。

完全正确 95% 区间
无记忆 0 / 18 0.0% – 17.6%
有记忆 14 / 18 54.8% – 91.0%

Fisher 精确检验 p = 0.000002。 拆开是单轮 0/12 对 9/12(p = 0.0003)、跨会话 0/6 对 5/6 (p = 0.0152——第一个会话听到并自己记下,第二个会话用上)。

场景二:文件该放哪(同一文档第二十二节)

约定换成位置:示例配置一律放 conf/samples/。仓库里连 conf/ 目录都没有。

放对位置 95% 区间
无记忆 0 / 6 0.0% – 39.0%
有记忆 6 / 6 61.0% – 100.0%

Fisher 精确检验 p = 0.0022。

两场景合计:无记忆 0 / 24,有记忆 20 / 24(p 远小于百万分之一)。

最要紧的细节:无记忆那 24 次不是没动手——它们几乎每一次都写了文件,只是内容错、位置错。 场景一无记忆组写出了 695–1751 字节像模像样的部署配置,18/18 都错;场景二无记忆组 6/6 都写了 文件,6/6 都放进 config/(模型自己对"示例配置放哪"的默认猜测)。所以差别不是"写不写", 是"写得对不对、放得对不对"。

适用范围(说死了):只有"用户说过、文件里查不到"那类知识有这个效果。仓库里写了的, 模型自己会读、记忆不需要;谁都没说过的,库里没有,也不该有。所以这个框架真正的价值是在 没有第二处可查的那些事上——这也是它和"查文档"的根本区别。

自动采集:把"模型没想到要记"的东西接住

记不记得住,取决于模型选择调用 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 才行」。这些都不是命令句。

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

  1. 永远是候选。 采集直接写库,不走 remember,所以永远不会凭空给它一个等级。它由构造决定就是候选, 常驻层不会看它;唯一的转正路径是模型把同一句复述一遍,那时照常过证据门禁。测试里钉的就是这条 —— 用的还是一条 引文本身就是用户原话的采集记录(按普通定级它会被判 verified-user),它仍然必须停在候选。
  2. 什么都不推断。 五条判据读的都是会话已经写下的标记;origin 与 harvest_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 没找到;剩下的也多是 rg 在 System Volume Information 上崩 默认关
goal-changed 23 同一段目标文本被反复发出 —— 目标系统本来就已经存着 默认关(重复采集)

所以 harvestBroad 默认 false:默认只跑那条测出来站得住的判据(user-correction,外加不花成本的 action-refused), 产出约 1.7 条 / 100 轮。加过滤之前是 63.8 条 / 100 轮,而里面大部分不是经验。

这不是判据写错了,是规则做不到那件事:要分清"用户陈述了一件持久的事"和"harness 把一大段文本当成用户消息送进来", 那是语义判断;买它就得花一次 LLM 调用,而这个插件一次都不花。所以宽判据留作开关,等精度被量到值得打开再打开。

迁移

命令行(仓库内,适合脚本化):

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 过滤事件挡不住它们,必须按正文形状挡。

工作历史事件不导入:旧运行时把 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。 旧库里没有「教训」这一类知识,只有被切块存进记忆的项目规格、边界和状态台账——版本基线、 范围排除项、安全不变量、里程碑退出门、数据来源授权、当时尚未验证的项。它们在各自项目里很有用, 在别的项目里是噪声,所以都是工作区级而非领域级。

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

  1. 旧运行时没有 lesson 和 failure_mode 字段,所以每条导入记录这两个字段都是空的。常驻行是 「标题 — 教训」,教训为空时回退渲染正文,所以导入的记录以正文形式出现,可执行教训这一层是缺的。
  2. 它们是 verified-file,而资格线是 5.5、基础分是 6.0,所以够新的导入记录靠年龄自己就能上线; 旧到 60 天以上的,要么查询命中一个标识符、要么被查过/被确认有用才回到线上。所以导入的实际效果是 一个可按需检索的项目知识库,其中较新的那部分还会每轮自动浮现。详见「证据定级」一节。

排查「记忆为什么不出现」

两种原因——库里没有和在库里但进不了提示词——从工具调用里看不出来。

插件内(推荐,装完即可用):

/memory-status              # 库里有多少、多少条够常驻线、为什么有记录退役了
/memory-preview 继续        # 这一轮实际会注入什么

离线(仓库内,可以对任意库文件跑,不必启动 DSH):

node tools/preview.mjs --db <库路径> --cwd <项目根> --query "继续" --query "WandererProfile"

两者共用 src/census.ts 与 src/digest.ts,所以结论一致。统计里还包含审计轨迹:usage 与 correction 两张表记录每次复用结果和每次纠错, 并列出最近退役的记录及其原因(显式遗忘、连续失败、过期、复核逾期……)。这两张表此前只写不读, 所以「这条为什么掉出池子」在框架里没有答案,只能手工开 SQLite 查。

它加载 lib/ 里的构建产物,所以顺带验证了发布产物与源码行为一致。

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 会指出"哪条记录写了却没挡住"。 判定要同时满足三条:关键词全中(且至少两个词, 一个词的重合是巧合)、记录比这些重复早(一小时宽限,不然新写的记录会被下一次手滑冤枉)、写完 之后又犯了至少 3 次。测得的例子:那条"本机抓不了网页"的经验写于 21:05,之前 web_fetch 三种 失败每小时 0.54/0.34/0.14 次,之后 0.00/0.12/0.00——这是"经验挡住了错误"目前唯一的硬证据。 要复算随时可以跑 audit/verify-prevention-before-after.mjs。

  • /memory-gaps 里有些行不是错误。 用户打断计划评审、工具被中止、用户取消等待,都会被记成"失败" 形状——它们是用户的动作,不是 agent 的判断失误。这一版刻意不过滤:过滤要靠一张"这不算错"的字面 清单,而本仓库在这类清单上翻过车(一个词之差就绕过去)。代价是报告前几行可能混着这类行;缓解方式是 每一行都带原始报错,读者一眼能认出来。实测数据支持这个取舍:重启后 19 次失败里有 3 次是这一类。

  • 计数只在"回合结束"时读最近一个回合,实测边界(重启后 19 次 vs 逐回合重数 19 次,完全一致): 重启前就已经在跑的回合不会被记(那一版还没这个功能),从头到尾没停过的会话也不会被记。 一致性检查脚本是 audit/diagnose-counter-gap.mjs,随时可以照原样重跑复核。

  • 动手前提醒:已经做了,但用的是"记录自己声明适用哪次调用",不是"照着 trigger 字段猜"。 早先推迟这条是因为那条路实测不可靠:拿"即将调用的工具名出现在某条记录的 trigger 里"当触发条件, 13,198 次调用里会触发 949 次(7.2%),最大触发源是 grep(623 次触发只对应 3 次失败——"grep 断言" 这种句子被误当成触发器),而真正该触发的 web_fetch 反而被淹没。原因不是调参:分辨"这条讲的就是 用这个工具"和"顺带提到这个工具"需要语义判断,本插件不做模型调用;"两个词撞上"推不出"这条经验适用 于这次调用"。所以改成写记忆时用 recall_for 声明(path: / tool: / command:),没声明就 不在动手前出现。预注册的四条判据是这么结的:

    判据 结果
    触发率 ≤2% 的调用 1.18%(同一份 15,383 次调用回放)
    单条记录误触发 <300 83 次(工作区里有三个 tools.js;按文件名锚会撞 508 次,改成相对路径后 83)
    不增加每轮固定开销 没变,204 字节照旧
    覆盖 ≥15% 的失败 撤掉,理由见下面那条

    代价与边界:动手前这一层只对"用户说过、文件里查不到"的知识实证有效(见下文「它到底有没有用」); 库里声明了锚点的永远是少数,而且这个数随库变化(2026-09-25 在本工作区实测:可投递 234 条,自己声明锚点的 70 条,动手前静默的 107 条;在库所属工作区的根目录跑 node dsh-experience-memory/tools/anchors.mjs 可重测),其余动手前静默——多数讲的是"讨论某项目时"这类没有 文件可锚的事,得人工补 tool: / command: 锚点,这一步没有自动化——tools/backfill-anchors.mjs 只从记录的出处推断 path: 锚点,tool: / command: 仍然要人写。

  • "覆盖 ≥15% 的失败"这条判据已撤,换成它本来想表达的那句话:"这条经验写下之后,同类事件还犯不犯?" 撤的理由是实测出来的,不是嫌麻烦:442 次工具失败里 83% 是工具自己拒绝、并在报错里写着下一步怎么做 (file has not been read 一类占 49%),没有任何记忆能预防它;而分子需要一个语义判断 ("这条记录本该拦住这次失败吗"),本框架刻意不做模型调用——连词共现代理都会把"数据源独立性纪律" 算成"工具调用被中止"的相关记录,同一个病在上一层复发。继续追的唯一达标办法是把"改文件前先读"挂到 edit 上(占 28.3% 的调用,超触发预算 14 倍),那是作弊而不是覆盖。换成的新问题是能答的: failure_shape 按形状计数并保留发生时间、delivery 记下送过什么、tools/prevention-ledger.mjs 出四分类账。实测与撤除依据见 docs/DELIVERY-GAPS.md 第五节 (含 2026-09-23 13:00 的撤除条)、第十五、二十节。

  • 类型注解从不被检查。 构建只做剥离,toolchain 里没有 tsc(零构建依赖是刻意的),所以类型不一致不会被任何一步 发现——错注解被原样删掉,运行期行为不受影响,连测试都不会惊动。类型在这里是给人读的文档,不是被验证的契约。 要加门禁就得引入 TypeScript 依赖,与"构建期零依赖"冲突;这是明知的取舍,现在明确写在这里。

  • 相关性闸会让"只共享功能词"的相关匹配落空。 常驻层要求命中标识符或共享一个实词,所以一句只含「这个/可以」这类词的 回话不会带出任何记录——即使某条记录确实相关。缓解手段是按需检索:memory_recall 不受这道闸约束。

  • 标题比较折叠标点,所以同标题的不同主张可能被一起退役。 这是刻意的弱把手换来的:动作是退役而非删除, supersededBy 与纠错日志都留痕,判断错了可以恢复。

  • 那一行经验提示是每轮无条件付费的:204 字节,即使这个工作区永远不记任何东西也照付。这是有意的取舍—— 把它做成"有记忆时才出现"会让它在库空时消失,而库空正是它要解决的问题。RECORD_HINT 的长度由测试钉了 256 字节上限;要彻底关掉它,删掉 src/index.ts 里的那次 ctx.systemPrompt.context 注册即可(它只贡献文本, 没有别的副作用)。

  • 动手前把经验递到眼前,要求记录自己声明"适用哪次调用"(path: 文件名 / tool: 工具名 / command: 命令里的词, 写记忆时填 recall_for)。没声明的记录不会在动手前出现,只进每轮摘要、只被 memory_recall 搜到。 这是一次实测后的取舍:旧做法靠"调用与记录撞上同一个词"来猜,在 15,383 次真实调用上对 57% 的调用都发了提示, 抽 47 条人工看只有 5 条真的相关(10.6%);把门槛调紧到能去掉噪声,召回又掉到个位数。判据为什么改、实测数字、 人工抽查与失败路径,见 docs/DELIVERY-GAPS.md;node tools/anchors.mjs 能看当前库的覆盖率。

  • 没有语义/向量检索。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/,而脚本是仓库内工具。这也消除了 「随包脚本 import src/*.ts 因而在 node_modules 下跑不起来」那一类缺陷——不是修好它,而是不再发它。

  • 不做跨机器同步;数据库是单机文件。

  • 真实模型回合跑过三次独立实验,但它们都不在 pnpm verify 里。每一次都抓到了套件抓不到的东西:

    1. 挂载层对不上真实 Session:两个读取器都在读 agent.session.events,而这个属性在真实 Session 上不存在——于是生产环境里事件日志恒为空,逐字引文永远定不到 verified-user, 检索查询永远是空串,注入层的查询段恒不命中。测试全部手写了那个数组,固化的是假设而不是 契约。现在读取统一走 src/session.ts(snapshotEvents(),其余为带标签的兼容分支)。 结论:挂载层断言替代不了一次真实回合。
    2. 新旧两版投递判据的对照:无记忆 0/12 对 有记忆 9/12(p = 0.0003),跨会话 0/6 对 5/6 (p = 0.0152)。见上文「它到底有没有用」。
    3. 换一类知识再复现一次:约定从"配置写什么"换成"文件放哪",无记忆 0/6 对 有记忆 6/6 (p = 0.0022)。同一节有合并数:两场景合计 0/24 对 20/24。

    跑法见 docs/DEVELOPING.md 的「跑一次真实模型回合」与 tools/verified-user-ab/README.md, 但它们要消耗真实 token,所以没进自动化。

  • 斜杠菜单的浏览器渲染没有自动化。命令的可发现性已经断言过了:测试用的是斜杠菜单读取的同一个 API (ctx.commands.list(agent)),检查 7 个命令都在、都有描述、带参数的那几个都声明了参数提示、且按名排序。 剩下未验证的只是「浏览器把这份数据画出来」这一步——而这一步对 in-box 命令与本插件是同一条代码路径。

关于这份文档

当前版本 0.5.0(2026-09-25)。 完整版本记录(每一版改了什么、为什么改、实测数字)见 CHANGELOG.md;0.3.0 的关键变化是写入时把命中过宽的锚点丢掉并写明理由(外加把"这条经验有没有改变结果"记进 effect 字段,默认不参与排序);0.2.0 的关键变化是投递判据换成"记录自己声明 recall_for", 这是行为变更:没声明的记录不再在动手前打断工具调用。

  • README 里的数字是机器核对的,不是手抄的。 tests/docs.test.ts 逐格比对配置表的默认值、注册的工具与命令名单、摘要行数上限(2+5=7)、测试套件数、审计产出清单,以及那一行提示的字节数;对不上测试就红。改文档和改代码是同一件事。
  • 对外讲过的每一句硬话都登记在 docs/CLAIMS.json:一条一行,写明状态与凭证。measured 必须指向仓库里真实存在的检查或产物(tests/claims.test.ts 逐个确认文件在不在);没跑的东西只能写 not-run,而且不许带凭证;已经讲出去、但仓库里没有可复跑凭证的,如实登记成 readme-only——那是待补的债,不是合格状态。
  • 测试按标题逐字定位。 被钉住的标题是 ## Known Limitations and Deferred Work、## 配置、## 模型的体验(Model Experience)、### 它挂了四个表面、#### Token effect——重命名它们要同时改测试,否则整套检查会找不到锚点而失败(失败,不是静默跳过)。细节见 docs/DEVELOPING.md 的「文档与代码对齐」。
  • 一共 23 个套件,一条命令跑完全部:pnpm verify。各套件覆盖什么,见 docs/DEVELOPING.md 的「测试」。
  • 构建、打包、启动验收、测试清单与开发环境在 docs/DEVELOPING.md。
—/ 5

No ratings yet

Verified DSH bundle

Commit 8e78d86b53df

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