- Startseite
- Plugins
- Gedächtnis
- dsh-experience-memory
dsh-experience-memory
marquez807/dsh-experience-memory
Sitzungsübergreifendes Erfahrungswissen für DSH. Lektionen erhalten Evidenzstufen und werden bei gleichem Thema eingefügt; direkt vor einer Aktion nur, wenn der Datensatz den passenden Aufruf nennt. Themenunabhängige Regeln wie „immer auf Chinesisch antworten“ können als standing markiert werden und gelten dann in jedem Turn. Messung mit 24 isolierten Realmodell-Turns: 6/6 Befolgung gegenüber 0/18 in Kontrollen. Vermutungen werden nicht dauerhaft: standing verlangt ebenfalls verifizierte Belege. Ungenutzte Einträge werden reversibel stillgelegt. Fünf Tools, sieben Slash-Befehle, keine Runtime-Abhängigkeiten.
Installation
dsh plugin --profile web add github:marquez807/dsh-experience-memoryREADME
经验记忆(@marquez807/dsh-experience-memory)
给 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 × 标识符精确命中数) ← 封顶
排序键按层不同:有查询的两层(按需召回、每轮注入)用 bm25 ASC, 重要性 DESC, id ASC——先由相关性定序,资历只在同样相关时破平;无查询的两层(核心、常驻规矩)没有相关性信号,仍用 重要性 DESC, bm25 ASC, id ASC。旧系统把常驻 8 条按 uuid4 字符串排序,等价于随机抽样且永久冻结——库里 100 条时新记忆进入概览的概率只有 8%。
⚠️ 2026-10-11 修正(0.5.4):此前所有层都用 重要性 DESC, bm25 ASC,而重要性是浮点数、两条记录几乎不可能并列 ⇒ BM25 实际上从不生效,排序完全由资历决定。刚写下的 verified-file 记录正好 6.0 分,被复用过的记录在 10–17.8 分 ⇒ 新写的教训在任何查询里都垫底。实测(量化会话工单):4 条当天新写、已确认、可见、且能被检索命中的记录,5 组查询(其中两组逐字引用其正文)一条都召不回,真实排名在第 39–104 位。改成相关性优先后,同样 5 组查询里它们回到第 1–3 名;同一批老记录不再霸占每次结果。
2.1 记了不等于有用:一个把记忆变成"只写不读"的死循环
这条是用户直接点的题:"记下来的东西不用"。查下来的原因不是 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 就是"只写不读"的存量;它应该随着会话推进而下降。
2.2 记了,但没在它动手的那一刻出现
这是用户点的第二个题,比"记了不用"更隐蔽:那条记忆真的存在、真的是对的、也真的被注入过, 但偏偏在它该出现的那一轮没出现。
真实例子:一条"启动 Bannerlord 前必须确认 Steam 已登录,否则游戏 10 秒后静默退出"的记录, 有文件级证据,在那个会话的 15 轮里有 9 轮被注入——唯独用户说"开始吧"的那一轮没有。 agent 直接启动,那一轮白跑。
原因有两个,都不是"记忆坏了",是"递送方式不对":
- 用来找记忆的那句话,只有用户说的话。 用户回一句"开始吧",这几个字里没有任何东西能命中 "Steam"或"启动"。于是摘要层在长任务进行中恰好清空,而 agent 手头正在做的事,一个字都没进查询。
- 只有"每轮开头"这一个递送时机,而这个时机由用户的话决定,不由 agent 在做的事决定。
改了两处,都拿那个会话的真实日志(444 次工具调用)量过,不是推出来的:
- 查询里加上"agent 正在做什么":它调工具的参数、它自己写出来的话、它的待办清单。 插件自己注入的消息一律跳过,否则一条提示会把自己喂回下一轮的查询。没有活动时, 拼出来的查询跟以前一字不差——这一点有断言钉着。
- 在工具调用正要动手时递(
precall):由记录自己声明它适用于哪些调用——写记忆时 填recall_for,取值是三选一的事实:path:<文件名>(这次调用要点名这个文件)、tool:<工具名>(这次调用就是这个工具)、command:<命令里的词>(命令行或任意参数文本里出现这个词—— 插件类工具没有命令行,它的action/参数里的词就是这里要匹配的)。 调用满足其中一条就递那条记录,一次调用最多一条、最多 300 字节(本会话已经看过的那条会让位给还没看过的——否则资历高的老记录会长期霸占同一个动作的提示位,新写的教训永远轮不到;2026-10-12 实测:一条"开跑前先查门禁"的教训在 15 条竞争记录里排第 4,提交挖掘时从未出现过);没声明的记录不会在动手前 出现,只进每轮摘要、只被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 | 整个摘要(所有段合计)的字节硬上限 |
thinDigest | false | 每轮摘要只给指针(记录 id + 标题),不含"该怎么做"——要看详情得用 memory_recall 带 record_ids 取全文。默认关。开着的意义在于:摘要里那句被截断的话不再像是完整指示,代价是读者必须真的去取——而实测 15,896 次调用里它只主动取过 9 次。判据与对照见 docs/PREREGISTER-l1-thin-digest.md |
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 | 100 | 一个会话最多提醒几条(按真正递出去的条数算)。2026-10-10 由 20 上调:实测按会话计时代的六个会话投递量为 20/19/12/10/1/1,撞顶那个只用 1 小时 17 分就烧完、之后整场静默 |
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 才行」。这些都不是命令句。
三条性质让它不会变成这个框架最想避开的那种东西:
- 永远是候选。 采集直接写库,不走
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 调用,而这个插件一次都不花。所以宽判据留作开关,等精度被量到值得打开再打开。
迁移
命令行(仓库内,适合脚本化):
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。
旧库里没有「教训」这一类知识,只有被切块存进记忆的项目规格、边界和状态台账——版本基线、
范围排除项、安全不变量、里程碑退出门、数据来源授权、当时尚未验证的项。它们在各自项目里很有用,
在别的项目里是噪声,所以都是工作区级而非领域级。
⚠️ 两个必须知道的后果:
- 旧运行时没有
lesson和failure_mode字段,所以每条导入记录这两个字段都是空的。常驻行是 「标题 — 教训」,教训为空时回退渲染正文,所以导入的记录以正文形式出现,可执行教训这一层是缺的。 - 它们是
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/,而脚本是仓库内工具。这也消除了 「随包脚本 importsrc/*.ts因而在node_modules下跑不起来」那一类缺陷——不是修好它,而是不再发它。 -
不做跨机器同步;数据库是单机文件。
-
真实模型回合跑过三次独立实验,但它们都不在
pnpm verify里。每一次都抓到了套件抓不到的东西:- 挂载层对不上真实 Session:两个读取器都在读
agent.session.events,而这个属性在真实 Session 上不存在——于是生产环境里事件日志恒为空,逐字引文永远定不到verified-user, 检索查询永远是空串,注入层的查询段恒不命中。测试全部手写了那个数组,固化的是假设而不是 契约。现在读取统一走src/session.ts(snapshotEvents(),其余为带标签的兼容分支)。 结论:挂载层断言替代不了一次真实回合。 - 新旧两版投递判据的对照:无记忆 0/12 对 有记忆 9/12(
p = 0.0003),跨会话 0/6 对 5/6 (p = 0.0152)。见上文「它到底有没有用」。 - 换一类知识再复现一次:约定从"配置写什么"换成"文件放哪",无记忆 0/6 对 有记忆 6/6
(
p = 0.0022)。同一节有合并数:两场景合计 0/24 对 20/24。
跑法见
docs/DEVELOPING.md的「跑一次真实模型回合」与tools/verified-user-ab/README.md, 但它们要消耗真实 token,所以没进自动化。 - 挂载层对不上真实 Session:两个读取器都在读
-
斜杠菜单的浏览器渲染没有自动化。命令的可发现性已经断言过了:测试用的是斜杠菜单读取的同一个 API (
ctx.commands.list(agent)),检查 7 个命令都在、都有描述、带参数的那几个都声明了参数提示、且按名排序。 剩下未验证的只是「浏览器把这份数据画出来」这一步——而这一步对 in-box 命令与本插件是同一条代码路径。
关于这份文档
当前版本 0.5.11(2026-10-18)。 完整版本记录(每一版改了什么、为什么改、实测数字)见
CHANGELOG.md;0.5.11 修的是记忆正文里出现双花括号占位符会让整个会话每轮都起不来——DSH 的提示词上下文默认严格插值,遇到未注册变量直接抛错,而摘要层拼的正是记录正文;现在两处上下文都声明"这段文本不是模板"(interpolate: false),并有三层回归钉住。触发它的是 DSH 升级:0.2.1-rc.2 的 agent-loop 不再注册 cwd(0.11.1 还注册),同一条记录从"被替换成路径"变成"未知变量抛错"。0.5.10 新增 thinDigest(默认关)——把每轮摘要行渲染成指针(只给记录 id 与标题,做法要用 record_ids 去取),
并用真回合跑完了它该不该有的对照:每臂 8 格、正确率 8/8 对 8/8、薄臂 8 格全部去取了全文,所以"摘要只给指针会被静默忽略"这个担心不成立;
默认仍关(证据只来自单一场景)。0.5.9 的关键变化是**"反复踩的坑该加权"经实测否掉**(重踩最多的 344 次改文件失败没有任何记录可加权,能对上的靠工具名关联、约一半是错的,而它们本来就不该被记录);候选不再为"报错里已写着做法"的失败产生;记经验时若没填"该怎么做"会明说(31% 的确认记录处于这个状态);新增库健康快照(被复用/从未被检索/无 lesson/无锚点的占比,带趋势)与检索拒绝码;0.5.8 的关键变化是摘要里印了记录的 id,却没有按 id 取全文的路——现在 memory_recall 收 record_ids,取不到时会说明是哪道闸拒绝的("没这条"和"这条属于别的工作区"是两回事);两个工具各写了一份"这条记录该对哪个根解析",合并成一份,审计数字从 138 条降到真实的 63 条(那是口径修正,不是库变好了);新增 docs/CONCLUSIONS.md 登记已作废的方向,防止下一轮重提;0.5.7 的关键变化是出处体检此前拿「当前会话所在目录」去判定全库每一条记录——别的子工作区的记录被拼接上一个不属于它的根,文件明明在也被标成"已不存在",而模型读到这行会丢弃整条记录;实测 161 条标记里有 109 条是这么来的,现在只判定本工作区的记录,并用 tools/repair-provenance.mjs 清掉已写进库的 104 条;0.5.6 的关键变化是记录可以自己声明"硬约束"(priority:critical)——声明后压过同一动作上的所有竞争者(此前 15 条记录竞争、关键那条排第 4,提交挖掘时从未出现;标了之后从第 4 升到第 1);0.5.5 的关键变化是动手前提示的"让位规则":本会话已看过的那条记录让位给还没看过的——此前一个动作上多条记录竞争、每次只投一条、按资历定胜负,新写的教训永远轮不到(实测 15 条竞争、门禁那条排第 4,提交挖掘时从未出现);0.5.4 的关键变化是排序按层分开:有查询的两层改成「相关性优先、资历破平」——此前所有层都按资历排,浮点分数几乎不并列,导致相关性从不生效、新写的记录在每次查询里垫底;0.5.3 的关键变化是**command: 锚点改为「命令行或任意参数文本(含参数名)」——插件类工具没有命令行,此前这类锚点在它们的调用上永远匹配不上**;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的「文档与代码对齐」。 - 一共 24 个套件,一条命令跑完全部:
pnpm verify。各套件覆盖什么,见docs/DEVELOPING.md的「测试」。 - 构建、打包、启动验收、测试清单与开发环境在
docs/DEVELOPING.md。
Ähnliche Plugins
hindsight (coding-agents)
vectorize-io/hindsight
OpenViking (dsh-memory-plugin)
volcengine/openviking
MemOS (memos-local-plugin)
memtensor/memos
ReMe (dsh)
agentscope-ai/reme