dsh-memory-md
纯本地 Markdown 记忆插件(DSH)。助手通过 memory_md_* 工具把值得长期保留的内容写进 ~/.dsh/memory-md/,跨会话记住你。
记忆索引会在回合开始时作为上下文快照注入,所以助手不必先调工具就知道有什么记忆。
一、设计要点
1. 注入分两段:常量进提示词,读盘的进快照
记忆文本按是否读盘分两类,走不同通道:
| 内容 | 通道 | 为什么 | |
|---|---|---|---|
| 协议 + 行为纪律 | 索引是什么、怎么写记忆(先查再写、删过期、按主题组织、四类型判据……) | systemPrompt.section()(系统提示词) |
纯常量,逐字节恒定 → 前缀 KV Cache 始终命中,不产生新消息 |
| 索引 | 用户级 + 项目级 MEMORY.md |
systemPrompt.context()(上下文快照) |
读盘、随记忆变化 → 进 section 会让整个前缀缓存失效 |
两个方向的红线都成立:
- 读盘的内容绝不允许进
section()—— DSH 每个 step 都重新装配系统提示词(dsh-agent-loop的systemPrompt.assemble())。任何随文件变化的 section 都会让整个前缀的 KV Cache 失效(含全部历史)。 - 常量不该混进
context()—— 快照是追加而非替换。常量一旦和读盘内容捆在同一条快照里,读盘内容一变就把常量整段重发,而重发的那份永久留在会话历史里。常量放section()每步都在,但逐字节恒定 → 缓存命中,不产生新消息,严格更优。
// 协议 + 纪律:静态常量,写死在提示词里
ctx.systemPrompt.section({ name: 'memory-md:protocol', order: 950, text: () => memoryPromptText() })
// 索引:每次装配重读;空串则不贡献
ctx.systemPrompt.context({
name: 'memory-md:index',
order: 10_000,
text: () => renderMemoryIndex(...),
})
实测体量:常量段 2638 字符(协议 230 + 纪律 2406),索引快照约 1478 字符。
一段走过的弯路(已修正):行为纪律曾经被拼进
renderMemoryIndex()的产物,与索引同走context()。结果是规则一字未改,却因为和读盘的索引捆在同一条快照里,索引一变就带着 2400 多字整段重发 —— 等于把常量的缺点(重发)和动态内容的缺点(耦合)都占了。当时给的理由是「让模型读到『有哪些记忆』的同时读到『该怎么对待』」,但那是个读者便利的考虑,代价却由每一轮请求承担。CodeBuddy 的原版犯同一个错(把规则与## Current MEMORY.md contents拼成一整块<memory>每次全量注入),不该照搬。
早先把两段都放进 context() 时问题更严重:协议文本跟着每次记忆变化重发。
行为纪律的内容与来源
规则文本(MEMORY_RULES)吸收自 CodeBuddy 的记忆提示词,只吸收适用于 DSH 的部分:
- 吸收:按主题而非时间组织、更新或删除过期/错误记忆、写前先查避免重复、用户明确要求就立刻办、用户纠正了从记忆里说出的说法必须改掉、写前先核实。附正面清单(什么值得存)与负面清单(什么不该存)—— 两者成对,只给约束不给目标等于没给规则。
- 吸收(typed 路径):四类记忆各自的「何时写 / 怎么写 / 为什么」(
## Types of memory的<when_to_save>/<how_to_use>/<body_structure>)、「记忆不等于当前事实」(## Before recommending from memory)、「用户说别用记忆时当作空」(## When to access memories)。 - 排除:依赖 CodeBuddy 具体工具特性的说法(
write to it directly with the Write tool、Use the Write and Edit tools—— 我们的模型不能传路径,写盘由插件走 Host 侧 fs);grep*.jsonl会话转录(DSH 的会话日志是session.jsonl.zstd压缩格式,直接 grep 不可行);<examples>逐条照搬(那些是英文通用编码场景,与中文 DSH 语境错配)。
为什么不能只放工具 description:这是事前纪律,不是工具用法。模型得在决定要不要写的时刻就知道「先查再写」,等它已经调 memory_md_save 时才看到就晚了。工具 description 只适合放「这个工具怎么调」。
没有索引时快照为空,但纪律照常在。 纪律是常量、走 section(),它不可能
依赖"磁盘上有没有索引"——所以一条记忆都没有时,提示词段里仍有纪律,只是
context() 那份快照返回空串(不注入任何东西)。这是刻意的:纪律讲的正是
"什么时候该去写记忆",恰恰在空库时最需要它。
后台总结也要防重复。 后台总结是独立 LLM 调用,它看不到注入快照(快照进的是主对话)。所以:
SUMMARY_SYSTEM里有一份防重复规则:告诉它看不到记忆库、只写本段对话里新出现的明确事实、宁可漏记也不要重复记;并明确<memory-index>块是已有记忆的索引、不是用户说过的话 —— 否则它会据此再写一条重复记忆(自我喂养)。- 消息末尾附上「已有的记忆」清单(
memoryManifestFor())—— 这是机械保障,不是叮嘱。清单里每条带[scope]前缀、类型、文件名、年龄与描述,模型据此判断"是不是已经有同一件事的条目,该覆盖哪个文件"。
只叮嘱不喂清单是没用的:模型无从知道已经有什么。这一招吸收自 CodeBuddy 的
buildExtractPrompt()—— 它把formatMemoryManifest(scanMemoryFiles(dir))作为 "## Existing memory files" 一节喂给抽取子代理。
索引超过 1 天会带年龄属性。 索引是一份快照,记的是写下那一刻的记忆库状态。所以旧索引的标签会多一个 updated 属性:
<memory-index scope="global" updated="10 天前">
当天/昨天的不附(不存在"过期"问题,挂了只是噪音)。机制吸收自 CodeBuddy 的 memoryFreshnessText() —— 那边是注入时附一句 "This memory is N days old … Verify against current code before asserting as fact.";这里改成标签属性,因为它天然属于这份索引,模型不必再读一句话去对应它是说给谁听的。规则段里有配套的行为要求(见下)。
去重是白拿的。 dsh-agent-loop 的 RuntimeContextProjection.project() 会比对上一次保留的快照文本,内容未变则不产生任何消息 —— 所以「索引变了才注入、没变就不重复注入」由 loop 负责,插件不需要自己做跳过机制。
压缩会重新注入一次。 自动压缩(dsh-compaction-basic)与 /compact 提交的都是 surfaceOp.replace,把历史里一段消息(含我们的快照)替换成摘要 checkpoint。loop 监听到这次替换后会把 retained 置空(dsh-agent-loop/lib/index.js:325),下一轮必然追加一份新快照 —— 但若不配合,追加的会是冻结缓存里那份旧文本。
所以开启冻结时,缓存连带记住渲染那一刻的 session.surface.replaceGeneration:该计数只在 surface 发生 replace 时递增(dsh-session/lib/index.js:464-468),append(含我们自己的快照注入)不递增。代数变了=历史被替换过=缓存文本可能已不在历史里 → 重新读盘渲染并重新缓存。这比监听 compaction/summary 更通用:手动 /compact 走同一个后端方法 ctx.compaction.compactNow()(dsh-command-compact/lib/index.js:48-58),顺带覆盖工具结果裁剪这类 replace。取不到 session 时代数为 undefined,退化成纯冻结,与无此机制时行为一致。
索引带确定性标签。 每份索引用 <memory-index scope="global|project" cwd="…"> 裹住,模型不必靠上下文猜这段文本的边界和作用域:
<memory-index scope="global">
- [用户偏好](memory/user_x.md) — 用中文回复
</memory-index>
注入的只有索引,不含分类文件全文;模型命中描述后自行读原文。
⚠️ 注入内容会被官方二次处理,{{...}} 必须先中和。 官方 interpolate() 把注入文本里的
{{name}} 当作提示词变量严格校验(变量名须匹配 /^[a-z][a-z0-9_]*$/),且对
context() 和 section() 都生效。所以记忆描述里只要出现 {{挖空}}、{{.Server.Version}}
这类合法模板语法,就会在 systemPrompt.assemble() 里抛错 —— 整个回合失败,
而且每轮都失败、永久锁死该工作区(agent 无法自救,只能手工改 MEMORY.md)。
修法是在注入边界做相邻花括号转义({{ → {\{,任意长度连续花括号都处理),
语义不变、原文可还原。inject 套件有回归断言,另有一条防未来断言:
协议段那个常量里不允许出现 {{。
2. 轮末总结在后台异步跑,不打扰对话
早先的实现挂在 agent/turn-stopping,往主对话 inbox.append('next-step') 塞一条 "Before this turn closes…" 的提醒。两个问题:它显示在对话里,而且逼主模型再跑一步。
现在改成:轮末起一次独立的、无工具的 LLM 调用(ctx.llm.stream()),由它总结做了什么、有什么值得沉淀,然后插件自己写记忆文件和日志文件。主对话全程不参与、不显示任何东西。
为什么不用子代理:ctx.subagents.start() 会创建真正的子 agent(有自己的 session、进会话列表、能调工具),还会再次触发 agent/turn-stopping —— 递归风险。这里只需要一次模型调用。
总结范围是「上次成功总结之后」,不是「本轮」。 每个会话维护一个游标,只在成功后推进。回合崩了、内容不足、调用失败时游标原地不动,下一轮自动补上。
这条是踩坑换来的:早先只取最后一个
turn/start起的事件,于是任何"没写成"的回合内容永久丢失。 真实事故:20 分钟的源码研究与实测,日志里一个字都没有(turn 以 error 结束 + 另一轮太短, 两者的工作就此出局,后面的回合用切片也看不到)。连续失败 3 次才放弃该段,避免游标永久卡死。进程重启会重置游标(锚定在上一轮),所以重启前丢失的那段不会自动补回 —— 这是刻意取舍:宁可丢一段,也不要每次重启都把整个会话历史重刷一遍日志。
3. 不冒充人设,也不让模型拼路径
systemPrompt.section() 里有一个是主会话的人设槽位(order: 0)。插件往那里塞 You have a persistent memory system… 等于冒充 DSH 给模型立规矩 —— 这条仍然成立。
本插件只用 950 这个空档(文件引用段 900 之后、工具说明段 1000+ 之前),且内容仅限解释自己的东西:索引长什么样、怎么读、边界在哪。角色设定与行为准则仍归 DSH 官方。
记忆根目录固定在 <dshHome>/memory-md,路径全部由插件计算,工具参数里没有 path / dir。
早先版本的提示词只给裸文件名(user_role.md、MEMORY.md),模型只能用自己的 cwd 补全,结果把文件写进了工作区:
D:\workspaces\ai\dsh-memory-md\MEMORY.md ← 污染工作区
D:\workspaces\ai\dsh-memory-md\memory_test.md
二、工具
五个工具,写入规范写在 description 里;记忆是什么、该怎么对待记忆归提示词段的协议与快照里的行为纪律。
memory_md_save
保存 / 更新一条记忆。插件负责路径与 MEMORY.md 索引,模型只提供内容。
| 参数 | 必填 | 说明 |
|---|---|---|
scope |
✅ | global(跨项目)/ project(当前工作区) |
type |
✅ | user / feedback / project / reference |
name |
✅ | 简短标题 |
description |
✅ | 一句话,说明什么时候这条记忆有用 —— 决定它日后能否被找到 |
content |
✅ | Markdown 正文 |
file |
覆盖已有的某个文件;省略则按 type + name 生成 |
同一文件重复保存 = 就地更新,索引条目不会重复。
memory_md_search
搜索记忆内容,返回匹配行及其文件。用窄词(报错信息、文件路径、函数名)而不是宽泛关键词。
memory_md_read
省略 file 则列出所有记忆(标题 / 类型 / 描述);给了 file 则读全文。
memory_md_forget
删除一条记忆 —— 正文文件与索引行一起删。用在三种情况:用户明确要求忘记某事; 这条记忆被证明是错的;它已经过期、不再适用。
| 参数 | 必填 | 说明 |
|---|---|---|
scope |
✅ | 从哪个存储里删 |
file |
✅ | 要删的文件名(先用 memory_md_read 列表或 memory_md_search 拿到确切名字) |
这是不可逆操作,所以:
- 文件名对不上时报错,而不是猜一个最像的删掉;
- 删除后回报被删条目的标题与描述,用户和模型都看得到究竟删了什么;
- 重复删除是幂等的(返回
removed: false,不抛错); - 接口只会删单条,没有「清空全部」这种批量入口。
内容需要修正、条目本身仍成立时,应当用 memory_md_save 传同一个 file
覆盖更新 —— 那是「纠正」,不是「忘记」。
memory_md_journal
追加到当天的工作日志(记忆设置 → 工作留痕 打开时才有内容)。
日志属于当前工作区 —— 它记的是「这个项目里做了什么」,所以需要打开一个工作区;没有全局日志。跟具体项目无关的结论应该写成记忆(memory_md_save),而不是流水账。
一次调用 = 一个带时间戳的批次,每条 1-3 句,写清「做了什么 + 得出什么结论」:
# 2026-09-13
## 04-27-56
- 修了分隔符 bug —— 根因是并发写共用同一个临时文件名,后写的覆盖先写的,改成随机后缀。
- 补了并发回归测试:8 次并发写入全部保留。
## 04-27-57
- 加了后台总结:轮末起独立 LLM 调用,写记忆与日志,不往主对话塞消息。
为什么不是一句话流水账:结论才是事后翻看时真正要找的东西。
「研究了 X 的实现方式」看过等于没看;「X 用的是双块折叠,没有 section,所以不吃缓存优化」
才能还原当时的判断。判定标准见 docs/design/plan.md 第 7.3 节(正面清单 + 负面清单)。
同一天分几次写的,一眼可见 —— 不必靠条目顺序去猜。
参数:notes(字符串数组,一次提交多条)或 note(单条便捷写法)。
一次能说清的就别调多次,插件会把它们归到同一批次。
通常不需要手动调它。 轮末的后台总结会自动把本轮做了什么写进日志(见第一节第 2 点)。 这个工具留给需要精确补记的场合。
日志是只写不读的:不参与索引、不出现在 memory_md_search、也不会被 memory_md_read 列出。它记录「做了什么、结论是什么」供你事后翻看;要让某件事影响未来的对话,用 memory_md_save。
开关关闭时静默跳过(返回 written: false),不报错打断对话。
返回值里三个字段含义不同,别混:
| 字段 | 含义 |
|---|---|
stamp |
本批次的时间戳 HH:MM:SS |
entries |
本批次新增的条目数 |
total |
当天累计条目数 |
早先只有一个
entries,返回的却是累计值 —— 连写两条时看到2会误以为「一次写了 2 条」,进而误判成丢数据。拆开后就没有歧义了。
二·一、命令:/memory-check
全库体检。只读 —— 出一份报告,不改任何记忆文件。
/memory-check
它查什么
分两段,结果在报告里分开列,出问题能一眼看出是机械判据还是模型判断在报。
一、结构问题(规则判定,确定性、零 token)
| 判据 | 级别 | 说明 |
|---|---|---|
索引死链 |
error | 索引里有这一行,但 memory/<file> 不存在 |
索引超限 |
error | 索引超过 200 行或 4e4 字符 —— 超出部分注入时被截断,那些记忆模型看不到 |
缺 frontmatter |
error | 没有 frontmatter,name/description/type 全丢 |
逐字重复 |
error | 两条正文完全一样,是同一份内容的副本 |
frontmatter 缺字段 |
warn | 缺 name / description / type 之一 |
type 非法 |
warn | 不是 user / feedback / project / reference |
type 与文件名不符 |
warn | 文件名前缀与 frontmatter 的 type 对不上 |
文件名与内容错配 |
warn | 文件名的语义与 name 的语义零交集(借名) |
正文为空 / 描述过长 |
warn | 只有 frontmatter;description 远超「一行约 150 字」 |
相对时间 |
warn | 正文出现「昨天」「上个月」这类指代,过一阵会读不懂 |
标题相似 |
warn | 同作用域内两条标题相似度 ≥ 62%,可能重复 |
二、内容问题(模型判定) —— 一次独立 LLM 调用,判五类:duplicate(同一件事)/ mergeable(同主题但拆散了)/ outdated(曾经成立现在不成立)/ wrong(自相矛盾或标题正文不符)/ vague(空泛到没法照着做)。
每条结论必须附「原文依据」
体检模型看不到代码库,也没有任何工具 —— 它只拿到一段拼好的记忆文本。这意味着两件事:
- 它能判「这两条是不是同一件事」,因为那只需要文本;
- 它判不了「这条记忆是否已与现状漂移」,因为那要去查真实代码。
所以这里有两条防线:
防线一:强制逐字引用。 每条结论必须带 quote,且必须是该条记忆正文里的逐字片段。宿主自己比对 fullBody.includes(quote),对不上就丢弃并计数。
这一条挡的是「模型凭印象瞎猜」:只有 id 校验时,它能编 id 之外的一切;要求引用原文之后,它必须先找到支撑结论的那句话。
CHECKUP_SYSTEM 里明确写了:
- 抄不出来就说明**这条结论不该报** —— 不要凭印象下判断。
并且对最容易误报的 outdated 额外加了一句:
⚠️ 这条最容易误报:**你无法访问代码库,所以「我猜它可能变了」不是证据**。只有记忆**自己内部**就自相矛盾(例如正文说「用 4.6」,描述说「已升到 4.8」)时才算。
引文校验区分两种失败(这条很重要):喂给模型的正文只有前 600 字,而校验拿的是完整正文。于是引文对不上有两种成因:
| 情形 | 处理 |
|---|---|
| 完整正文里也没有 | 模型编的 → 丢弃,计入 quoteMismatch |
| 完整正文里有、但位置在 600 字之后 | 我们自己截的,不是模型错 → 保留,报告标注「引文在正文 600 字之后,未喂给模型」 |
不区分的话,会把「记忆太长」误判成「模型不可信」,把好结论一起扔掉。
防线二:把待复核清单投回主模型。 光靠引用还不够 —— 引文只能证明「这句话在记忆里」,证明不了「这句话现在还对」。而体检模型没有工具,查不了。
主模型不同:它有 read / grep / glob 全套工具,而且已经在用户意图的上下文里。所以体检跑完后,插件用 agent.followup() 把「待复核清单」投回去,让主模型自己去核对真实情况。
只投需要外部核对的:
outdated/wrong→ 值得去查;duplicate/mergeable/vague→ 读记忆本身就能判断,不投;- 且正文里必须出现可检索的锚点(反引号标识符、路径、版本号、包名)—— 没有锚点的条目,「去核实」会退化成无目标的全库 grep。
投递的指令里写死了三条边界(不靠模型自觉):
1. **只核实,不要修改任何记忆文件** —— 核实结果报告给用户,改不改由用户决定。
2. 用 read / grep / glob 查**当前真实情况**(代码、配置、版本号)。
3. **查不到就明确说「无法验证」**,不要因为「看起来像过期」就下结论。
投递是可选的(设置项 checkupReviewByModel,默认开)—— 它会额外起一轮对话,不想被打断可以关掉,只留报告。投递失败也不影响报告本身。
为什么不用子代理:
dsh-cc-haha-dream是用 fork 子代理做这件事的,它为此要处理父 agent 选取、provider 可用性、工具不支持时降级,超时默认 15 分钟。而我们要的判断(重复/空泛/矛盾)不需要看代码库 —— 一次llm.stream()就够;唯一需要看代码库的那类(漂移),交给本来就在场的主模型更省也更准。
三条刻意的取舍
1. 只报不改。 记忆是人工可读可改的 Markdown,本项目明确不做自动改写(防重复走「提示 + 警告」)。报告的结尾固定有一句「本报告不会改动任何记忆文件」。
2. 「有文件、无索引」不报。 索引是用户控制上下文体积的手段 —— 他会主动删掉某些索引行给后面的条目腾位置。把这种情况报成缺陷会诱导人去「修复」,而那正是被明确叫停过的行为。这一点在 checkup.mjs 的 ruleCheck() 里留了注释,免得后来的维护者以为漏写了。
3. 语义分析没跑成时,绝不说「没有问题」。 LLM 拿不到、路由缺失、调用报错时,报告写的是「结构检查通过,但内容分析未完成 —— 这不等于没有问题」。反过来做(信息为空就当健康)是本功能最严重的失败模式:它给出的是一句没被验证过的保证,比直接失败更糟。
为什么是命令而不是工具
- 它是人驱动的动作。体检要跑一次全库 LLM 调用,让模型自己决定何时花这笔钱不合适;用户输入
/memory-check才是明确授权。 - 不占工具位。工具 schema 每份请求都要带,一个只有人类会用的能力放进去是纯开销。
- 结果直接显示给人看。
CommandResult.text由 UI 直接渲染,不必先进模型上下文再转述。
注册方式照抄官方 /compact(dsh-command-compact):同样是一次长耗时后台动作、同样把结论作为 text 返回。
借鉴来源
必须分清两件事:本插件从 CodeBuddy 移植而来(记忆格式、提示词纪律、索引截断口径都源自它,见 docs/research/memory-scheme-codebuddy.md);而 /memory-check 这个事后整理能力,是三个参考实现里都没有的,因此没有可移植的实现。下面区分「机制借鉴」与「只影响设计判断」。
完整的调研底稿(含各家源码证据、实测关键词计数)见
docs/research/memory-research-dream-and-checkup.md; 全部文档的分类索引见docs/README.md。
一、真正写进代码的借鉴
| 借鉴点 | 来源 | 落点 |
|---|---|---|
| 「反思式整理」的整体形态(审视已有记忆 → 合并 → 修剪索引) | Claude Code Dream(提示词原文,ccVersion 2.1.296,四阶段 Orient → Gather → Consolidate → Prune) |
CHECKUP_SYSTEM 的判据骨架与报告结构 |
| 相对日期转绝对日期 | Dream Phase 3;CodeBuddy when_to_save |
relative-date 判据(把叮嘱变成确定性检测) |
| 索引是指针不是内容、一行约 150 字 | Dream Phase 4 |
long-description 判据(>200 字报警) |
| 非法结论单条丢弃、合法子集保留(不整份作废) | dsh-mneme validateDecisions 的 skipInvalid |
parseCheckupOutput 的 dropped + 报告「未覆盖」一节 |
| 输出 id 必须与真实条目求交(反幻觉) | dsh-mneme 的 evidence intersect |
knownIds.has(f) 过滤 |
| 结构化决策清单 + 严格校验 | dsh-mneme consolidation prompt |
CHECKUP_SYSTEM 的固定 JSON 形状 |
llm.stream() + finish.reason.kind 判截断/失败 |
官方 /compact + 本项目既有 summarize.mjs |
semanticCheck 的 chunk 处理 |
二、只影响设计判断、没有写进代码的
Dream的Auto Dream三重门控(24h 时间闸 + 至少 5 个新会话 + 文件锁):未采用。那是自动后台触发的设计,本插件走手动命令,用不上。dsh-prime-memory的memory_ruminate、dsh-auto-memory的memory_consolidate:只参考了「该不该提供手动入口」这一判断(结论:该提供),分层 L1→L2→L3、容量驱动压缩等机制一条都没用。dsh-mneme的动作集(keep/merge/archive/conflict/update):未采用。它的动词是「改数据库」,本插件只出报告,动词对不上;本插件的duplicate/mergeable/outdated/wrong/vague是诊断类别,不是执行动作。- CodeBuddy 的每轮抽取子代理:未采用。那是写入侧机制,本插件已由后台总结承担。
三、本插件自己设计的(上述来源都没有)
- 二元组 Jaccard 标题相似度(阈值 0.62)——参考实现全部无相似度阈值(mneme 用向量聚类,prime/auto 用召回候选)。
name-mismatch(借名检测):文件名语义与name语义零交集。三款插件都没有这类检查(调研报告明确记为「未找到」)。exact-duplicate(逐字重复):整段逐字比对,比相似度更保守。dangling-index(索引死链)、index-overflow:文件式记忆特有的故障,数据库式插件没有这个概念。- 「语义未跑成时绝不说没问题」:自审时发现自身的 bug 后加的(见下节)。
- 「有文件、无索引」刻意不报:来自本项目用户的既定取舍(索引是他控制上下文体积的手段),不是任何插件。
三·五、调研盲区与处置(诚实记录)
第一轮调研按「memory 分类 + npm 下载量排序」取样,系统性漏掉了最贴题的三个实现 ——
它们没有 npm 包(downloads 恒为 null),按热度永远排不进前列。补查后找到两条实质差距,均已处置:
| 差距 | 对标实现 | 处置 |
|---|---|---|
| 没有逐字证据引用(只校验 id 在清单里) | @klarkxy/dsh-memory 要求 source.text.includes(quote) |
已补:结论必须带 quote,宿主校验逐字命中(见上节「每条结论必须附原文依据」) |
没有子代理验证(只用裸 llm.stream() 喂记忆文本) |
dsh-cc-haha-dream fork 只读子代理 |
改用主模型复核:agent.followup() 把待复核清单投回主模型,由它用 read/grep/glob 核实 —— 不引入子代理基建 |
其余遗漏(预览/应用分离 + revision 乐观锁、冷启动守卫、跨进程锁 token、.history/ 版本备份、
候选状态推导、删除墓碑)都属于「若将来从只读报告扩展到自动改写」才需要的基础设施,当前形态下不构成缺陷。
详见 docs/research/memory-research-dream-and-checkup.md 第六·五 / 六·六节。
四、关于 CodeBuddy 有没有「总结/整理」功能
实测 @tencent-ai/codebuddy-code v2.150.0 的 dist/codebuddy.js:autoDream 0 次、蒸馏/巩固 各 0 次;dream 仅 5 次且全是噪音(动画词表 Dreaming、域名 dreamhosters.com、MIME vnd.dreamfactory);consolidat 7 次全属 Insights 报告的 at-a-glance consolidation,与记忆无关。
它的实际做法是写入时抽取,不是事后整理:memory_extraction_stop_hook(event = STOP,每回合结束触发)→ extractAsync → doExtract(新增消息 < 2 即跳过;主代理已写过记忆也跳过)→ 一个受限子代理(只给 Read/Grep/Glob/Write/Edit,maxTurns: 5),提示词要求「只用最近约 N 条消息」。它的「去重/删过期」只写在那份提示词里:
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
/memory 命令只有 toggle(切换 autoMemoryEnabled),没有整理子命令。
结论:CodeBuddy 没有 dream/定期整理功能,它对「去重、删过期、改错」的保障全靠写入那一刻的子代理自觉——只在那一轮看得见的几轮消息里判断,历史越久越管不到。/memory-check 是全库、跨会话、事后的视角,这是它结构上没有的能力。一个反向印证:CodeBuddy 同样把 MEMORY.md 限在 200 行 / 25KB(truncateMemoryEntrypoint,em=200, ef=25e3),但它只能靠提示词提醒模型自己压缩,本插件能用 index-overflow 确定性报出。
写入是原子的
日志采用「读 → 拼接 → 原子替换」,临时文件名带随机后缀。这是必须的:早先用 <path>.<pid>.tmp,同进程内两次写会共用同一个临时文件,后写的覆盖先写的再各自 rename —— 条目静默消失。tools 套件里有对应的回归断言。
时间戳的同秒问题
同一秒内多次调用会产生名字相同的小节(如两个 ## 04:27:57)。这不会丢数据,只是两个小节并排 —— 批次边界仍然可读。要更细就得带毫秒,但那是噪音,不值得。
二·五、写入频率
两条路径并存,互不重复:
| 路径 | 时机 | 谁决定 |
|---|---|---|
memory_md_save 工具 |
模型自己判断该记了 | 模型 |
| 后台总结 | 每轮结束时兜底 | 插件 |
后台总结怎么做的
监听 agent/turn-stopping,在轮末起一次独立的、无工具的 LLM 调用
(ctx.llm.stream()),由它总结本轮做了什么、有什么值得沉淀,然后插件自己
写记忆文件和日志文件。主对话全程不参与、不显示任何东西。
为什么不用子代理:ctx.subagents.start() 会创建真正的子 agent(有自己的
session、进会话列表、能调工具),还会再次触发 agent/turn-stopping —— 递归
风险。这里只需要一次模型调用,不是 agent,所以不会递归。
带来两条必须自己处理的保障(因为不走子代理,官方不为这次调用兜底):
- 超时 ——
llm.stream()没有内置超时。不兜的话 provider 一挂起, 内部状态永不复位,游标从此卡死、再也不写日志(比丢一段严重)。故配 60s 超时。 {{...}}中和 —— 官方interpolate()把注入文本里的{{...}}当提示词变量 严格校验,且对context()与section()都生效。记忆里含{{会抛错炸整轮, 并永久锁死那个工作区(每轮都炸、agent 无法自救,只能手工改MEMORY.md)。 故在注入边界把相邻花括号转义({{→{\{,语义不变、原文可还原)。 dsh-mneme 被 issue #40 追过同一个问题。
旧实现是反面教材。 它往主对话
inbox.append('next-step')塞一条 "Before this turn closes…" 的提醒:显示在对话里,还逼主模型再跑一步; 且投递时漏了source,loop 在轮次收尾路径上读message.source.kind直接抛Cannot read properties of undefined (reading 'kind'),整个回合失败。那段代码(
src/remind.mjs)已删除。regression-kind套件现在断言summarize.mjs里没有inbox.append/createUserMessage,且真跑一次 轮末时inbox.append一次都没被调用 —— 只要有人再把"提醒主模型"加回来, 测试立刻变红。
防重复与防卡死(当前实现)
⚠️ 早先这里描述一套已删除的「轮末提醒」机制(
remindedTurns按 turn 记账、 声称remind套件覆盖)—— 那套机制连同src/remind.mjs一起删掉了, 这段描述却留了下来,与上面「旧实现是反面教材」自相矛盾。现已改成描述实际生效的机制。
后台总结不会往对话里塞任何东西(无提醒、不逼主模型再跑一步), 所以不存在「模型看到提醒 → 不写记忆 → 无限追加」这个循环。 真正要防的是重复总结与卡死:
- 游标只在成功后推进 —— 失败 / 内容不足时游标原地不动,下一轮把这段一起带上
(跨回合补偿)。连续失败到
MAX_SUMMARY_ATTEMPTS(3 次)才放弃该段, 否则一段坏内容会让游标永久卡住。 - 主模型本轮写过记忆 → 不重复总结 —— 只认写操作
(
memory_md_save/memory_md_journal),只读的search/read不算。 判据只看本轮:用累积段会让一次调用永久污染后续所有轮。 - 超时 + 陈旧锁 ——
SUMMARY_TIMEOUT_MS(60s)兜住 LLM 调用本身;RUNNING_STALE_MS(5 分钟)兜住任何挂起路径,防running永久置位。 - 会话结束 force 触发 ——
agent/disposed时绕过门槛与防抖写一次, 否则最后一段对话没有「下一轮」可以补。 - 失败可见 —— 失败写进
error.log(与settings.json同级),不进.journal/。 解析拿不到结果时额外把模型原始输出存进diagnostics/,并按实情分类 (summary-call-failed/summary-empty-output/summary-truncated/summary-unparsable)—— 这几种的修法完全不同。error.log里的措辞由实际发生的事决定,不是固定一句:早先无条件写 「JSON 不完整或格式错误」,而实测最常见的是调用失败(finish error、 原文 0 字符),那时根本不存在 JSON —— 那句话会把排查引向"调大 maxTokens"的死路。 诊断文件含会话 id、模型、轮次、finish终态、failure详情(code/message/ status)、截断标记与原文,足够直接定位。
全程 try/catch —— 任何异常都不得影响轮次收尾。
三、安装
以 link 方式装进 DSH 的 web profile。
profile 位置:<dshHome>/profiles/web/(Windows:C:\Users\<你>\.dsh\profiles\web\)
1. 建链接
cd ~/.dsh/profiles/web/node_modules
cmd //c "mklink /J dsh-memory-md D:\workspaces\ai\dsh-memory-md"
Windows 上必须用 junction(mklink /J)。Git Bash 的 ln -s 会退化成复制目录,改了源码不生效。建完用 test -L 确认。
2. 登记到 profile
~/.dsh/profiles/web/package.json 两处:
{
"dependencies": { "dsh-memory-md": "link:D:/workspaces/ai/dsh-memory-md" },
"dsh": { "profile": { "bundles": [ "...", "dsh-memory-md" ] } }
}
3. 安装并重启
cd ~/.dsh/profiles/web && pnpm install
然后重启 dsh(Host 半是 bundle 行插件,进程启动时装载)。
4. 验证
node D:/workspaces/ai/dsh-memory-md/test/run.mjs
四、目录结构
记忆根目录固定,不在任何项目目录内:
~/.dsh/memory-md/ # Windows: C:\Users\<你>\.dsh\memory-md\
├── settings.json # 插件设置
├── error.log # 错误日志(与设置同级,排查看这层)
├── diagnostics/ # 后台总结失败时的原始输出(排查用,平时为空)
│ └── 20261012-170620-a1b2c3d4-summary-unparsable.txt
├── global/ # 用户级(跨项目)
│ ├── MEMORY.md # 索引(插件维护,唯一入口)
│ ├── memory/ # 记忆正文
│ │ └── user_xxx.md
│ └── .journal/ # 留痕(默认关)
│ └── YYYY-MM-DD.md
└── {slug}/ # 项目级(结构同上)
├── MEMORY.md
├── memory/
│ ├── feedback_aaa.md
│ └── project_bbb.md
└── .journal/
索引在作用域根,正文在 memory/ 子目录,日志在 .journal/ —— 三者同级。
这样作用域根一眼看清结构,memory/ 与 .journal/ 也彼此对称。
索引里的链接相应带子目录:- [数据库端口](memory/reference_db.md) — …。
从旧版升级(正文曾平铺在作用域根):跑一次迁移脚本 —— 这是一次性运维动作, 不是插件运行时行为(新写入本来就会落在正确位置)。
node scripts/migrate-memory-layout.mjs --dry-run # 先看会做什么 node scripts/migrate-memory-layout.mjs # 实际执行改了目录结构但还没重启 DSH 时,旧进程仍会往旧路径写新文件 —— 所以重启后要再跑一次,把这段时间散在作用域根的文件收进去。 脚本幂等:已搬过的不会再动,没得搬时直接报「无事可做」。
安全保证:只搬带已知
type:frontmatter 的真记忆(别的文件一律不动), 目标已存在则跳过并报告,绝不覆盖,只rename不改内容。跑前建议先备份。
{slug} 由工作区绝对路径压缩而来(盘符与分隔符转 -、全小写,中文原样保留):
D:\workspaces\ai\dsh-memory-md → d-workspaces-ai-dsh-memory-md
D:\坚果云工作日志 → d-坚果云工作日志
中文路径:早先版本把非 ASCII 字符整体当分隔符抹掉,
D:\测试中文路径与D:\坚果云工作日志都会塌缩成同一个d,于是所有中文项目共用一份项目级记忆。 现已改为只对 ASCII 部分做折叠 —— ASCII 路径的输出与旧版完全一致(无需迁移), 但旧的d/目录不会自动改名:里面的记忆要按项目路径手工归位到新的d-<中文>目录,迁移步骤见FIX-CJK-SLUG.md的第七节。
五、记忆文件格式
---
name: 数据库端口
description: 本地数据库跑在 5433
type: reference
---
正文。feedback / project 类型建议写成:规则或事实,然后 **Why:** 与 **How to apply:** 两行。
索引 MEMORY.md 由插件维护,一行一条,超过 200 行会被截断(只影响读取时的截断,文件本身不动):
- [数据库端口](memory/reference_db.md) — 本地数据库跑在 5433
六、设置
设置 → 记忆设置:
| 项 | 默认 | 说明 |
|---|---|---|
| 启用记忆 | 开 | 关掉后不注入上下文快照、拒绝保存、不做后台总结;读与搜索仍可用 |
| 工作留痕 | 关 | 是否允许写日志(只写不读)。后台总结与 memory_md_journal 都受它控制 |
| 触发轮数 | 2 | 累积够这么多轮就跑一次后台总结。与「触发字符数」任一达标即触发 |
| 触发字符数 | 2000 | 这一段新增内容够长也触发一次。单条很长(如一次长分析)时不必等够轮数 |
| 体检后交模型复核 | 开 | /memory-check 跑完后,把「可能过期/可能有误」的条目投回主模型,让它用 read/grep 核实。关掉则只出报告,不额外起一轮对话 |
| 在这些预设下停用 | 空 | 一行一个 preset id,支持 * 通配(如 presetmd-*)。这些预设里的对话不读也不写全局记忆 |
两个阈值清空即用默认值(服务端归一化兜底);填非正数也回落默认, 不会静默夹到下限。改动点「保存」后生效。
预设级停用
本插件挂在 host 平面,默认对所有 agent 生效 —— 包括官方四个预设。 但某个预设可能自带独立记忆(跟着预设走的人格记忆),那时全局记忆必须整体让位, 否则两套记忆同时生效、互相干扰。
disabledPresets 就是为此。命中后三条入口全停:
| 入口 | 行为 |
|---|---|
五个 memory_md_* 工具 |
直接拒绝并说明原因 |
| 后台总结 | 不跑(不写记忆、不写日志) |
| 记忆注入 | 不注入协议段与索引快照 |
为什么连读也停:只拦写会让 agent 读到一个它不该依赖的记忆库, 于是回答里混进别的项目的上下文 —— 比完全不生效更难排查。
判定依据是 session.header.agentPreset(durable,随会话保存)。
没有 preset id 时永不匹配,即默认全局生效 —— 官方预设不受影响。
为什么不做成「只在某预设下生效」的白名单:那会让新装的预设默认没有记忆。 「默认有、特定预设退出」更符合直觉,漏配时也只影响那一个预设。
设置页有开关 + 两个触发阈值 + 四条固定路径(记忆目录、用户级目录、设置文件、错误日志),
外加预设停用名单。改动点「保存」才生效(攒草稿,不即时提交)。
设置存 <dshHome>/memory-md/settings.json。
刻意不显示任何与会话有关的东西 —— 不列条目、不显示记忆卡片与计数、
不显示项目级目录或留痕目录。原因:设置页是全局页面,拿不到「当前会话」,
Host 半只能退回 sessions.list() 去猜工作区,而官方文档明确该列表是
创建顺序 —— 于是永远返回最老的会话,切换会话后界面仍显示上一个会话的
路径。猜错的路径比不显示更糟,所以整块去掉。
七、开发
node test/run.mjs # 跑全部 15 个测试套件
| 套件 | 覆盖 |
|---|---|
differential |
截断逻辑与 CodeBuddy 2.150.0 真实 bundle 逐字节比对 |
load |
按包名装载插件(与真实 loader 一致),确认工具与两段式注入注册 |
paths |
路径解析到 DSH 用户目录,不落工作区 |
routes |
HTTP 接口:只给与会话无关的路径、内容接口已移除 |
client |
client 半依赖白名单与注册契约 |
empty-state |
设置页不显示条目、卡片、计数与会话相关路径 |
inject |
两段式注入:索引标签、只注入索引、截断、协议不进快照 |
summarize |
后台总结:解析、写记忆与日志、开关与异常隔离 |
preset-exclude |
预设停用名单:读写日志全拦 |
regression-kind |
从真实会话日志取证:缺 source 会崩回合 |
schema |
工具 schema 过 DSH 真实的 assertSupportedJsonSchema |
layout |
目录布局:索引在作用域根、正文在 memory/、写入落位与防撞车 |
render |
设置页组件真实渲染一遍(源码文本检查测不出渲染路径的错) |
salvage |
总结输出超限被截断时的括号配平与数组元素抢救 |
tools |
工具行为:写入位置、索引维护、越界拒绝 |
代码结构
| 文件 | 作用 |
|---|---|
src/index.js |
Host 半:注册工具 + 两段式注入 + HTTP 接口 |
src/tools.mjs |
五个记忆工具的实现与说明书 |
src/inject.mjs |
注入:MEMORY_PROTOCOL(提示词段)+ renderMemoryIndex(快照) |
src/summarize.mjs |
轮末后台总结:独立 LLM 调用,写记忆与日志 |
src/checkup.mjs |
/memory-check 全库体检:规则体检 + LLM 语义分析 + 报告(只读) |
src/schema.mjs |
schema 转换(不用 defineTool,见下) |
src/routes.mjs |
HTTP 接口 /memory-md/api/* |
src/settings.mjs |
设置读写 |
src/store.mjs |
共享写入原语:原子写、记忆正文、日志追加、索引行、错误日志 |
src/context.mjs |
路径解析 |
src/codebuddy-port.mjs |
截断与 frontmatter(CodeBuddy 移植) |
client/client.js |
设置页(settings.section) |
⚠️ 为什么顶层不声明 inject: ['webServer']
cordis 的 inject 是必要依赖:依赖未就绪时 fiber 停在 INACTIVE,apply()
根本不会执行(实测:缺 webServer 时 apply() 不跑,没有 inject 声明的照常跑)。
而 webServer 只由 dsh-web-app bundle 提供(其 cordis.patch.yml:136 插入
dsh-host-webserver)——dsh-base、dsh-headless、dsh-acp-app 都不含它。
所以把 webServer 写进顶层 inject 的后果是:在 headless / acp profile 下,
插件整体静默失效 —— 不只是设置页没有,而是五个记忆工具与两段式注入
全部不注册,且不打任何警告,排查时完全看不出原因。
因此本插件分层:
| 能力 | 依赖 | 缺 webServer 时 |
|---|---|---|
| 五个记忆工具 | ctx.get('tools') |
✅ 照常注册(取不到只 warn) |
| 两段式注入 | ctx.inject(['systemPrompt']) |
✅ 照常生效 |
| 后台总结 | ctx.inject(['llm']) |
✅ 照常运行 |
| 设置页路由 | ctx.inject(['webServer']) |
⛔ 只有它不注册 |
load 套件有一条防回归断言:mod.inject 必须为空数组 —— 谁再把
webServer 提升成整个插件的准入门槛,测试立刻变红。
⚠️ 为什么不用 defineTool
@deepseek-ai/dsh-tools 不在 profile 的可解析范围内(profile 只暴露 cosmokit 与 schemastery),插件的 import 必然失败。
好在 ctx.tools.register() 要的本来就是纯 JSON Schema(内部调 assertSupportedJsonSchema,不是 spec 转换器),所以 src/schema.mjs 自己做转换。两个硬性要求:
- 可选字段必须完全不写
required;写required: false会被拒绝 required只能出现在对象根,属性节点上带着它会被拒绝
schema 套件用 DSH 真实的校验器验证这两点,而不是靠约定。
与同机其它插件的关系
同机还装着 dsh-preset-md(伙伴设置),它也占用 settings.section。两者是并列的两页,互不替换:slot id 不同、各插各的 <style>(前缀 .mmd- vs .pmd-)、不共享模块。
样式令牌与参数行布局沿用 dsh-preset-md 的约定 —— 两个页面并排时不该长得像两个产品。这是刻意的视觉对齐,不是代码复用。
⚠️ client 半不能引用已消失的包
真事故:两个设置页同时变成「裸 HTML」。
根因是两者的 client 都 require('@deepseek-ai/dsh-client-ui-primitives'),
而该包在当前 DSH 版本里已不存在(find 全盘无结果,dsh/package.json 也未提及):
FAIL @deepseek-ai/dsh-client-ui-primitives MODULE_NOT_FOUND
OK @deepseek-ai/dsh-client-ui-settings
dsh-preset-md 在 factory 顶层解构它,所以一抛错 apply 就从不执行 →
ensureStyles() 从不调用 → 整页裸样式。
教训:client 半的任何外部 require 都必须先确认包真的存在,
不能靠「别的插件这么写」来推断。
client 套件把这件事写成显式白名单 —— 新增依赖必须同时改测试,
并且点名禁止 primitives 再被引入。
八、卸载
# 1. 从 profile 的 package.json 移除 dsh-memory-md(dependencies 与 bundles 两处)
# 2. 删链接并重装
cd ~/.dsh/profiles/web/node_modules && cmd //c "rmdir dsh-memory-md"
cd ~/.dsh/profiles/web && pnpm install
记忆文件不会被删(在 ~/.dsh/memory-md/),需要时手动清理。
No comments yet. Be the first to write one.