# 经验记忆(@marquez807/dsh-experience-memory) [简体中文](README.md) · [English](README.en.md) 给 DeepSeek Harness 的**分领域长期经验记忆**:分得清轻重、攒得下经验、忘得掉过期、纠得了错,并在再次执行同类工作时自动召回相关经验。 - **每轮固定只花 204 字节**:一行提示,除此之外只在真有相关经验时才注入内容。 - **运行时零第三方依赖**,只用 Node 内置能力;存储是单个 SQLite 文件。 - 五个模型工具、七个斜杠命令,**零配置可用**。 ## 目录 | 想做什么 | 去哪一节 | |---|---| | 先装上,并确认它真的在工作 | [快速开始](#快速开始) | | 搞清它靠什么机制记住东西 | [它做什么](#它做什么) | | 查有哪些工具、哪些斜杠命令、有哪些配置项 | [工具](#工具) · [斜杠命令](#斜杠命令) · [配置](#配置) | | 看它在模型眼里长什么样 | [模型的体验](#models-experience) | | 知道它**做不到**什么 | [已知限制与推迟的事](#known-limitations-and-deferred-work) | | 从旧记忆库搬数据进来 | [迁移](#迁移) | | 想改代码、编译、跑测试 | [`docs/DEVELOPING.md`](docs/DEVELOPING.md) | ## 快速开始 装(把路径换成你手上的 tarball): ```sh dsh plugin --profile 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`](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 时用最近两条用户消息——与插件自己的查询推导是同一套逻辑 | | `/memory-maintain` | — | 立刻跑一次有界维护并报告退役了几条、为什么(同一套规则每轮结束也会自动跑) | | `/memory-harvest` | `[--retire ]` | 列出自动采集的候选,或退役其中一条 | | `/memory-audit` | ` [--out ]` | 审计归档库的正确性并落盘四份报告 | | `/memory-import` | ` [--selection ] [--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`](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 调用,而这个插件一次都不花。所以宽判据留作开关,等精度被量到值得打开再打开。 ## 迁移 命令行(仓库内,适合脚本化): ```sh 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,就是给你改的**。 删掉你不同意的条目,然后: ```sh 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` 说明东西坏了,没说下次该怎么做。把它们当经验导入,正是常驻阈值要挡住的那种噪声。 ### 导入前先审计 迁移工具回答「什么能导」,审计工具回答「**这些经验是不是对的**」——后者必须在前: ```sh 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): ```sh 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`](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`](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`](CHANGELOG.md);`0.3.0` 的关键变化是**写入时把命中过宽的锚点丢掉并写明理由**(外加把"这条经验有没有改变结果"记进 `effect` 字段,默认不参与排序);`0.2.0` 的关键变化是**投递判据换成"记录自己声明 `recall_for`"**, 这是行为变更:没声明的记录不再在动手前打断工具调用。 - **README 里的数字是机器核对的,不是手抄的。** `tests/docs.test.ts` 逐格比对配置表的默认值、注册的工具与命令名单、摘要行数上限(2+5=7)、测试套件数、审计产出清单,以及那一行提示的字节数;对不上测试就红。改文档和改代码是同一件事。 - **对外讲过的每一句硬话都登记在 [`docs/CLAIMS.json`](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) 的「测试」。 - **构建、打包、启动验收、测试清单与开发环境**在 [`docs/DEVELOPING.md`](docs/DEVELOPING.md)。