dsh-memory
DeepSeek Harness(dsh)的分层记忆插件: 项目级 / 全局记忆严格分离,任务前自动召回,任务后自动学习,不依赖模型"记得去复盘"。
- 当前版本 v0.4.0|变更
CHANGELOG.md|跨宿主升级UPGRADE.md - 宿主兼容见支持矩阵;Node
^22.19 || >=24(依赖内置node:sqlite) - 类型:host 插件(cordis v4),TypeScript / ESM,运行期零第三方依赖
- 设计存档(唯一决策来源):
docs/DESIGN.md
它做什么
| 阶段 | 行为 |
|---|---|
| 会话开始 | 注入常驻段:协议段(≤240 tok)+ 索引摘要(≤150)+ L5 偏好(≤200),合计 ≤590 tok |
| 每轮第 1 步 | agent/pre-step 召回:项目记忆 + 关键词命中的全局教训(≤600 tok / ≤5 条,会话内幂等去重) |
| 干活时 | 11 个工具按需读写(见工具),会话内工具输出有累计预算 |
| 轮次结束 | 只对"疼痛轮次"做有界蒸馏:15s 超时、每会话 3 次、每日 200k tok |
| 任务收尾 | 导出文本视图 + 记忆根本地 git 提交(绝不 push) |
| 每 5 任务 / 7 天 | 惰性巩固:过期归档、置信度衰减、矛盾检测、晋升提案 |
三条不变量:项目记忆只写 <repo>/.dsh/memory、全局只写 $DSH_HOME/memory(默认 ~/.dsh/memory),跨库写入硬阻断;
插件故障不伤宿主(钩子整体 try/catch);绝不写 stdout/stderr(日志只落文件)。
快速开始
# 发布版(推荐,固定 tag)
dsh plugin --profile nvim-tui add github:kovey/dsh-memory#v0.4.0
# 再把 "dsh-memory" 追加到 ~/.dsh/profiles/<profile>/package.json 的 dsh.profile.bundles
# ⚠️ dsh plugin add 只是在 profile 里执行 pnpm;只 add 不加 bundles = 装了但不加载
# 本地开发(link 到工作区;tui 是生产面,脚本默认拒绝操作它)
ln -sfn "$PWD" ~/.dsh/profiles/node_modules/dsh-memory
./scripts/install-into-dsh.sh --dry-run # 先看要改什么
./scripts/install-into-dsh.sh # nvim-tui / web / headless
重启 profile 后,~/.dsh/memory-plugin.log 出现 memory: ready (…) 即为就绪。
覆盖配置写在 profile 的 cordis.patch.yml,按 id 覆盖、不要重复 insert(loader 对重复 id 直接抛错):
- id: memory
config:
recall: { autoInject: false } # 只关自动召回
prompt: { protocol: { enabled: false } } # 关掉常驻协议段
dsh --patch <file>的 overlay 实测不作用于本插件 config(六次真机运行 ready 行始终是默认值)——调参请用 profile patch。
支持哪些宿主和端
端无关。 插件只注入 ctx.tools 与 ctx.systemPrompt、只订阅核心 agent 事件,没有任何 surface 专用代码,
所以 nvim-tui / web / headless 同构可用。官方桌面端就是 web 应用的 Electron 壳,同样零适配——
但它有独立的 $DSH_HOME/profiles/desktop,插件要单独装进那个 profile(CLI 的 web/tui profile 不共享)。
真正会挡住它的是宿主内核版本:5 个 @deepseek-ai/dsh-* peer 不匹配时,加载器会静默跳过整条 bundle
(只写日志,记忆功能无声消失)。
| 宿主 dsh | 该用的 dsh-memory |
|---|---|
0.2.0-rc.2 / 0.2.1-rc.x / 0.2.1 |
v0.4.0(范围锚定 >=0.2.0-rc.2 <0.2.2) |
0.1.7-rc.2 |
v0.2.1 |
0.1.7-rc.1 |
v0.2.0 |
0.2.0-rc.1 |
v0.2.2 |
≥0.2.2 |
无(需要下一次适配) |
被跳过时日志/启动 stderr 会出现 Plugin dsh-memory@… is incompatible with dsh …。
dsh plugin allow-version 能对精确 插件@版本 × 内核 显式豁免,但它只跳门禁、不解决 API 差异,跨大版本别用。
记忆放在哪
| 层 | 位置 | 生命周期 |
|---|---|---|
| L1 情节 | <root>/sessions/*.jsonl + signals 表 |
90 天、脱敏、从不注入 |
| L2 项目记忆 | <repo>/.dsh/memory/lessons/*.md |
长期,每任务必召回 |
| L3 全局记忆 | ~/.dsh/memory/lessons/*.md |
长期,关键词命中才召回 |
| L4 技能 | 草稿 <root>/proposals/<id>.SKILL.md → 人审后装到 ~/.dsh/skills/<name>/SKILL.md |
人审闸门 |
| L5 偏好 | <root>/profile/*.md |
常驻提示词(≤200 tok) |
<root>(项目根或全局根)内固定这些内容:
lessons/*.md 记录,唯一真源(进 git)
MEMORY.md 人读索引,自动重生成(勿手改结构)
metrics.jsonl 任务指标(进 git)
profile/*.md L5 偏好(进 git)
baseline.md 评估基线任务集(人所有,插件只读)
sessions/ archive/ proposals/ 情节 / 归档 / 晋升草稿
memory.db(+wal/shm) 派生索引,不入库(.gitignore 已含)
全局根默认 ~/.dsh/memory,可用 DSH_MEMORY_HOME 重定向(测试用);项目根由会话 header.cwd
经 git 解析得到(不看进程 cwd,同一仓库的 linked worktree 共享一个记忆根)。
工具
| 工具 | 用途 |
|---|---|
memory_search / memory_get |
检索(标题+摘要)/ 按 id 读全文 |
memory_recall |
一次取回召回包(已排序、按预算裁剪) |
memory_save |
写长期经验(默认项目级;同一门控负责去重/合并/拒收);pin: true/false 钉住/取消(免衰减、免归档、召回下限 0.6,每层上限 20) |
memory_stats |
健康度(含 pinned 数)+ 评估门禁 + 趋势 + 当月花费 |
memory_consolidate / memory_forget |
巩固(默认 dry-run)/ 退役(归档不删) |
memory_sync |
提交 → fetch/rebase → 冲突按规则合并 → 重建(push=true 才推) |
memory_reindex |
从文本视图重建索引;rebuild=true 全量重建(只删盘上已无、且早于上次成功导出的记录;force=true 才连未导出的也删) |
memory_import / memory_config |
导入外部 lesson / 会话级降噪(不落盘) |
注册失败的工具不会出现在常驻协议段里——能力由实际注册结果推导,不是"打算注册什么"。
常用配置
默认值全在 cordis.patch.yml,profile patch 里只写要改的:
| 键 | 默认 | 说明 |
|---|---|---|
recall.autoInject / budgetTokens / maxItems / minScore |
true / 600 / 5 / 0.35 |
每轮自动召回 |
recall.sessionToolBudgetTokens |
20000 | 会话内工具输出累计上限 |
recall.maxBodyChars |
220 | 单条命中保留的正文长度(实测 76% 记录超过它,调大即多带细节、也多花 token) |
recall.quota.globalMinTokens / projectMaxTokens |
0 / 0 | 分层配额:全局保底 / 项目封顶,两者都 0 = 关闭(默认行为) |
routing.subagentWrite |
false |
子代理默认不能写记忆(写类工具统一拒绝) |
learn.collectSignals |
true |
信号采集:失败命令 / 请求失败 / 用户纠正 / 返工 |
learn.autoDistill / distillTimeoutMs / maxDistillPerSession |
true / 15000 / 3 |
疼痛轮次蒸馏 |
learn.distillRunner |
inline |
inline 任何端都成立;jobs 让轮次立即结束(长驻会话) |
learn.distillModel |
空 | 留空 = 沿用当前会话自己的 provider/model |
learn.exitCodeSignals |
strong |
strong=exit≥2 与错误标记;all=任何非零;off |
episodic.retentionDays / captureUserText |
90 / redacted |
情节保留与脱敏 |
consolidate.everyNTasks / everyDays |
5 / 7 | 惰性巩固触发频率 |
eval.autoFreezeBaseline |
false |
基线默认人工冻结(见评估门禁) |
git.autoCommit / checkpointMinutes |
task-end / 30 |
off / task-end / immediate;插件永不 push(git.autoPush 是字面量 false,没有开关) |
sqlite.journalMode / maxOpenRoots |
wal / 4 |
派生库 |
semantic.enabled / maxTokensPerDay |
false / 200000 |
语义召回;日预算,0 = 不限 |
完整键表与取值范围以 src/config.ts 的 DEFAULT_CONFIG 为准。
语义召回(可选,默认关)
词法检索(FTS5 + CJK bigram)零成本、离线可用,是默认路径;只在词法召回不足时才调用 embedding, 失败静默降级为词法,向量按内容哈希缓存、未变动的记录不重复嵌入。默认关是因为它是除蒸馏外唯一会花钱的路径。
- id: memory
config:
semantic:
enabled: true
baseUrl: 'http://127.0.0.1:11434/v1' # OpenAI 兼容 /embeddings;也可用 baseUrlEnv
model: 'bge-m3'
timeoutMs: 8000 # 冷启动要重载模型(~2.8s),默认 1.5s 会把自己中断
minLexicalHits: 3 # 词法已召回 ≥3 条时不再调用
maxAdditions: 2 # 每次最多补几条纯语义命中
maxTokensPerDay: 200000 # 日预算(0 = 不限);耗尽后降级为词法
19 条真实教训上的对照(复跑需先 npm run build:node scripts/eval-semantic.ts --root ~/.dsh/memory --minLex 3 --weight 0.3 --minSim 0.5 --maxAdd 2):
| 指标 | 词法 | 混合 |
|---|---|---|
| Top-1 命中 | 6/9 | 6/9 |
| Top-3 命中 | 8/9 | 9/9 |
| 改写问法 Top-3 | 5/6 | 6/6 |
| 平均耗时 | 0ms | +33ms |
| 注入 token | 2670 | 3606 (+35%) |
结论:它救的是"换个说法就搜不到"那类查询,对原词查询无影响。
前提是端点真有 embedding 模型——本机网关没有该通道(text-embedding-3-small / bge-m3 等全部
model_not_found),所以默认配置下无法真机跑通;接上本地 Ollama 或独立端点后,用
memory_reindex({ embeddings: true }) 做一次全量回填,memory_stats 会显示索引量与最近错误。
上下文会不会被撑爆
不会。三条入口都有硬上限,与库大小无关:
| 入口 | 上限 |
|---|---|
| 常驻段 | 协议 240 + 索引 150 + L5 200 = ≤590 tok |
| 自动召回 | 600 tok / ≤5 条,同会话幂等去重 |
| 工具输出 | 单次 memory_recall ≤4000 tok;会话累计 20k 后明确拒答并说明如何调整 |
实测 55 次注入:中位 234 tok、最大 592。增长的只有存储,且都有保留策略 (情节 90 天、召回记账 180 天、换模型后的旧向量 30 天宽限;记录只归档不删除)。
评估门禁
memory_stats({ windowDays: 30 }) // 健康度 + 门禁判定:pass / regression / unknown
memory_stats({ setBaseline: true, baselineReason: "用户要求;v0.4.0 发布后 7 天无回归" })
对比四项指标:成功率↑、耗时↓、打扰↓、返工↓(容差:成功率 ±0.05、耗时 ±15%、打扰/返工 ±0.5)。
没有基线时判定只能是 unknown——冻结基线是人工步骤:需要理由,子代理调用会被拒。
baseline.md 里的基线任务集属于其他仓库,插件无法自动重放,只会列出清单供人工回归;
它能自动判定的是任务日志实际承载的指标。
排障
| 现象 | 先看什么 |
|---|---|
| 完全没生效 | ~/.dsh/memory-plugin.log 有没有 memory: ready;没有多半是 bundle 被跳过(peer 版本,见支持矩阵) |
工具报 store unavailable |
Node 是否带 node:sqlite(node -p "process.versions.node",需 ≥22.19 或 ≥24 的构建) |
| 召回总是空 | 该作用域有没有 active 记录;minScore 是否过高;memory_config({}) 看会话覆盖 |
| 蒸馏没发生 | 该轮是否够"疼痛"(minSignals)、每会话/每日预算是否已用尽 |
| 语义检索没贡献 | memory_stats 的索引行;多半是没配端点或没回填(memory_reindex({ embeddings: true })) |
memory_stats 报 text view may lag |
库比 git 里的文本视图新(某次导出失败):先让一次导出发生(例如 memory_save,写入即全量导出),再考虑 rebuild——护栏会保留这些记录 |
日志 ~/.dsh/memory-plugin.log 超过 5 MB 自动轮转为 .log.1。插件从不写 stdout:
TUI 拥有 stdout,web server 有自己的 console。
开发
npm install # peer @deepseek-ai/* 已在 devDependencies(干净 clone 也能构建)
npm run build # tsc → dist/(dist/ 入库,github: 安装免构建)
npm test # 构建后 node --test 跑全量(不写死用例数——写死的数字会过期)
npm run typecheck # 只做类型检查
测试全部在仓库内 .tmp-tests/ 运行,并把全局记忆根重定向到临时目录,不会触碰真实的 ~/.dsh/memory。
发布与安装链路(dev 链 vs 发布链、tui 为何拒绝 link)见 docs/RELEASE.md。
真机验证(隔离 profile + 隔离记忆根,不碰真实记忆):
mkdir -p ~/.dsh/profiles/memlive && printf '[]\n' > ~/.dsh/profiles/memlive/cordis.patch.yml
cat > ~/.dsh/profiles/memlive/package.json <<'JSON'
{ "name": "dsh-profile-memlive", "private": true, "dependencies": {},
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless", "dsh-memory"] } } }
JSON
cp -R ~/.dsh/memory/lessons /tmp/dsh-live-mem/lessons # 用真实语料播种,便于验证召回
DSH_MEMORY_HOME=/tmp/dsh-live-mem dsh --profile memlive "调用 memory_stats 并汇报记录数"
tail -20 ~/.dsh/memory-plugin.log # 期望:memory: ready + 注入行
文档
| 文档 | 内容 |
|---|---|
docs/DESIGN.md |
设计存档:分层模型、双库隔离、钩子方案、里程碑与实机验证记录 |
docs/RELEASE.md |
dev 链路 vs 发布链路、发布前检查、装进 profile 的步骤与回退 |
docs/REVIEW-2026-09-14.md |
一次全面代码审查:59 条发现与修复清单 |
CHANGELOG.md / UPGRADE.md |
版本变更(Keep a Changelog)/ 跨宿主版本的升级指引 |
No comments yet. Be the first to write one.