ububwudb/dsh-memory-agent

dsh-memory-agent

File-based long-term memory plugin for DeepSeek Harness: fixed read budget, BM25 search, confirmation gate, always-on injection, auto-settle. Zero runtime dependencies.

待分类agentbm25deepseek-harness

dsh-memory-agent

给 DeepSeek Harness(DSH / Cordis)用的文件式长上下文记忆插件。

它是一次「整合升级」:把若干记忆插件里已经被验证过的机制——固定阅读预算、BM25 检索、 确认闸门、索引重建、原子写入、显式删除——收进一个零硬依赖的包里,而不是捆绑或依赖它们。

  • 运行时 dependencies 为 {};不捆绑 dsh-persist、dsh-memory-pyramid、@max-null/dsh-memory、

dsh-df-memory、@libai168/dsh-tool-memory,也不依赖任何其他记忆插件。

  • 默认后端就是纯文件(/.agent/memory/),暴露 MemoryBackend 接口供后续替换。
  • 工具 handler 是纯函数:async (backend, input) => output。所以 test/run-fixtures.mjs

可以直接 import('lib/tools/*.js') 传临时 backend 做断言,不需要启动 DSH。

目录

  • 安装
  • 工具契约
  • 存储布局与索引字段
  • Token 口径
  • 检索:BM25,不是子串
  • 固定阅读预算不变量
  • 写入:查重、冲突、确认闸门
  • 当前认知(分层蒸馏)
  • 自动沉淀
  • 检索日志与锚点(P2)
  • 删除联动 forget_memories_by_session
  • 索引重建
  • 配置
  • 零硬依赖与 MemoryBackend
  • 验证:verify.sh
  • 发布:publish.sh
  • 安全与隐私
  • 已知限制

安装

npm i -g dsh-memory-agent        # 或装进某个 DSH profile

package.json 里声明了 bundle patch:

"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }

cordis.patch.yml 顶层是数组,用 insert 增加插件行,name 写 npm 包名(不写源码路径):

- insert:
    - id: memory-agent
      name: dsh-memory-agent
      inject: [tools, systemPrompt, webServer, sessions]
      config:
        root: null
        requireConfirmation: false
        defaultBudget: 1200
        maxTopK: 200
        lifecycle:
          enabled: false
          audit: true

插件入口只导出三样:

export const name = 'dsh-memory-agent' // 包名
export const inject = ['tools']        // 让 Cordis 在 apply 前备好工具注册表
export function apply(ctx, config)     // 只做接线

apply() 只做三件事:创建 backend、把 handler 包成 defineTool(...) 注册到 ctx.tools、 用 ctx.effect 注册清理(卸载时逐个调用注册器返回的 disposer,不留悬空状态)。

defineTool 来自宿主 @deepseek-ai/dsh-tools。若该模块解析不到(三方插件常常解析不到,
它装在宿主自己的 node_modules 里),插件会打印一次警告,并改用内置的
DSL → JSON Schema 编译器把同一份作者 DSL 编成注册表要求的原始 JSON Schema。
两条路径产出同构定义,不会因为一条 import 解析失败而整体不可用;verify 的接线烟测
就是跑降级路径,顺带断言 parameters.type === "object"、required 已经是数组。

UI 面板(给不想改 YAML 的人)

装好后在 设置 → 记忆 里有一块面板:

