dsh-memory
一个 DeepSeek Harness 插件:把「跨对话的记忆」做成一摞本地 Markdown + 一张索引表,再配上一套管理界面。 零依赖、无构建,纯手写 JavaScript(Host 半 + Client 半)。
模型平时的记忆成本只有系统提示词里的那一行大纲;只有压缩流水线(每 100 轮或点一次沙漏)才会调模型。
它做什么
- 一行大纲进系统提示词:按分类分组的索引(
【教训】/【资料】/【说明书】/【项目记忆】/【其它】),教训永远排第一;关键词写的是「以后什么场景该想起它」,不是摘要。 - 四个记忆工具:
memory_read/memory_write/memory_update/memory_delete,锁定项一律拒写;项目记忆只走人工通道。 - 教训三段式:写教训时强制填「场景 / 错误链条 / 绕开与修复」,并自动在大纲末尾追加一句「动手前先过一遍【教训】」。
- 设置页「记忆管理」:搜索、分类折叠、新建/删除、待审批改稿的 diff 审阅、压缩状态。
- 记忆日志页:会话顶部多一个「记忆」页签,仿轨迹列表 —— 操作分色标签、行内结果摘要、右侧详情(来源/类型/关键词/状态/时间/完整正文),来源可一跳跳回轨迹里那次工具调用。
- 回忆统计:一轮 = 用户按一次回车;窗口内从没被回忆过、且复核日超期的条目会被标成待删除候选。
快速安装
只能走 DSH 的 plugin_manager(官方唯一支持的安装路径):
plugin_manager { "action": "install_bundle", "target": "<本仓库的绝对路径>" }
- Host 改动(
index.js)必须重启 DSH 生效; - Client 改动(
client.js)刷新页面即可; - 卸载:
plugin_manager { "action": "remove_bundle", "target": "@local/dsh-memory" }。
已知边界
- 依赖宿主的私有槽位(
settings.section、conversation.view、commandUi.register等),DSH 大版本升级后需要复核一次。 - 设置页导航图标的字形由外壳按 section id 写死,本插件借用了
archived-sessions这个 id 来拿到归档图标(否则会落回齿轮)。 - 记忆日志页是独立的
conversation.view页签:外壳的轨迹行类型是固定枚举,第三方插件不能往里插新行类型。 - 数据全在本机
~/.dsh/memory/,不上传、不埋点;只有压缩流水线会把记忆正文发给当前配置的模型。
数据面
~/.dsh/memory/
MEMORY.md:7 列索引表(文件|关键词|启用|类型|何时读|复核|受保护),唯一索引。*.md:一份记忆一个文件,YAML 头写name/description/type/reviewed,下面是正文。.tidy/status.json:上次整理运行的记录,只有lastRun / result(ok|failed) / detail。.tidy/proposed/*.md:待审批的改稿,只存正文;存在即代表有待审批方案。.tidy/turns.json:全局用户轮计数{count};.tidy/recall.json:回忆流水[{turn,file,ts}]。.tidy/compress.json:压缩流水线状态{startedAt,finishedAt,stage,reason,files,detail}。
status.json 不存 state / count。 待审批份数的唯一事实源是 proposed/ 目录里有没有文件,由 snapshot() 现算;写进 status.json 就会在批准/忽略之后与事实不一致。读 status.json 时剥 BOM(PowerShell 5.1 写文件必带 BOM,JSON.parse 会直接抛错)。
回忆率与「待删除」也是现算的:不进索引表,索引只存人写的事实。一轮 = 用户按一次回车(session/event 的 user/message 且 source.kind === 'user'),工具调用、子代理、goal 自动续轮都不计数。窗口 = 最近 min(100, 总轮数) 个用户轮;分子 = 这些轮里读过它的轮数(一轮读多次算一次);pendingDelete = 窗口内一次没回忆过 且 复核日距今 ≥ 14 天 且 启用 且 未锁定。2026-10-29 修掉一处错位:早先 recordRecall 把读记成 turnCount + 1(下一轮),当轮读的记忆要等用户再发一条才进窗口;现在记在当前轮,读完立刻计入。
批准只改正文:proposed/ 里只有正文,approveFile() 沿用原文件的 YAML 头(所以 description 不会被改稿冲掉),刷新复核日,删掉方案。
Host 半边(index.js)
系统提示词两段
memory:outline(order 9890):按分类分组的多行索引,取自MEMORY.md,停用条目不出现。第一行说明格式与用法(「名称=关键词」、关键词=「以后什么场景该想起它」、正文用memory_read取),其后每行一组:【教训】…、【资料】…、【说明书】…、【项目记忆】…;组名已表明分类,关键词里省掉重复的前缀。教训组永远排第一。 单条关键词超过 42 字时在/边界裁剪并加…(预算扣掉分类前缀);不做整行截断,任何一条记忆都不会从大纲里消失(旧的 240 字整行截断实测丢过huashu-motion-install)。 只要存在启用的教训,末尾追加一句前置检查:「动手前先过一遍【教训】:这次任务命中哪个场景,就先 memory_read 读完再开始,不要等报了错才回来找。」memory:tidy-status(order 9900):正常时零输出,异常才一行「待审批 N 份 / 上次失败:原因」。
关键词口径(2026-10-29 定稿):关键词不是摘要,是「以后什么情况下能想起它」的触发词。
- 资料 → 这份资料是什么内容类型(
资料/身份/联系方式、资料/笔记本规格),不要只写人名; - 教训 → 犯错之前正在做的场景/任务(
教训/插件往会话注入消息、教训/截断长文本、教训/卸载重装DSH),不许写 400 / 422 / 报错 这类事后症状名——那是修的时候才想得起来的抓手; - 说明书 → 对应的工具/插件名(
说明书/btw插件、说明书/context面板)。
教训正文三段式(创建时强制):① 场景(什么任务、什么动作会踩到,读者一看就知道「我现在就在这个场景里」);② 错误链条(第一步做了什么、哪一步开始不对、为什么没察觉);③ 绕开与修复(下次怎么走、已经部署了什么防护)。
四个模型工具:memory_read(名称 / 关键词 / 描述命中,只返回正文)、memory_write、memory_update、memory_delete。三个写操作对锁定的记忆一律拒绝。
首关键词硬约束:memory_write / memory_update 的第一个关键词必须是 资料 / 教训 / 说明书 / 其它 之一;项目记忆 只能由人工通道产生(设置页新建,或大脑按钮开出的 5 分钟窗口,只放行一次)。
两道闸门,都走同一个 memoryKind() 判定(realpath + path.relative,大小写 / .. / 符号链接都归一到同一身份):
| 路径身份 | read |
grep / glob |
|---|---|---|
body 记忆正文 |
拒绝,改用 memory_read |
拒绝 |
root 仓库根 |
放行(read 目录本来就会失败) | 拒绝 |
index(MEMORY.md) |
放行 | 放行 |
internal(.tidy/**) |
放行 | 放行 |
tools/post-execute 对写进 body 的文件做 2000 字软提示:只提示,写入已生效;措辞刻意放轻,说重了模型会为缩字数丢信息。
十四条路由:GET /dsh-memory/status(兼 ?client=1 心跳)、GET /dsh-memory/calls(按会话列记忆调用)与 POST keyword / open / approve-file / reject-file / toggle / protect / create / delete / search / compress / remember / dockping。
记忆调用流水:tools/post-execute 里记下每次 memory_* 调用(会话 id、轮、读/写/改/删、目标、关键词、字数,最多 400 条)到 .tidy/calls.json,供对话界面的「记忆」标签页显示;不进上下文、不写会话历史。
面板只在自身操作后更新,所以另有一条兜底:回到窗口(window.focus)自动重拉快照,工具栏也有「刷新」按钮;索引行在、文件不在的卡片标「文件已丢失」,垃圾桶改叫「确认清理」。
create:人工通道,文件与索引行一起写;索引补不上就把刚建的文件撤回,不留孤儿。delete:锁定的拒绝;删文件 + 删索引行 + 顺带清掉指向它的未审方案。文件已经不在磁盘上的死行也能删(回包ghost: true),否则卡片会永远钉在面板上(2026-10-28 实测:报「文件不存在」,再也清不掉)。search:关键词、「何时读」、frontmatter 描述、正文都算命中;正文命中回原文附近 ±2 行(最多 3 处)。compress:沙漏按钮,立刻跑一次压缩判定(不阻塞,前端轮询status.compress.running)。
/remember 是客户端 action(ctx.commandUi.register,大脑图标进斜杠列表),不产生 command 事件、不写会话历史:点一下只向 POST /dsh-memory/remember 发一个 sessionId:宿主优先借 DSH 的 llm 服务,把 session.deriveMessages() 出来的上下文原样当前缀、末尾追加一句隐藏指令(只存在于这一次请求里),并把 memory_write 的工具声明一起递过去——模型「往里填空」拿到的是协议层的 tool-call-delta 结构化参数,天然合法 JSON,不再出现「模型没有返回 JSON」。llm 服务不可用时退回插件自己发 HTTP + 只带转录 + 同一条工具声明(tool_choice 点名)。回 {file, chars, turns}。状态条挂在 conversation.input.right(输入框控件行):「记忆中」→「记忆完毕,正文长 N」,下一次人类发言(turns 变大)后自动消失。
不要用 agent.followup() 往会话里塞提示词:它收的是完整 UserMessage,少 id / role 会把这条消息写进会话日志,DeepSeek 侧序列化出没有 role 的 messages[i],整个会话从此每轮 422。
压缩流水线(每 100 轮 / 沙漏)
规则(2026-10-27 定稿):
| 窗口内回忆次数 | 距今 < 2 周 | 距今 ≥ 2 周 |
|---|---|---|
| 0 次 | 中删减 | 报备删除(只标「待删除」,不花 token) |
| 1 次 | 轻调整 | 中删减 |
| 2–10 次 | 轻调整 | 轻调整 |
| ≥11 次 | 不送 | 不送 |
另有两类永远不送模型:锁定、已有待审改稿。触发点两个:全局轮计数每满 100 轮(turnCount % 100 === 0,只数人类消息),以及设置页沙漏按钮。
送模型的内容:一组「关键词 + 正文」+ 固定系统提示词(model: deepseek-flash、thinking:{type:'disabled'}、response_format: json_object、max_tokens: 8192),不带任何 DSH 工具提示。目标篇幅:轻调整 75%–90%、中删减 50%–70%(2026-10-28 用户定稿);这些记忆写得很密,实测模型通常只能压到 95% 左右,所以比例是「尽量」不是门槛——只要比原文短 3% 就先给人看,没达标的在 detail 里标「未达目标比例」。压缩中途被关掉时 compress.json 只剩 startedAt,超过 15 分钟没 finishedAt 就在面板上显示「上次压缩中断」并把沙漏变色。
串行对话 + 前缀缓存(2026-10-28):同一档位的所有批次共用一条 messages 链(批次 → 助手改稿 → 下一批),DeepSeek 的自动前缀缓存因此一轮比一轮吃得多。实测同一组记忆:第 1 批 0%、重压那轮 65%、第 2 批 77%、重压 83%。每轮命中数/未命中数都写进 .tidy/compress.log。
两个实测坑(2026-10-28,用户报「点了压缩没反应」):
deepseek-flash默认就开思考,thinking字段不传 ≠ 关闭,必须显式{type:'disabled'}。- 思考 +
json_object一起上时,思考可能把整个输出预算吃光:回包finish=stop、completion_tokens全是reasoning_tokens、content为空,表现为「跑完了但什么都没有」。用户 2026-10-28 定稿:压缩一律thinking:{type:'disabled'},不要再思考;回包为空或被截断就原样重试一次;每条请求最多送 4 份(COMPRESS_BATCH),单批失败不拖垮整次压缩,失败原因进compress.json.detail、面板红条与.tidy/compress.log。
Client 半边(client.js)
设置页「记忆管理」:
- 顶部工具栏:「新建记忆」(l2 轮廓)、搜索框(去抖 250 ms,命中后只留相关卡片并把原文片段贴回卡片)、沙漏按钮(压缩中显示「压缩中」)、右上角提示条。
- 卡片按首关键词聚类成
资料 / 教训 / 说明书 / 项目记忆 / 其它 / 未分类,分类行可点折叠(只在界面上折叠,模型仍看到全部关键词);搜索时自动展开全部组。 - 每张卡片:关键词双击改名、启用开关、锁定按钮、参数展开(含创建/更新时间,精确到秒)、打开位置、
回忆率 N%、待删除标记、右侧垃圾桶两段式确认、审核(双栏 diff:删除行红底黑字、新增行绿底黑字,每栏顶部显示各自总字数;两版相同时禁用「批准」)。 - 顶部有「上次压缩中断」与「上次压缩失败」提示条;底部状态汇总与当前大纲原文。
/remember命令:对话里只出现一行「记忆中」,跑完变「已记住本对话」,外观由conversation.chat.commandview的remember行接管(大脑图标)。- 「记忆」标签页(
conversation.view,idmemory-calls,order 20):与「对话」「轨迹」并列。左边是按会话的记忆日志,单行一条:操作标签(回忆/记忆/改写/删除/对话归纳,各自配色)+ 文件名 + 关键词;时间不进行,只在详情里。时间正序(老的在上、新的在下),首屏滚到最新一条,用户贴着底部时才跟随新记录。点一行在右侧开详情(仿轨迹检查器):来源(工具/对话归纳)、类型、关键词、状态、时间、会话、字数、完整正文(回忆=真正注入模型的那段、记忆/改写=写进文件的正文、删除=删掉前的正文),带callId的还有「在轨迹中定位」按钮,直接跳到那次工具调用。列表接口只回轻量字段,正文走/dsh-memory/call?id=单取。正常但没数据时只显示居中的淡色「暂无记忆调用」,加载中什么都不显示;只有接口不可用(404 未重启 / 连不上)才在中间报错。DSH 的轨迹行是固定枚举(system/user/tool/…),第三方插件加不了新的行类,所以做成并列视图而不是轨迹里的一行。
样式只用官方 token(--dsw-menu-surface-fill、--dsw-shadow-lv3、--dsw-radius-* 等);分类下拉是自绘的,不用原生 <select>。设置页图标借用外壳映射里唯一空闲的 archived-sessions id。
界面只放状态、数字、输入框、按钮,不加说明性备注。
工具(tools/)
selfcheck.mjs:mock 一个 cordis ctx 把apply()整条路径跑起来(临时DSH_HOME,不碰真实记忆库),覆盖装载面、大纲、闸门、status、分类约束、回忆统计、新建/删除、搜索、压缩、/remember、调用流水共 173 项。改完先跑它,再考虑重启:node tools/selfcheck.mjs。tidy.mjs+run.cmd:旧的「整理」worker,已不再挂计划任务(压缩不是定时任务)。保留作手动兜底:node tools/tidy.mjs [--dry-run|--if-due],只写proposed/与status.json。
未完成
- 压缩只按「回忆次数」分档;窗口不足 100 轮(手动沙漏提前跑)时用的是次数而不是百分比,语义上等价于按满窗折算。
- 卡片聚类顺序写死为
资料 / 教训 / 说明书 / 项目记忆 / 其它 / 未分类,没有做成可配置。
No comments yet. Be the first to write one.