dsh-memory
DeepSeek Harness 的跨会话持久记忆:模型自己判断值得记什么、自己写入、后续会话自动召回的那一层。
这不是 AGENTS.md / CLAUDE.md —— 那层 dsh 已经由 @deepseek-ai/dsh-agent-instructions 做掉了,而且做得更完整。这里补的是它上面那一层:人没有写下来、但模型在干活过程中发现值得留住的事实。
由三个部件组成
1. memory 工具 —— 模型可调用,六个动作:
| action | 作用 |
|---|---|
write |
建一条或整条替换(name + description + type + content,可选 scope) |
read |
按 name 读回全文;names 可一次取多条 |
list |
列出索引摘要(不含正文),可按 type 过滤;附带待写清单 |
search |
按内容检索,返回命中片段 |
edit |
局部改写:按 old_string / new_string 锚点替换,或只换 description / type |
delete |
删除一条并重建索引 |
2. 一个常驻的 system-prompt section(order: 50)—— 每次 prompt 组装时把索引注入上下文。
3. 一个捆绑的 memory-maintenance 技能 —— 记忆库需要整体过一遍时用:合并重复、清理过期、修悬空链接。只在 host 挂载了 skills 服务时才注册,没挂载就静默跳过。
关键设计:索引常驻,正文按需
每条记忆在上下文里只占一行 name — description,模型看到相关的才用 read 拉全文,或用 search 直接按内容命中。几十条记忆只花几百 token,而不是把全部正文塞进每一次请求。
索引按 type 分组注入,user / feedback 在前 —— 这两类会改变模型的行为,project / reference 只是背景。
两层作用域
$DSH_HOME/memory/
├─ global/ # 跨项目共享
│ ├─ MEMORY.md
│ └─ prefers-chinese.md
└─ C--Users-x-repo/ # 按 cwd 隔离
├─ MEMORY.md
└─ otc-deploy-target.md
默认(scope: layered)两层同时可见,索引里全局条目标 [global]。写入默认落项目层,write 时传 scope: "global" 才落全局层。同名时项目层遮蔽全局层。
这样"这个人是谁、希望你怎么工作"可以写一次全项目通用,而"这个仓库在做什么"仍然不会串味。
单条记忆的格式:
---
name: prefers-chinese
description: 用户要求所有回复使用中文
metadata:
type: feedback
created: 2026-08-21T03:11:07.412Z
updated: 2026-08-21T03:11:07.412Z
---
所有回复用中文。
**Why:** 用户母语。
**How to apply:** 包括代码注释。
四种 type:user(这个人是谁)· feedback(希望你怎么工作,含原因)· project(在做什么,写绝对日期)· reference(外部资源指针)。正文里用 [[other-name]] 互链,包括还没写的名字——见下面「链到还没写的记忆是特性」。
created 在同名更新时会被保留,updated 每次改写刷新。老格式(没有这两个字段)的记忆照常可读,时间戳落回文件 mtime。
性能
索引注入发生在每一次 prompt 组装,也就是模型每走一步都会跑一遍。天真实现是每次把目录里所有记忆全文读一遍再解析 —— 120 条记忆时这在本机实测约 107 ms/次,是白白加在每一轮上的延迟。
这里用签名缓存:每次只做一次 readdir + 每个文件一次 stat(不读内容),拼成 文件名:mtime:size 的签名。签名没变就直接返回上次的结果,连渲染都跳过;签名变了也只重读真正变过的那几个文件。同样 120 条记忆,约 1.8 ms/次。
自己写完的文件会显式失效缓存,不依赖签名 —— 同一毫秒内把一条记忆改成等长的另一份内容时 mtime 和 size 都不变,只靠签名会漏。外部进程(另一个会话、手工编辑)的改动则由签名捕获。
search、反向链接、悬空链接检测全部跑在这份缓存上,不额外碰磁盘。
配置
| key | 默认 | 说明 |
|---|---|---|
dshHome |
$DSH_HOME 或 ~/.dsh |
记忆根目录的父级 |
scope |
layered |
layered 两层都可见;project 只按 cwd 隔离的那层;global 只共享层 |
cwd |
$DSH_CWD 或 process.cwd() |
决定项目 slug |
indexBudgetBytes |
16384 |
注入索引的字节上限 |
maintenanceSkill |
true |
设为 false 则不注册捆绑技能 |
装法
作为 preset 的一行(推荐,这样是 agent-plane,每个 preset 自己决定要不要):
- id: memory
name: /absolute/path/to/dsh-memory/index.js
preset 里绝对路径按原样解析(mount 会转成 file: URL 再 import,空格会被正确编码),相对路径按 preset 目录解析。npm 安装进 profile 之后可换成裸包名 dsh-memory。
作为 profile bundle 的一行(全局生效,但会落到 host 的全局层):
dsh plugin --profile <name> add <本包绝对路径>
本包根 package.json 声明了 dsh.bundle.patch,所以 dsh plugin add 会自动把它追加进该 profile 的 dsh.profile.bundles 并应用 cordis.patch.yml —— 不需要手工编辑 profile 的 package.json。反过来说,路径指错时只会打一条 warning,不报错,是静默失效,装完记得确认插件真的挂上了。
捆绑技能要生效还需要该 preset 挂了 skills 服务(skill-filesystem / tool-skill 之类)。没挂就只是少一个技能,memory 工具和索引注入照常工作。
权限与副作用
装这个插件之前,它会碰什么、不会碰什么:
| 磁盘 | 只读写 $DSH_HOME/memory/ 下自己那两层目录(global/ 和项目 slug 目录)。不读、不写工作区里的任何文件。 |
| 网络 | 无。不发任何请求,也不带任何运行时依赖。 |
| 子进程 | 无。 |
| 生命周期脚本 | 无。纯 ESM,无构建步骤,scripts 里只有 test。所以从 git 直接装时不会撞上 pnpm 对 prepare 的构建门禁。 |
| host 服务 | tools、systemPrompt 必需,缺任一在装配时立即报错而不是静默降级;skills 可选,只用来注册捆绑技能。 |
| 上下文 | 注入一个 order: 50 的 system-prompt section,默认上限 16 KiB(indexBudgetBytes 可调)。不注入记忆正文。 |
信任边界:记忆正文是模型自己写进去的,属于不可信文本。注入前会转义 </system-reminder>,description 里的换行会被压平,所以记忆内容既关不掉插件自己的注入框,也塞不进第二个 frontmatter 头。但记忆内容本身仍然会被模型当作背景读到——注入的措辞明确说了那是过去的记录、不是用户当前的指令,不过如果你的部署里有人能往 $DSH_HOME/memory/ 里写文件,那等于能往每次请求的上下文里写字,按这个前提设权限。
卸载
dsh plugin 把参数转发给 profile 目录里的 pnpm,所以卸载就是 remove:
dsh plugin --profile <name> remove dsh-memory
装在 preset 里的话,删掉那一行即可。
两种方式都不会删记忆文件——它们在 $DSH_HOME/memory/ 下,是你的数据不是插件的。要一并清掉就手工删那个目录;只想清掉某个项目的,删对应的项目 slug 目录,global/ 留着。
几个刻意的取舍
MEMORY.md 是给人看的镜像,不是注入源。 每层各有一份,写入/删除后从磁盘上真实存在的文件重建。模型看到的索引是每次组装时重新扫 .md 文件本身扫出来的,不读 MEMORY.md —— 手工编辑它对模型零影响。文件头部自己写了这句话,免得下次有人对着它改半天。
索引是派生物,不做增量维护。 手改坏、并发写、进程中途挂掉都不会让它和实际文件长期失配。
超预算时按 type 优先级裁,不按字母序截尾。 先保 user / feedback,同类里保最近更新的,并明确写出「还有 N 条被省略,用 list / search 够得到」。按名字尾部截断等于让字母表决定模型记得什么。
记忆文本会被转义。 记忆内容里的 </system-reminder> 字面量会被转义,模型写进记忆的文本关不掉插件自己的注入框。插件自己的框不转义。description 里的换行会被压平,塞不进第二个 frontmatter 头。
局部编辑不经过 frontmatter。 edit 的 old_string 只在正文里匹配,改完之后 frontmatter 由插件重新渲染。所以无论锚点匹配到什么、new_string 里塞了什么,都改不动 name / created,也注入不进第二个文档头。替换按下标切片做,不走 String.replace——否则 new_string 里的 $&、$1 会被当成替换模式解释。锚点不唯一时直接拒绝并说明出现了几次,除非显式 replace_all。
链到还没写的记忆是特性,不是错误。 [[some-name]] 指向一条尚不存在的记忆,记录的是"这件事值得单独写一条"。所以 write 不会为此报警;这些名字被 list 收集成 unwritten 待写清单,按被引用次数排序——被最多条记忆惦记的那个,就是记忆库最明显缺的那块。read 也会把某条记忆里尚未写的链接单独列出来。
唯一需要修的情况是名字曾经存在过:delete 会点名谁还在引用它,让你决定这件事是搬走了(那就把引用指过去)还是不成立了(那就留着,它退回成一条待办)。
近重复只提示,不拦。 write 一条和已有记忆高度相似的新条目时会给出候选名,判断权留给模型。
损坏的文件被跳过,不影响其余。 没有 frontmatter 或读取失败的 .md 不进索引,其他记忆照常工作。整个 section provider 外面还有一层兜底:记忆库坏了也只是不注入,不会让会话起不来。
注入的措辞明确说了这是背景、可能过时。 read 一条超过 14 天的记忆时,结果里会直接标出它有多旧,并提醒先确认它提到的东西还在。
测试
npm test
56 个用例,两个文件:test/memory.test.js 覆盖插件装配、工具的六个动作、分层与遮蔽、待写清单、近重复提示、注入转义、预算裁剪、output.schema 一致性,以及 edit 的几条边界(锚点不唯一、锚点碰不到 frontmatter、$& 是字面量、拒绝路径不落盘);test/store.test.js 覆盖缓存的正确性与性能 —— 包括"内容被等长替换且 mtime 复原时确实没读盘"这种直接验证缓存生效的用例。
与 Claude Code auto-memory 的关系
行为目标是对标它:一条事实一个文件、四种 type、[[name]] 互链(包括"链到还没写的名字是特性"这个立场)、索引常驻上下文、读取时提示陈旧度、配一个维护技能。实现是独立写的,提示词措辞、分层作用域、签名缓存、search / 批量 read / edit 局部改写 / 待写清单这些都是本包自己的。
有一处它更强而本包补齐得晚:Claude Code 的记忆是普通文件,可以用通用编辑工具改一句话;本包的记忆在 $DSH_HOME 下、模型只能走 memory 这一个接口,所以 v0.2.1 才补上 edit。反过来,本包的索引是从文件派生的,不像手工维护的 MEMORY.md 那样会和真实文件漂移。
License
MIT
No comments yet. Be the first to write one.