能做什么 · 说明 看记忆库路径 · 只读展示 /.agent/memory 的绝对路径 看统计 · 全部 / 可检索 / 待确认 / 冲突 / 待清理 / 孤儿 六个计数 改六项行为 · 「写入需要确认」开关、默认阅读预算(token)、检索条数上限、写入前脱敏开关、常驻注入开关(含字符预算 200–8000)、自动沉淀开关(勾完下一轮生效) 搜索 · 输入框 + 「搜索」,走的是和模型同一套 BM25 + 别名;别名推断的命中标「别名」,零命中直接列出常用标签供点选重搜 写一条记忆 · 文本框 + 标签,手写的直接生效(不过确认闸门),不用等模型动手 看详情 · 每行「详情」展开:id / 路径 / 状态 / 时间 / 置信度 / 来源 / 标签 / 冲突现场 / 原文预览 确认 · 待确认的条目一键转「可检索」 冲突收尾 · 冲突行给「保留这条」/「丢弃」:留下的转 active,同槽位其它说法转 superseded 并退出检索 软删 / 恢复 · 软删只是标记(文件保留、退出检索),随时能点「恢复」撤回 重建索引 · 等价 rebuildIndex(),索引坏了点一下就好 清理已软删 · 物理删掉已软删的 episodes/、raw/ 文件;遇到已软删的 topics/ 会跳过并回显「跳过 N 条」 彻底删除主题记忆 · 只在有待清理的 topics/ 时出现,两步确认后连主题文件一起物理删除 按会话忘却 · 列出会话及条数,两步确认后整批忘却(共享 topics 只解绑来源) 导出备份 · 一键把索引 + 所有记忆文件打成 JSON bundle 下载走(≤8MB,超了会标记截断)

改完点「保存设置」立即生效(不用重启宿主):面板写的是 /config.json, 启动时 createRuntimeConfig() 用它覆盖 cordis 配置里的同名字段。 想回到配置文件为准,删掉 config.json 即可。

实现方式(两个半身,都不引入新依赖):

  • 宿主半身 src/host/api.ts:一个路由 POST /memory-agent/api,按 op 分发

(status / config.set / rebuild / confirm / resolve / softDelete / restore / purgeDeleted / search / detail / create / forgetSession / export)。 purgeDeleted 默认只清 episodes/、raw/(并返回 skipped_topics 告诉你跳过了几条); 传 { "includeTopics": true } 才连 topics/ 一起删,返回值里带 removed_topic_count。 三重门槛:只接受 POST + `content-type: application/json`、只放行本机回环、 并且要么 Origin 与 Host 一致、要么带面板自带的 x-dsh-memory-agent: panel 头。 所以 curl -X POST 这种没有 Origin 也没有面板头的请求一律 403; op 白名单、请求体上限 64KB;配置只允许写白名单里的四个字段并做范围收敛 (maxTopK 超 200 会被夹回 200)。宿主没提供 webServer 时安静跳过,工具照常可用。

  • 客户端半身 client/client.js:手写的 window.__ModuleLoader__.load({ id, factory })

产物,不需要打包器,require 只用平台种子表里的 react;通过 ctx.slots.inject("settings.section", () => ctx.slots.register({…, id: "memory-agent"}, MemorySection)) 挂到设置页,ctx.effect 兜住卸载。

面板本身没有任何自动化测试靠肉眼——verify.sh 第 5 步会用一个迷你 React + 假 fetch 把这块面板渲染出来、把按钮点一遍(test/run-client-check.mjs), 再拿假 req/res 把宿主路由打一遍(test/run-api-check.mjs)。

工具契约

九个工具,返回值就是契约本身:

工具 · 入参 · 返回 memory_nav · { path?, limit?, offset?, sort? } · { entries: { id, path, type, title, summary, time }[], total, offset, limit, has_more, facets?, readme? } memory_search_hybrid · { query, tags?, time_range?, entities?, top_k? } · { hits: { id, path, type, title, summary, score, reason }[], guidance } memory_assemble · { task, hits?, budget? } · { context, used_tokens, sources, omitted } write_memory · { summary, raw?, tags?, entities?, source, session_id, confidence?, requireConfirmation?, ... } · { path, id, status, dedupe?, similarity? } memory_confirm · { id } · { id, status: "active" } forget_memories_by_session · { session_id, scope?, mode? } · 见删除联动 memory_resolve · { id, action?: "keep" \ · "drop" } · { id, status, superseded: string[] } memory_sweep · { days?, limit? } · { at, scanned, thresholds, longUnused[], supersededBacklog[] }(只读体检,见体检与使用信号) memory_digest · { key?, refresh? } · 有 key:{ slot, path, text, updated_at, status: "active" \ · "conflict", rebuilt };无 key:{ slots: { slot, path, updated_at, stale, entries, conflict, history, text? }[] },见当前认知

