用文件保存长期记忆,按需检索并自动沉淀
30.8/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/26 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add ububwudb/dsh-memory-agent该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:仅索引
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🟢 扫描:未发现敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描未发现敏感能力:最多只发网络请求、读取自身元数据或只读文件,未发现执行命令、文件写入或敏感信息读取。
发起外部网络请求
查看扫描证据(共 1 处,展示前 1 条)
- 发起网络请求client/client.js:90
return fetch(API, {
扫描于 2026/11/3(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-tools给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
DeepSeek Harness 的基于文件的长期记忆插件:固定读取预算、BM25 搜索、确认关卡、常驻注入、自动结算。零运行时依赖。
README
由 DeepSeek 最新模型翻译生成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,也不依赖任何其他记忆插件。
- 默认后端就是纯文件(<cwd>/.agent/memory/),暴露 MemoryBackend 接口供后续替换。
- 工具 handler 是纯函数:async (backend, input) => output。所以 test/run-fixtures.mjs 可以直接 import('lib/tools/.js') 传临时 backend 做断言,不需要启动 DSH。
展开完整文档(README 剩余 1 节)
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,也不依赖任何其他记忆插件。
- 默认后端就是纯文件(<cwd>/.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 的人)
装好后在 设置 → 记忆 里有一块面板:
| 能做什么 | 说明 |
| 看记忆库路径 | 只读展示 <cwd>/.agent/memory 的绝对路径 |
| 看统计 | 全部 / 可检索 / 待确认 / 冲突 / 待清理 / 孤儿 六个计数 |
| 改六项行为 | 「写入需要确认」开关、默认阅读预算(token)、检索条数上限、写入前脱敏开关、常驻注入开关(含字符预算 200–8000)、自动沉淀开关(勾完下一轮生效) |
| 搜索 | 输入框 + 「搜索」,走的是和模型同一套 BM25 + 别名;别名推断的命中标「别名」,零命中直接列出常用标签供点选重搜 |
| 写一条记忆 | 文本框 + 标签,手写的直接生效(不过确认闸门),不用等模型动手 |
| 看详情 | 每行「详情」展开:id / 路径 / 状态 / 时间 / 置信度 / 来源 / 标签 / 冲突现场 / 原文预览 |
| 确认 | 待确认的条目一键转「可检索」 |
| 冲突收尾 | 冲突行给「保留这条」/「丢弃」:留下的转 active,同槽位其它说法转 superseded 并退出检索 |
| 软删 / 恢复 | 软删只是标记(文件保留、退出检索),随时能点「恢复」撤回 |
| 重建索引 | 等价 rebuildIndex(),索引坏了点一下就好 |
| 清理已软删 | 物理删掉已软删的 episodes/、raw/ 文件;遇到已软删的 topics/ 会跳过并回显「跳过 N 条」 |
| 彻底删除主题记忆 | 只在有待清理的 topics/ 时出现,两步确认后连主题文件一起物理删除 |
| 按会话忘却 | 列出会话及条数,两步确认后整批忘却(共享 topics 只解绑来源) |
| 导出备份 | 一键把索引 + 所有记忆文件打成 JSON bundle 下载走(≤8MB,超了会标记截断) |
改完点「保存设置」立即生效(不用重启宿主):面板写的是 <root>/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" }) → 那一条 + 该文件全文(不分页)
存储布局与索引字段
默认根目录 <cwd>/.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 一行一个条目: json { "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 依赖): markdown
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 → 改 → 整体重写」,所以每次改索引都在
<root>/.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",渲染成「(别名推断)」——让你一眼看出「这是按同义词猜的」而不是字面命中;
- 表可以用 <root>/aliases.json 追加:{ "组名": ["词1","词2"] },和内置表合并;文件写坏了直接忽略,不会因为配置错误就搜不了。
同一组 query 的复测结果:4/6 → 6/6 命中,两个原本零命中的都靠别名兜住。
零命中时给「下一步」,而不是空数组
memory_search_hybrid 现在返回信封 { hits, guidance }:
- 命中为 0 → guidance 摊开「库里有几条、常用标签、最近记的 3 条标题」,并提示可以 memory_nav({ sort: "recent" }) 浏览;
- 有别名命中 → 说明「其中 N 条是推断的,不对就换说法」。
没有语义检索时,模型最常犯的错不是搜不准,而是不换词重试就告诉用户「没记过」。
这段引导就是把这个动作显式化。面板里同样处理:零命中会列出常用标签,点一下就按标签重搜。
字段加权:content(title + summary)权重 1,keywords(tags + entities)权重 2; TF 与文档长度都按这个权重折算,DF 取「content ∪ keywords」的文档频次。
强词规则:长度 ≥ 2 的 token 是强词。只要查询里有强词,文档必须命中至少一个强词才算候选——否则「算力」会把「量子计算」的查询拉进来,中文单字命中只准加分、不准单独成命。
流程:先按 tags(AND)/time_range(闭区间)/entities(AND)过滤,再 BM25 打分排序,最后截断 top_k(默认 10,硬上限 200)。同分按 id 升序,保证确定性。
不参与检索的状态:suggested(闸门未确认)、superseded(冲突裁决后被取代的旧说法)、 deleted_pending(已忘却)、orphaned(来源被清空)。只有 active / conflict 会进结果—— conflict 留在结果里是为了让你看见「这里有两套说法」,再由 memory_resolve 收尾。
固定阅读预算不变量
used_tokens <= budget // 永远成立
memory_assemble 的做法:
1. 有 hits 就按分数降序用;没有就按 task 现搜(20 条)。
2. 预算换成字符上限 charsForBudget(budget) = floor(budget × 1.6)。
3. 每条命中先试「摘要 + 原文片段(≤600 字)」,放不下退成「只要摘要」,再放不下就按剩余空间截断摘要;剩余空间小于 60 字符就停手。
4. used_tokens 用最终 context 反算,所以不可能超过 budget。
5. 没塞进去的命中不会无声消失:omitted 给你漏了几条,渲染层还会写未放入 N 条(预算不够)——避免模型以为「这就是全部相关记忆」。
输出永远带来源路径(块内 来源: <path>,整体再给 sources[])。
memory_nav 默认只读摘要层且分页(50 条/页),raw 只能通过 raw_ref 显式下钻——这三条一起构成「固定阅读预算」:所有读路径都有上限,要烧 token 必须自己指名道姓。
写入:查重、冲突、确认闸门
write_memory 写一条记忆:摘要 + 可选原文 + 来源 + 时间 + session_id + 置信度 + 标签 + 实体。
- 有 raw → type=episode,正文写 episodes/<date>-<id>.md,原文单独写 raw/<id>.md 并登记一条 type=raw 的索引(raw_ref 指过去)。episodes/、raw/ 必须带 session_id,没给会直接报错。
- 没 raw → type=topic,写 topics/<slug>-<id>.md。
- 写入前查重(两级):
- 完全重复:sha1(type + 归一化摘要) 相同 → 直接返回既有 path/id/status;
- 近似重复:同槽位(title 相同)且 2-gram Jaccard ≥ 0.9 → 也返回既有条目。
避免「改个标点就是一条新记忆」把库撑爆。返回里带 dedupe: "exact" | "near" 与 similarity,模型能看出刚刚那句没有被重复记录。
- 版本演进(默认):同槽位(key,不给则用归一化标题)但内容不同 → *旧版本转 superseded、新版本 active,两边互相留指针(supersedes / superseded_by),旧版本的 raw/ 兄弟条目一起退场。superseded 不参与检索,所以库里只会留下「当前认知 + 历史版本」,不会出现两份互斥事实同时被喂给模型。
fixture 19-key-supersede 覆盖:双向指针、原文联动、旧文件仍可 nav 下钻。
- 冲突要显式声明:只有 write_memory({ expectConflict: true }) 才走 conflict 语义(双方都标 conflict、旧值进 variants 等裁决)。默认不再自动制造冲突——「改主意」是常态,不该被当成「无法判断谁对」。裁决仍用
memory_resolve({ id, action }):keep 本条 active、同槽位其它转 superseded。
- 确认闸门:config.requireConfirmation 默认 false(直接 active)。
为 true 时先写 suggested——不进入检索、不进入 assemble,只有 memory_confirm 才能提升为 active。这个模式用来挡「模型自我提升错误记忆」。
- 落盘前脱敏(redactSecrets,默认开):sk-…、ghp_/github_pat_…、AKIA…、私钥块、Bearer …、JWT、连接串里的密码、18 位身份证号,写进磁盘前就换成 [已脱敏:kind]。返回里的 redactions 会告诉你动过哪几类,ledger 里也有记录。
只挡「几乎确定是凭证」的形态,不碰手机号/邮箱——那种通常是用户真想记住的。
memory_confirm 只处理 suggested → active;已是 active 幂等返回;其他状态直接报错,不许拿确认动作绕过 conflict / deleted_pending 的语义。
体检与使用信号
记忆系统只增不减必然腐化,所以要有「哪条还有用」的证据。这部分判据不是拍脑袋定的,是参考了社区里迭代过十几个版本的实现(@max-null/dsh-memory 的 sweep / demoteStale)在注释里留下的结论,只搬规则不搬代码:
- 使用信号:每次检索命中的前 5 条记进 <root>/usage.jsonl(追加一行,{id, at, rank})。
只记前 5 条是刻意的——保证 hit_count 度量的是「模型真的会看的那几条」。
为什么是独立文件而不是写索引:检索是读路径,绝不能为了 +1 去重写整个 index.jsonl;记录失败也静默(旁路证据,不影响检索结果)。面板搜索同样计数,否则会误判「没人用」。
- memory_sweep(只读体检):
- longUnused:默认 90 天没命中。阈值远大于降级阈值,因为「非常驻记忆几十天不用」是常态,按 30 天报会把正常记录说成待清理。
- supersededBacklog:已被新版本取代、但文件还留档的旧版本(可择期物理清理)。
- 只列清单,绝不删除:阈值在没攒够分布数据前只能靠猜,所以先让它可观察;真要删就走 forget,要合并就走 memory_resolve。
- 面板里每条会显示「命中 N 次 / 还没被检索过」,idleDays 由 last_used_at ?? time 反算。
常驻注入(记忆自动进上下文)
没有注入管道时,「记忆生效」依赖模型主动调用检索工具;注入管道把每轮最值得带上的记忆摘要自动放进 runtime context,模型不调用任何工具也能看到。
- 落点:宿主 @deepseek-ai/dsh-system-prompt 的 context() 条目(memory-agent:recall, order 800,排在内置 runtime context 之后)。宿主对 context.text 是同步取值、每轮请求刷新(refreshContext),所以 provider 是同步函数、同步读库;返回 Promise 会被宿主按「非字符串」静默丢弃,这是硬约束不是风格偏好。
- interpolate: false 是强制的:记忆正文里可能出现 {{...}},进宿主插值器会抛 unknown prompt variable 直接杀死整轮 prompt 组装(dsh 0.2.x 对未知变量 fail-loud)。
- 内容优先级:命中过的条目在前(hit_count 降序,再按最后命中时间),其余按最近更新贪心保留。
- 字符预算硬顶(默认 1500,含标题行;注意与 memory_assemble 的 token 预算是两个口径):每条标题 40 字符、摘要 80 字符;放不下的条数给一行省略提示;text.length ≤ 预算 恒成立,一条都放不下时返回空字符串(只有标题行的注入是纯噪音)。空库 / 开关关闭 / 读库失败同样不注入。
- 面板:行为设置里有「常驻注入」开关与「注入字符预算」(200–8000),「常驻注入」卡片可以预览本轮会注入什么、占多少字符(injectPreview op,不写 usage、不污染读路径)。
- fixture 覆盖:21(空库不注入)、22(预算硬顶 + 省略提示)、23(命中优先排序), wiring 断言覆盖「开关关闭不注入」与「provider 必须同步」。
当前认知(分层蒸馏)
同一个槽位(write_memory 的 key)反复演进后,逐条翻摘要会越翻越长。
digests/ 目录给每个槽位存一份 200~400 token 的「当前认知」:最新说法做主体,未裁决冲突与历史版本数附在后面,模型先读它、再决定要不要下钻原文。
存储与编码:每个槽位一个文件 digests/<slotKey>.md。槽位键经 encodeURIComponent 编码(/ 与 .. 进不了文件名,防路径穿越),超过 120 字符截断后追加 sha1 前 12 位(防文件名超限)。文件 front-matter 记 slot / updated_at / status / sources (参与提炼的条目 id);digests/ 不在检索目录里,rebuildIndex 不会把它当条目扫。
memory_digest 的两种用法:
| 用法 | 入参 | 返回 |
| 查单个槽位 | { key } | { slot, path, text, updated_at, status: "active" \| "conflict", rebuilt } |
| 列槽位清单 | {} | { slots: { slot, path, updated_at, stale, entries, conflict, history, text? }[] } |
两种用法都接受 refresh: true 强制重建(有 key 只重建该槽位,没 key 全量重建)。
digest 是缓存,条目文件才是真相源:正文按时间取槽位最新条目的摘要做主体; digest 的 updated_at 落后于槽位最新条目,或 sources 没覆盖最新条目 id (同一毫秒写入的新版本也算落后),就判定过期——下次读取自动重建并置 rebuilt: true,命中新鲜缓存则 rebuilt: false。
谁在消费:memory_assemble 命中槽位时优先拼新鲜 digest(缺失/过期回退碎片拼装);常驻注入给有 digest 的槽位排序加权。两头都不要求模型先调 memory_digest。
未裁决冲突会标注在认知里:status: "conflict" 时正文加「⚠ 未裁决冲突」行,列出其它说法(最多 3 条,带日期与来源);被取代的历史版本不进正文,只报「另有 N 个历史版本留档(superseded,可顺 supersedes 链追溯)」。
fixture 覆盖:24(缺失重建 → 新鲜缓存 → 写新版本再重建)、25(冲突标注 + 槽位清单)、 26(assemble 优先用 digest)。
自动沉淀
把会话里用户明确说过的事实/偏好,自动提炼成待确认记忆。默认关闭。
提炼规则在 src/lifecycle/auto-settle.ts;「候选怎么变成一条记忆」单独放在 src/lifecycle/auto-settle-writer.ts——生产接线和测试都走它,让「只写 suggested」只有一个落点。
数据源是宿主 ctx.sessions(@deepseek-ai/dsh-session 的 SessionStore):
ctx.sessions.list() 拿会话,session.deriveMessages() 直接给出模型看到的消息历史。
不需要去读磁盘上的 session.v4.jsonl.zstd 快照——早期「插件层拿不到会话消息」的判断不成立。
inject 因此变成 [tools, systemPrompt, webServer, sessions]:没进 inject 时 apply 阶段拿不到服务,沉淀会静默失效(和面板 404 那次是同一类坑)。
两个实现约束:deriveMessages() 返回的 Message 是宿主 deep-frozen 的共享对象,只读、绝不
mutate;content 的块类型共 7 种(text / reasoning / image / file / tool-call / tool-addition / tool-removal),沉淀只取 text。
只写 suggested:auto-settle-writer.ts 里硬编码 requireConfirmation: true,写出来的条目必须 memory_confirm 才进入检索。机器提炼必然有误判,闸门不过就不许污染检索。
来源判据是 kind === "user" 且带 rpcId,这是白名单不是优化:宿主把注入内容也塞进 user/message。实测 source.kind 有 6 种——user(聊天框输入)、agent-instructions、 runtime-context、skill-catalog、time-context、user-approval;一次 14 条 user/message 的会话分布是 user 6 / agent-instructions 1 / runtime-context 3 / skill-catalog 1 / time-context 2 / user-approval 1,一半以上是注入。
只认 kind === "user" 还不够:这一档里还有一批非聊天框的产出者——/plan 引导消息、 goal 附件引用、子代理续跑提示、ACP / SDK / headless 提交、标题生成会话。漏判的方向是「多认」,不是「漏认」。宿主自己的判据(dsh-api-session-controller 的 hasPromptRequest)是: ts message.source?.kind === 'user'
&& typeof message.source.rpcId === 'string'
&& message.source.rpcId !== ''
rpcId 只出现在聊天框 RPC 提交的消息上,所以两条一起用:kind 挡掉六种里的注入,rpcId 挡掉 kind: "user" 里的注入。实测 5 个真实会话里 kind === "user" 共 23 条、23/23 都带 rpcId,加这一条不会漏掉真人打字。来源整体缺失时退一步,只接受不带本插件注入标题的文本。
不按来源过滤的后果是回音:把插件自己注入的记忆(连同技能目录、时间上下文)当成用户发言,再提炼成「用户说过 X」。
规则提炼,不调 LLM、不联网。判定顺序(任一不过就丢):
1. 去代码块、行内代码、URL
2. 长度 ≥ 12 字(更短的多半是「好的」「继续」)
3. 含问号 → 丢(那是提问,不是知识)
4. 中文疑问语气 → 丢:好不好、能不能、够不够、是不是、行不行、要不要、有没有、怎么样、如何。真实会话里大量征求意见的话不带问号,只按 ? 过滤会把它们当事实沉淀下去
5. 以指令动词开头(帮我、请、看下、查一下…)→ 丢(那是让我干活)
6. 必须命中强信号词表
强信号五类,命中的那类直接当标签:记住、偏好、约定、环境、身份。
词表刻意保守:漏沉淀只是少记一条,误沉淀会污染整库。
身份 只认具体名词(名字 / 电脑 / 机器 / 手机 / 项目 / 账号 / 环境 / 系统 / 版本 / 工作区):「我的意思是」「我的问题是」这类是口语引导词、不是身份陈述,按 我的…是 泛匹配会把它们全捞进来。
触发时机:心跳挂在常驻注入的 systemPrompt context text provider 上(宿主每轮同步取一次
文本)。tick() 只做轻量比较(有没有新消息、是否过了间隔),然后 fire-and-forget;
真正的提炼是后台异步任务。不订阅任何会话事件。
写入形态:type: "episode",raw 存用户原话(便于日后核对),tags 含 auto-settle 与命中的信号类别,落盘前过 redactSecrets。写了哪些条记进 ledger.jsonl (事件类型 lifecycle/auto-settle)。
失败静默:任何异常只 console.warn,绝不影响宿主 prompt 组装或会话收尾。
宿主没提供 sessions(老版本 / headless)时 tick() 是纯空操作,工具与常驻注入照常。
配置:总开关由 cordis.patch.yml 的 lifecycle.autoSettle.enabled 打底,同时映射成运行期字段 autoSettleEnabled——面板「行为设置」里勾的就是它(默认 false,写进 <root>/config.json)。
settler 里 enabled 是 getter、每次心跳实时求值,所以面板勾完下一轮立即生效,不用重启宿主。
其余三项只从 cordis 配置读,改完要重启: yaml lifecycle: autoSettle: enabled: false # 总开关(等价于面板的 autoSettleEnabled) minIntervalMs: 90000 # 两次尝试的最小间隔 minMessages: 4 # 会话里至少这么多条用户消息才提炼 maxCandidates: 3 # 单次最多沉淀几条
先开着观察几天准确率(产出都在「待确认」里),确认一条才生效: ts
memory_confirm({ id: "mem_xxx" })
断言在 test/run-settle-check.mjs(14 项),verify.sh 的第 5 步会跑它。
检索日志与锚点(P2)
检索日志:query-log.jsonl
<root>/query-log.jsonl 一行一次真实检索:{ at, query, hits }。模型工具调用与面板搜索走的是同一个 searchHandler,所以两边同源,不会出现「面板搜了但不计数」的盲区; query 为空的纯浏览分支(只带过滤条件不带词)不进这条日志。query 去空白后截断到 120 字符。
有界策略(日志不能无限涨):
- 上限 200 条 + 滞回裁剪:逐条 append 很便宜,但每写一次就全量重写太贵,所以只在行数超过 250(200 + 余量 50)时才重写一次,保留最近 200 条;
- 稳态效果:大约每 51 次检索才触发一次重写,文件行数恒 ≤ 251。
纪律与 usage.jsonl 相同:只追加、失败静默、绝不为记录而拖慢检索主路径。
零命中查询进 memory_sweep 输出(zeroHitQueries,新→旧最多 20 条):模型问了但库没命中——这是「库该补什么内容」的只读信号,只列清单不做任何动作。
面板「检索日志」卡片可以看全部记录。
锚点:记忆的失效探测器
write_memory 支持 anchors: [{ kind, name?, value }](入口过白名单:kind 受控、value 非空、name/value 截断去重,单条记忆最多 8 个):
| kind | name | value | 语义 |
| env | 变量名 | 期望值(留空只查存在) | 环境变量还在、值没变 |
| tool-list | 工具名(可选) | 工具名 | 该工具还在宿主清单里 |
| self-version | — | 版本号 | 插件版本没变 |
| path-exists | — | 路径 | 路径还存在 |
两条铁律:
- 探测不到 = 未校验,绝不判失效——probe 返回 undefined 就当没看见,绝不把「宿主没这个能力」误报成「记忆失效」;
- 失效只撤常驻注入、保留检索,等模型核对后回写(改记忆或换锚点)。
宿主当前只实现 env / path-exists 两种探测(tool-list / self-version 恒为未校验,等宿主 API)。两头的表现:
- memory_sweep 输出里多一个 staleAnchors 清单(最多 20 条):哪条记忆、哪个锚点、为什么失效;
- 常驻注入自动跳过失效条目(staleSkipped 计数),检索不受影响。
fixture 覆盖:27(query-log 有界 + 零命中清单)、28(锚点写入与评估)、 29(失效锚点撤注入)。
删除联动 forget_memories_by_session
显式工具,不依赖任何自动钩子。判定只看 sources 数组。
| scope | episodes/ raw/ | topics/(共享) |
| session_only(默认) | 标 deleted_pending,移出检索,文件保留 | 不动 |
| include_shared_unlink | 同上 | 从 sources 移除该 session;sources 空了标 orphaned,绝不删文件 |
mode:
- soft(默认):只改状态(同时写回文件 front-matter)+ 写 ledger.jsonl。
- hard:在 soft 基础上物理删除本次 session 相关的 episodes/、raw/ 文件并移出索引; topics/ 永不删除——那是共享知识(要删只能走面板的「彻底删除主题记忆」,且需两步确认)。
返回: ts { deleted_episode_count、deleted_raw_count、 unlinked_topic_count、orphaned_topic_count、 affected_paths: string[]、
status_changes: { id, from, to }[]
}
计数只统计本次真的发生的状态迁移,重复调用是幂等的(deleted__count 为 0)。
索引重建 ts
import { rebuildIndex } from 'dsh-memory-agent/lib/store/index-store.js'
await rebuildIndex(root) // → { entries, scanned, skipped }
只扫 episodes/、raw/、topics/(跳过 _index.md),从每个文件的 front-matter 反推条目,重写 index.jsonl 和各级 _index.md。没有 front-matter 的手写文件也能救回来: id 取文件名、title 取首个 # 标题、summary 取首段、时间取 mtime。
fixture 08-rebuild-index-from-files 会真的把 index.jsonl 删掉再重建。
配置
| 字段 | 默认 | 含义 |
| root | null | 存储根目录;null = <cwd>/.agent/memory |
| requireConfirmation | false | 写入是否先落 suggested |
| defaultBudget | 1200 | memory_assemble 默认预算(token) |
| maxTopK | 200 | 检索硬上限 |
| injectEnabled | true | 常驻注入开关;空库时自然什么都不注入 |
| injectBudgetChars | 1500 | 注入文本的字符预算硬顶(200–8000) |
| lifecycle.enabled | false | 会话钩子,只注入/审计,绝不删除 |
| lifecycle.audit | true | 钩子是否写 lifecycle/attach、lifecycle/detach 事件 |
| autoSettleEnabled | false | 自动沉淀总开关;面板「行为设置」可勾,下一轮生效(只写 suggested,见自动沉淀) |
| lifecycle.autoSettle.enabled | false | 上面那个开关在 cordis 配置里的落点(面板写 config.json 时以面板为准) |
| lifecycle.autoSettle.minIntervalMs | 90000 | 两次尝试的最小间隔(毫秒) |
| lifecycle.autoSettle.minMessages | 4 | 会话里至少这么多条用户消息才提炼 |
| lifecycle.autoSettle.maxCandidates | 3 | 单次最多沉淀几条 |
会话钩子默认关闭:删除只能通过 forget_memories_by_session 显式触发。
钩子打开也只往 ledger.jsonl 写两条审计事件,不碰任何记忆文件。
lifecycle.enabled / lifecycle.audit 与自动沉淀的间隔、条数阈值只从 cordis 配置读;自动沉淀的总开关另映射成运行期字段 autoSettleEnabled,面板可改、立即生效(见自动沉淀)。
零硬依赖与 MemoryBackend
dependencies: {}。@deepseek-ai/ 只在 peerDependencies(dsh-tools 标 optional)
和 devDependencies 里。默认文件后端满足这个接口,换成 SQLite / 远端实现只需要实现它: ts interface MemoryBackend { readonly root: string
init(): Promise<void>
readIndex(): Promise<IndexEntry[]> // 坏行跳过
writeIndex(entries: IndexEntry[]): Promise<void>
appendLedger(event: LedgerEvent): Promise<void>
readLedger(): Promise<LedgerEvent[]>
readFile(relPath: string): Promise<string | null>
writeFileAtomic(relPath: string, text: string): Promise<void>
removeFile(relPath: string): Promise<boolean>
listFiles(relDir: string): Promise<string[]>
now(): string
}
约束:handler 不依赖 ctx、不读全局状态、不碰 backend.root 之外的路径(文件后端对越界路径直接抛错)。
验证:verify.sh bash bash verify.sh # 全量,退出码 0 才算通过 bash verify.sh 06 # 只跑名字含 06 的 case
六步,任何一步失败立刻非零退出:
1. 环境检查(Node / npm,缺构建依赖就装 devDependencies)
2. 清 lib/ + tsc 全量编译
3. 发布物契约:dsh.bundle.patch / dsh.client、files 含 cordis.patch.yml 与 client、 dependencies 为空、不许依赖记忆插件、patch 顶层数组 + insert + name 是包名、 lib/tools/ 里不许出现 ctx/node:fs/process.cwd()、 keyword.js 不许出现 .includes(/.indexOf(/.startsWith(、 client/client.js 必须是 ModuleLoader 产物且 id 等于包名、
npm pack 清单必须含 lib、client、cordis.patch.yml,且不含 src/、test/
4. fixture 断言:test/cases.json 里每个 case 都带期望输出,逐条对照,对不上就打印「期望 vs 实际」并失败
5. UI 半身断言:迷你 React 渲染设置页面板 + 点按钮(run-client-check.mjs),假 req/res 打宿主路由(run-api-check.mjs),最后跑自动沉淀的用例(run-settle-check.mjs)
6. 接线烟测(在 fixture 阶段末尾):假 ctx 跑 apply(),验证 9 个工具注册、ctx.effect 卸载后零残留
还有一个真宿主探针 test/host-probe.mjs:用 DSH 安装目录里真实的 @deepseek-ai/dsh-tools 校验注册出去的 schema(assertSupportedJsonSchema + validateJsonSchemaValue),再跑一遍 write → search → nav → assemble → confirm → forget。它依赖具体安装路径,所以不在 verify.sh 里,本地想复核时手动跑: bash
node test/host-probe.mjs "<DSH安装目录>/resources/app/dsh" "<profile>/node_modules/dsh-memory-agent/lib/index.js"
fixture case 的形状(新增 case 直接往 test/cases.json 追加): json { "name": "03-assemble-budget-hard-cap", "description": "大量 hits + 小预算:used_tokens 绝不超过 budget", "fixture": "seed-minimal", // 可选:从 test/fixtures/ 拷一份种子库
"steps": [
{ "op": "writeMany", "count": 40, "input": { "summary": "第 {{i}} 条", "session_id": "s" } }, { "op": "assemble", "input": { "task": "训练", "budget": 120 } } ],
"expect": [
{ "step": 1, "path": "used_tokens", "lte": 120 }
]
}
可用 op:write、writeMany、confirm、nav、search、searchMany、assemble、forget、resolve、sweep、digest、inject、queryLog、anchorCheck、index、indexStats、ledger、exists、readFile、removeFile、rebuild。
断言种类:value(深比较)、lte、gte、length、includes、jsonIncludes、jsonNotIncludes。
入参与期望都支持 {"$ref":{"step":0,"path":"id"}}(避免硬编码随机 id)和 {"$repeat":{"text":"…","count":30}}(造长文本)。
发布:publish.sh
发布走一条命令: bash bash publish.sh
脚本先找 dsh-publish-plugin(npm 上那个插件,提供 /publish-dsh-plugin 命令与 publish_plugin 工具);找得到就整段委托给它,找不到才执行本脚本自己的整体流程:
verify.sh → npm pack 预检 → 认证闸门 → npm publish --access public
认证闸门:NPM_TOKEN 环境变量或 npm whoami 成功才继续,否则打印指引并退出码 3——不会假装发布成功。
npm publish 前会自动跑 prepublishOnly(重新编译 + 跑一遍 fixture),所以「本地过了」和「发出去的包能跑」是同一件事。
安全与隐私
- 默认不联网、不调 LLM;检索是本地 BM25,可缓存。
- memory_nav 默认不带正文;raw/ 全文只能显式下钻。
- 确认闸门挡住「模型自己把猜测写成事实」。
- 删除是显式的:session_only 软删保留文件,hard 才真删 episodes/、raw/;共享 topics/ 默认不会被整文件删除(清理按钮只跳它、不删它),真要删得在面板上点「彻底删除主题记忆」并二次确认。
- 审计事件只增不改,ledger.jsonl 里能查到每次写入/冲突/确认/忘却的来源。
- 包里不写死任何真实姓名、邮箱、内部地址;LICENSE 用 contribution 署名。
已知限制
检索
- 没有真语义检索:vector_id 字段一直空着,同义/上位词靠人工维护的别名词表兜,兜不住的(比喻、跨领域联想)依然零命中。表之外的语义关系无能为力。
- BM25 只有进程内缓存(按索引指纹失效),没有持久化倒排表:进程重启后第一次检索要重新切全库。万级条目开始变慢,再大就该换 MemoryBackend。
- 强词规则(长度 ≥ 2 的词才算命中依据)会牺牲一部分纯单字查询的召回。
- confidence 只存不用:既不参与打分,也不拦低置信度写入。
- 没有同义词表:停用词和词形归一只解决「写法不同」,解决不了「语义不同」。
记忆语义
- 没有「更新」概念:改一条记忆 = 新建一条(同标题会变成 conflict,再由 resolve 收尾),旧条目不会自动 superseded。
- 没有 TTL / 时间衰减 / 归档:记忆只增不减,靠人清理。
- session_id 当前仍需模型显式给(forget_memories_by_session 依赖它)——所以面板里做了「按会话忘却」,由 UI 替用户填这个 id。能拿到当前会话之后,让 write_memory 自动带上它属于后续增强,本轮没做。
- 会话钩子本身只做 attach/detach 审计,不删记忆;自动沉淀是另一条链(ctx.sessions → 规则提炼 → suggested),这两件事不要混在一起谈。
安全
- UI 的 API 已经挡住「网页 CSRF」和「随手 curl」(要求 JSON + Origin 或面板头),但没有 per-request token:同机进程如果刻意伪造 Origin 与 x-dsh-memory-agent,仍能读写你的记忆库。它防的是误触与浏览器跨站,不防本机恶意程序。
- 记忆是明文 markdown:凭证类已经落盘前脱敏,但手机号/邮箱等 PII 不处理,文件本身也不加密(备份、云盘同步都会带着明文走)。
- source.kind 是调用方自称,模型可以声称「这是 user 说的」。
工程
- 配置有两个来源:cordis 的 cordis.patch.yml(初值)与 <root>/config.json(面板覆盖)。
规则写在上面了,但仍然是两个地方能改同一件事;要根治得走官方的 Config schema + settings.describe 通道,那会引入 schemastery 依赖,和「零硬依赖」冲突,所以这次没做。
- index.jsonl 有 schema_version 字段,但没有自动迁移脚本:旧条目要升版只能点「重建索引」(从文件重建,顺带写上新版本)。
- 客户端产物是手写的(无构建、无类型),测试用迷你 React 模拟,不覆盖真浏览器的 React 版本差异 / StrictMode 双调用。
- 没有跨项目的用户级记忆:root 跟着 <cwd> 走,每个工作区各一份。
- dsh-publish-plugin 未安装时只能保证 npm 侧;GitHub 侧需要另配 gh。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
ububwudb/dsh-memory-agent
用文件保存长期记忆,按需检索并自动沉淀
相关插件 · 对话 / 记忆
上下文中枢oceanbase/powercontext
上下文跟着工作走 —— 跨会话、跨智能体、跨人。
★ 1,487已汉化DSH
73
智能体记忆库plastic-labs/honcho
Honcho 是记忆基础设施,用来构建有状态的智能体。这些智能体能理解不断变化的人、智能体、群组、项目和想法。
★ 7,894已汉化DSH
71.7
长程智能体控制面loopx-project/loopx
给 Agent 一个目标,让工作持续向前。
★ 6,358已汉化DSH
71.3
智能体上下文数据库volcengine/OpenViking
给 AI 智能体用的上下文数据库
★ 41,379已汉化DSH
71.3
长效机器人团队Fei2411/DS-Bot
DS Bot 是一个基于 DeepSeek Harness简称 dsh的 bot 插件,插件将 dsh 改造为 bot…
★ 5,084已汉化DSH
71.1
可携带记忆层EverMind-AI/EverOS
为什么选择 EverOS
★ 13,778已汉化DSH
71.1
这是你的仓库?认领条目