handler 的 TS 形状(lib/tools/*.js 里就是这些函数,纯函数、不依赖 ctx):

navHandler(backend, input): Promise<NavOutput>
searchHandler(backend, input): Promise<Hit[]>
assembleHandler(backend, input): Promise<AssembleOutput>
writeHandler(backend, input): Promise<WriteOutput>
confirmHandler(backend, input): Promise<ConfirmOutput>
forgetHandler(backend, input): Promise<ForgetOutput>
resolveHandler(backend, input): Promise<ResolveOutput>
sweepHandler(backend, input): Promise<SweepOutput>
digestHandler(backend, input): Promise<DigestOutput>

memory_nav 的默认行为是只读摘要层 + 分页:entries 只带 id/path/type/title/summary, 任何正文(尤其 raw/ 全文)都不会被带出来;默认一次只给 50 条(上限 500), 用 offset 翻页、has_more 判断还有没有——库再大也不会一次糊满上下文。 要读全文,必须把 path 显式指到那个文件:

  • nav({}) → 第一页摘要层元数据 + 根 README.md
  • nav({ offset: 50 }) → 第二页
  • nav({ path: "topics/" }) → 该目录下的条目 + 该目录 _index.md
  • nav({ path: "raw/mem_x.md" }) 或 nav({ path: "mem_x" }) → 那一条 + 该文件全文(不分页)

存储布局与索引字段

默认根目录 /.agent/memory/(fixture 用独立临时目录):

.agent/memory/
  README.md
  index.jsonl          # 索引,一行一条
  ledger.jsonl         # 只追加的审计事件
  episodes/            # 会话情节(带 session_id 来源标记)
  raw/                 # 原文(只能通过 raw_ref 下钻)
  topics/              # 共享/主题记忆(可多来源)
  _index.md            # 每个目录一份,随写入自动刷新
  config.json          # 面板写入的运行期配置(可删,删了回落 YAML)
  aliases.json         # 可选:自定义别名词表,和内置表合并

index.jsonl 一行一个条目:

{
  "id": "mem_xxx", "path": "topics/xxx.md", "type": "topic",
  "title": "...", "summary": "...", "tags": [], "entities": [],
  "time": "2026-01-01T00:00:00.000Z",
  "sources": [{ "session_id": "s1", "kind": "session" }],
  "session_id": "s1", "confidence": 0.6,
  "raw_ref": "raw/mem_xxx.md", "vector_id": null,
  "status": "active"
}
  • type:summary | episode | raw | topic
  • status:active | suggested | superseded | conflict | deleted_pending | orphaned
  • sources 是数组,元素 { session_id, kind: "session" | "plugin" | "user" }。

所有写入记录全部来源;顶层 session_id 只是主来源的快捷字段, forget 逻辑只读 `sources` 数组。

  • 读索引时跳过 JSON parse 失败的行:一条坏行不会让整个库崩掉。
  • 索引条目带 schema_version(当前 2):旧数据缺这个字段会被当成 1,不会报错;

点一次「重建索引」就整体升到当前版本(rebuildIndex 写出的条目一律是新版本)。

  • 文件是真相源,index.jsonl 只是索引。状态变更(suggested → active、

conflict、deleted_pending、orphaned)会同步写回记忆文件的 front-matter, 所以 rebuildIndex() 重建出来的状态和索引里的一致。

记忆文件长这样(front-matter 由本插件自解析,不引入 YAML 依赖):

---
id: mem_xxx
type: topic
title: PostgreSQL 索引选择
time: 2026-01-02T10:00:00.000Z
status: active
session_id: sess-a
confidence: 0.9
tags: database, postgresql
entities: PostgreSQL, B-tree
sources: sess-a:session
---

# PostgreSQL 索引选择

B-tree 适合等值与范围查询;GIN 适合数组和全文。

原子写入:所有落盘都走「写临时文件 → rename」,Windows 上 rename 覆盖失败时先 unlink 再 rename;ledger.jsonl 只 append,从不重写。

并发写入:索引是「读整个 index.jsonl → 改 → 整体重写」,所以每次改索引都在 /.index.lock(open(path, 'wx') 互斥 + 15s 陈旧锁清理 + 8s 超时报错)里做。 两个 session / 两个 subagent 同时写不会互相吃掉对方的条目——宁可报「锁超时」, 也不静默丢数据。

Token 口径

token 口径是固定的,不调用任何 tokenizer:

tokens = Math.ceil(text.length / 1.6)

为什么不是 /3:中文在 DeepSeek tokenizer 里 1 字约 0.6~0.7 token,按 /3 会低估近一倍, budget: 1200 实际可能吃掉 2000+ token,预算等于形同虚设。/1.6 对中文基本贴合、 对英文偏保守(英文 1 token ≈ 4 字符)——偏保守的方向是「少放一点」,比悄悄超支安全。

memory_assemble.used_tokens、charsForBudget() 全部用这一个口径。 要换精确口径,改 src/types.ts 的 estimateTokens / CHARS_PER_TOKEN 一处, README 与 fixture 断言一起改。

检索:BM25,不是子串

src/retrieve/keyword.ts 是纯函数 BM25,无外部依赖、无 LLM 调用、确定性可缓存。

切词

  • CJK:单字 + 相邻 2-gram(中文没空格,单字保召回、2-gram 保精度)
  • 拉丁字母/数字:按词,统一小写,再做轻量词形归一(ies→y、ing/ed/es/s 去尾)
  • 停用词:中英各一小张表(的/是/和…、the/of/and…),含停用字的 2-gram 也丢掉,

免得「的兜」「是互」这种语法胶水把 BM25 分数拉平

  • 标点/空白是分隔符,不进词表

精确标签加成:查询词正好等于某条记忆的某个 tag/entity 时,分数 ×1.5—— 「tag 里写了 postgresql」比「正文里提了一嘴 postgresql」更可信。

缓存:索引解析与切词结果按「索引文本指纹」缓存在进程内,写入后指纹变化自动失效。 不为每次检索重新切全库。

没有向量,靠什么补?——别名词表

先用真实口语问题量过一轮,BM25-only 的失败模式不是排序差,而是词汇不重合:

怎么问 · 库里怎么记 · 裸 BM25 包管理器 · pnpm 管理依赖 · ✅ 图片存哪 · 图片上传走对象存储 · ✅ 数据库连的哪个 · sqlite / postgres · ❌ 零命中 上线前要做什么检查 · 提交前跑 verify.sh · ❌ 零命中

于是加了一层别名词表(src/retrieve/aliases.ts,零依赖):

  • 内置 14 组常见同义/上位词(数据库→sql/sqlite/postgres/mysql…、检查→test/verify/lint…、

部署→deploy/release/发布…),查询命中组内任一词,就把同组其它词作为低权重补充项 (权重 0.5)参与打分,并允许它们构成命中;

  • 这类命中在结果里标 reason: "alias",渲染成「(别名推断)」——让你一眼看出

「这是按同义词猜的」而不是字面命中;

  • 表可以用 /aliases.json 追加:{ "组名": ["词1","词2"] },和内置表合并;

文件写坏了直接忽略,不会因为配置错误就搜不了。

同一组 query 的复测结果:4/6 → 6/6 命中,两个原本零命中的都靠别名兜住。

零命中时给「下一步」,而不是空数组