dsh-memory-retrieval
死去的记忆突然开始攻击我。
让 agent 看见一个东西,想起一段本来已经忘了的事。
人在对话里被一句话勾起的,往往不是"资料",而是自己早先的经历:三天前查过的结论、上周犯过的错、某个角色自己写下的私事。这个插件把这些都变成可以被动撞上来的候选片段。
三条设计原则(改代码前先接受这三条)
召回优先于精确 假阳性很便宜——模型看一眼就忽略了;假阴性很贵——那段记忆永远想不起来。 所以匹配故意做得宽松,注入块里也明确写着「不相关就忽略」。
全量索引 ≠ 全量注入 集合内所有正文都进索引(本机实测 199 万字),但一次只注入千余字, 不会把几百万字塞进上下文。
默认零依赖 不装词库、不调 API、不上 embedding 也能完整工作。 内置的 55 组近义词是锦上添花,不是运行前提。
五种召回来源
| 来源 | 检索什么 | 默认 |
|---|---|---|
| 笔记 | 模型自己判断值得记的结论(带出处,可调原文);集合检索时成员会话的笔记也并进池子 | 开 |
| 会话集合 | 你拖在一起的几个会话,彼此可以互相检索 | 开(拖了就有) |
| 自己的历史 | 本会话更早说过的内容(只取 15 分钟前的,避免和当前上下文重复) | 开 |
| 全量记忆卡 | 角色自己的 memory-card.json(不进会话日志的那部分) |
配了路径才用 |
| memory.jsonl | 你手写的记忆条目 | 有文件才用 |
所有来源共用同一条注入通道:按当前消息做关键词匹配 → 取出 top-k 片段 → 附在 system prompt 后 → 由模型判断要不要用。
【集合记忆 ·「项目资料」跨会话】
- 《会话甲》 2026-11-03:…(原文片段)
【自己的历史 · 旧片段】
- 2026-11-02 05:40:…(原文片段)
模型拿到的不是"事实",是线索——相关就自然带出,拿不准就问「我之前提过……是同一个吗?」,不相关就忽略。
主动检索(可选)
除了被动撞上来,还有一个工具 memory_recall:模型想查旧账时可以主动调用,
支持 scope=collections | self | card | notes | all。这是补充手段,不是主路径——
主路径永远是"它自己想起来",而不是"你让它去查"。
纯本地:不调 API、不做 embedding、不联网;只有注入的候选块占输入 token(默认上限约 1400 字)。
笔记:模型自己记的结论(0.9.54)
原文片段要靠模型再看一遍才判断得出价值;笔记是已经判断过的结论。所以它排在所有来源之前注入。
这一层就是「自动写入记忆」:插件不会在后台偷偷抽记忆,但模型在理解上下文之后,
可以调用 memory_note 把结论写进 notes.jsonl。写入的是结论(不是原文摘抄),
每条都带出处,写完之后下一轮就能被注入或主动查到。
memory_note(text="这个项目用 pnpm 不用 npm", kind="decision")
memory_note(text="用户偏好:先给结论,再给理由", kind="preference")
memory_note(list=true) # 看看记了什么
memory_note(origin="a3f9") # 顺着出处调出原文核对
memory_note(retire="a3f9") # 标为作废(不物理删除)
# 顺手把同义词一起记了(不用再单独调 memory_links)
memory_note(text="这个项目用 pnpm 不用 npm", synonyms="网速慢,卡顿,延迟")
# 主动查(和被动注入是两条路)
memory_recall(query="pnpm 相关", scope="notes")
memory_recall(query="...", scope="all") # 笔记 + 自历史 + 集合 + 记忆卡
每条都带出处:会话标题、时间、当时的原话片段。注入块长这样:
【笔记 · 我自己记下的】
下面不是原文,是我以前理解过之后记下来的结论——可以当成事实线索直接参考。
每条都带出处;不确定或与原文冲突时,用 memory_note(origin="<id>") 调出原文核对。
- [a3f9c1] 这个项目用 pnpm 不用 npm
出处:《会话甲》 2026-11-07 · decision
规则:
| 项 | 行为 |
|---|---|
| 写入 | 模型显式调用;不做每轮后台抽取(贵、噪声大) |
| 重复 | 内容哈希去重,同样的结论只存一条 |
| 作废 | 追加一条 retired 记录,原始行保留可追溯 |
| 注入优先级 | order: 0,排在其他四种来源之前 |
| 可见范围 | 跟着来源会话走:本会话 + 与它同属一个集合的会话;集合外的笔记看不见。注入和 memory_recall 用同一套规则 |
| 无相关笔记时 | 仍注入最近 1–2 条 preference/decision(长期有效,不需要被勾起) |
| 存储 | ~/.dsh/dsh-memory-retrieval/notes.jsonl,append-only |
配置:notesEnabled(默认 true)、notesTopK(3)、notesMaxChars(600)、notesAlwaysInject(true)。
插件市场
插件市场在 DSH 里是一个 JSON 索引,不内置任何源——所以下面这个索引由本仓库自己发布
(market/index.json,可自行核对内容):
https://tashkent-sn.github.io/dsh-memory-retrieval/market/index.json
用法:
- 插件 → 实验性插件 → 打开「自定义插件市场」
- 切到「发现」→ 添加索引源 → 粘贴上面的 URL
- 卡片上会显示本插件,点安装即从锁定的 commit 拉取
索引里的每个版本都锁定了 40 位 commit,
engines.dsh与package.json一致; 不经过 npm,也不依赖第三方市场。
安装
# 1) 把本目录加入 profile 依赖(示例:web profile)
dsh plugin --profile web add <你的本地路径>\dsh-memory-retrieval
# 2) 在 profile 的 package.json 里把包加入 bundles
# "dsh": { "profile": { "bundles": [ ..., "dsh-memory-retrieval" ] } }
# 3) 重启 DSH
也可以直接把 cordis.patch.yml 的内容合并进 profile 的 cordis.patch.yml。
配置
- insert:
- id: dsh-memory-retrieval
name: 'dsh-memory-retrieval'
config:
enabled: true
label: 'Memory'
# memoryPath: 'C:/Users/you/.dsh/dsh-memory-retrieval/memory.jsonl'
topK: 5
maxChars: 800
minScore: 1
perRecordChars: 180
askWhenUnsure: true
# 只在这些 session 启用;空 = 全部
includeSessionPatterns: []
# 这些 session 永不启用
excludeSessionPatterns: []
# 会话级开关(GUI 写入)
sessionOverrides: {}
# 工作区级全局检索开关(GUI 写入;true = 跨工作区检索,默认只查本工作区)
workspaceGlobalRetrieval: {}
# 会话集合(GUI 拖拽写入;集合内跨会话检索)
collectionRetrieval: true
collectionTopK: 5
collectionMaxChars: 1400
collectionPerSnippetChars: 300
collectionChunkChars: 600
collectionSessionCapChars: 0 # 0 = 每个会话全文进索引
collections: []
# - { id: col-1, name: 合同与法务, enabled: true, sessionIds: [session-aaa, session-bbb] }
# 联想节奏:false = 只在开口时联想一次;true = 任务中途也继续想起新片段(更拟人,也更容易跑题)
recallMidTask: false
默认记忆路径:
~/.dsh/dsh-memory-retrieval/memory.jsonl
文件不存在会自动创建空文件;把 memory.example.jsonl 复制过去即可试用。
会话集合(拖拽)
比 memory.jsonl 更省事的一层:把会话拖成一个集合,集合内的会话互相检索对方的历史对话。
- 设置页左侧是全部会话(按工作区分组),直接拖到右侧集合卡片即可加入;拖到「+ 新建集合」会顺手创建一个。不方便拖时,每条会话右边有「加入…」下拉。
- 集合内每个会话的完整正文都会进索引(
user/message、assistant/message的文本块 +compaction/summary,跳过运行时快照、reasoning、tool-call 参数)。 - 检索时只把最相关的几段注入,不是把全文塞进上下文——199 万字的集合不可能整块进 prompt。
- 注入块带来源标注
- 《会话标题》 2026-11-02 03:57:…,模型据此判断出处。 - 每个集合有独立开关;关掉就不参与检索。
实测(本机 13 个会话、正文 199 万字):全量索引 311ms;3 会话集合 133ms、缓存命中 1ms;单次检索 1–10ms;最坏的 161 万字集合 250ms、39MB 堆。索引按 (会话文件 mtime/size + 分块参数) 缓存,会话更新后自动重建。
「试检索」按钮用的是和真实注入同一套逻辑,可以直接在设置页看某句话会捞出哪些片段。
原来的逐会话开关和工作区「全局检索」开关没有删,收进了设置页的「高级设置」折叠区,展开后和 0.6.x 一样用。
会话日志是多帧 zstd(一个 565KB 的文件有 242 帧),Node 24 的
zstdDecompressSync只解第一帧, 所以lib/sessions.mjs里是逐帧解码。改这块之后务必用宿主运行时跑测试,见文末「测试」。
全量记忆卡(角色记忆)
角色把自己的记忆存在独立的 memory-card.json 里(不是会话日志)。插件可以直接读它来检索,
好处是不会混进唤醒模板——那些"【生物钟】""【统一人设卡】"根本不在这个文件里。
{
"memoryCardPath": "C:\\Users\\you\\.dsh\\<角色插件>\\characters\\<角色>\\memory\\memory-card-full.jsonl",
"memoryCardSessionIds": ["<角色所在的会话 id>"],
"memoryCardTopK": 3,
"memoryCardMaxChars": 900,
"memoryCardAutoDisableSelf": true
}
归属规则(重要):memoryCardSessionIds 是这张卡的访问白名单。
- 只有名单里的会话会注入它,也只有它们能用
memory_recall(scope=card)查它; - 没有"全局可读"开关,这是有意的:角色的私人记忆不该漏进别的工作会话;
- 想让别的会话也能用这张卡 → 把那个会话 id 加进
memoryCardSessionIds。
装完先验一下:是全量,还是裁剪版
角色插件通常同时留着几个文件:
| 文件 | 是什么 |
|---|---|
memory-card.json |
活跃卡,会被维护脚本裁剪、降权、清理,条数少 |
memory-card-full.jsonl |
全量归档,一行一条,只增不减 |
memory-card-full.md |
给人看的可读版,插件不读 |
memoryCardPath 指错的话,插件读到的就是被裁剪过的那份——记忆看着"总是差一点",很难察觉。
最简单的检查方式:直接问你的角色。
你现在读到的记忆卡有多少条?是全量归档还是裁剪版?
它应该会调用 memory_recall(scope=card) 或去看配置,然后报一个条数。然后你自己核对:
# 全量归档应有的条数
wc -l ~/.dsh/<角色插件>/characters/<角色>/memory/memory-card-full.jsonl
- 对得上 → 读的是全量;
- 明显少 →
memoryCardPath还指着memory-card.json,改成.jsonl那份。
0.9.66 起两种格式都支持:.json(数组或 { records })和 .jsonl(一行一条,坏行自动跳过)。
记忆卡注入的每一条都会带上记录的 createdAt(和集合、自检索一致):没有时间的旧片段,
模型分不清是三个月前还是刚刚,容易把旧决定当新消息。
从 0.9.67 起,记忆卡注入块的第二行会写明这次读进来多少:
(全量卡 N 条 · 本次命中 M 段)
N 应该等于你自己 memory-card-full.jsonl 的行数。这个数字直接暴露"插件解析出多少条"——只读进一部分时会明显偏小,不用靠感觉判断注入厚不厚。
记忆卡在注入前会先跳过最近 24 条消息里已经出现过的原话:卡里也可能有刚发生的事, 把同一段话再贴一遍只会占字数。所以每次注入的都是"当前上下文里没有的"那部分。
{
"memoryCardPath": "C:\\Users\\you\\.dsh\\<角色插件>\\characters\\<角色>\\memory\\memory-card-full.jsonl"
}
记忆卡 ≠ 集合(别混)
| 数据来源 | 怎么共享 | |
|---|---|---|
| 会话集合 | 会话日志 session.v4.jsonl.zstd |
把会话拖进同一个集合 |
| 全量记忆卡 | 独立的 memory-card.json |
把会话加进 memoryCardSessionIds |
加集合并不会让别的会话读到记忆卡——集合索引的是"对话里说过的话", 卡里那些从没在对话中出现过的条目,集合永远拿不到。
匹配方式:关键词 + 近义词,不做语义
检索是字面匹配(中文按二元组切),不是向量检索。没改的话,「网速慢」永远捞不到「网络卡顿」。 0.8.0 起加了两层来缓解:
- 严格度
minHits(默认 2):一条查询里至少命中 2 个词才算相关,压掉「原因」「什么」这类泛词造成的假命中; 查询特别短(≤2 个词)时自动放宽到 1,避免把正常问题挡掉。 - 近义词表
synonyms.example.json:查询词按同组词扩展(权重synonymWeight默认 0.7,原词优先)。 词表可放~/.dsh/dsh-memory-retrieval/synonyms.json,或由synonymPath指定;都没有就用内置的 55 组 / 167 个词条。 条目按包含关系匹配,所以「网速慢」整词在表里时,「网速」这个二元组也能扩出来。
实测(3 个真实会话 / 206 段):
| 查询 | 关闭近义词 | 开启近义词 |
|---|---|---|
网速慢是什么原因 |
2 段,9.46 | 3 段,9.46 |
上网很卡怎么办 |
0 段 | 3 段,9.02(命中《会话丙》) |
上网很卡怎么办 |
0 段 | 3 段,6.73 |
量子力学的基本假设 |
0 段 | 0 段(不相关就该不命中) |
同义词表只影响召回,不改写记忆内容;表里没有的领域术语可以让模型自己记(memory_links,见后文),也可以导入外部词库。
排序:通用词和虚词不能撑起一次"想起"(0.9.38)
集合一大,会出现两种典型的假命中:
| 现象 | 例子 |
|---|---|
| 通用词到处都有 | 问「插件怎么没注入」,捞出一段讲 sandbox 权限的英文片段,只因两者都含「插件」「DSH」 |
| 虚词二元组偶然撞上 | 「提的」「我很」「是不」这种 bigram 在某些段里恰好出现,df 很小,看起来像"少见词" |
三条约束把这两类压掉(语料 ≥20 段才生效,测试夹具这种小语料不做降权):
- 通用词降权:出现率 >35% 的词权重 ×0.2,>60% 基本当停用词(×0.05)
- 虚词降权:由「的了是我你他…」组成的二元组权重 ×0.25
- 分层而不是淘汰(0.9.40):只命中通用词的片段照样保留,标成
weak、排在强命中之后, 只在 topK 还有空位时补进来,并在注入块里单独标一句「以下只是词面重合、可信度较低」。 这是为了守住设计原则第一条:检索层只负责召回,不替模型判断"这段有没有用"
效果(集合 295 段,查询「为什么我提到词典但是插件没有返回注入,这个词我提的很少」):
| 最高分片段 | |
|---|---|
| 改前 | 13.25 —— 一段讲 sandbox ACL 的英文,完全无关 |
| 改后 | 6.92 —— 讲的是 B站 登录态/插件功能,至少沾题 |
代价:语料很小(新建集合、只有两三个会话)时统计量不足,降权会自动关闭,此时仍然更宽松。
运行原理(先读这个)
这个插件不训练模型、不调 API、不依赖任何外部词库/向量库。它只在 prompt 组装阶段做一件事: 从你自己的历史里捞出可能相关的原文片段,贴进上下文,然后由模型自己判断要不要用。
两条链路:
① 记忆文件(memory.jsonl,手写 JSONL)
每轮用户消息 → 本地关键词检索 → top-k 片段 → 注入【旧记忆候选】
② 会话集合(设置页拖拽组成)
当前会话属于某集合 → 取集合内其它会话的正文
→ 读 ~/.dsh/sessions/<ws>/<sid>/session.v4.jsonl.zstd(逐帧 zstd 解压)
→ 按 ~600 字切块 → 关键词/近义词打分 → top-k 片段
→ 注入【集合记忆 · 「集合名」】,每段带《会话标题》+ 日期
0.9.12 起:
memory_recall(query)改为在apply()时全局注册一次,execute(args, exec)里用exec.agent.session.id反查当前会话属于哪些集合。 之前按 agent 注册时,register()虽然返回了 disposer,但工具不会进入该 agent 的可用工具快照,模型看不到它;现在它和两条自动注入链路并存。
设计约束:同义词只在查询侧展开,不进索引
这条比"能不能做增量"更根本,改动前先读。
| 会话日志 | 同义词 / 联想词 | |
|---|---|---|
| 追加会改变旧内容吗 | 不会(不可变前缀) | 会——加一条「网速慢 = 卡顿」,回头改变了所有旧片段的可达性 |
| 失效范围 | 只影响新片段 | 全局 |
| 适合增量索引吗 | ✅ 帧偏移 + 只重分块尾部 | ❌ 语义上不成立 |
所以:
语料索引:只存原文词项(卡顿、延迟)
查询时: cue 词 → 查同义词表 → 扩展成多个查询词
同义词是查询侧展开,不是索引侧预写。带来两个结果:
- 加一条同义词立刻全局生效,不需要重建任何语料索引("追溯改变含义"是免费得来的);
- 词典这一侧不做哈希桶增量——几千条的整表重建 <1ms,比维护桶 + 碰撞校验 + 改组同步便宜得多。
唯一的红线:不要把同义词预先写进语料索引(例如为了给同义命中加分)。 一旦写进去,加一条词就得回头重算所有片段的分数,增量假设当场作废, 只能全量重建——这正是本插件花了很多力气避开的事。
主动检索的 scope(0.9.13)
memory_recall 增加 scope 参数:
| scope | 检索范围 |
|---|---|
collections(默认) |
当前会话所属集合里的其它会话 |
self |
当前会话自己的完整历史(会话日志全量建索引) |
all |
自己的历史 + 集合里的其它会话 |
self 只返回 15 分钟以前的片段,避免把刚刚还在 prompt 里的消息又捞回来。它和集合检索一样是“全量索引、只取片段”,不会把整份会话塞进上下文。
memory_recall({ query: "上次那个 EPERM 是怎么修的", scope: "self" })
三条设计原则见开头的「三条设计原则(改代码前先接受这三条)」。这里补一条实现层的推论:
注入块里写「不相关就忽略」,是因为判断权属于模型:检索层只负责把候选放到它眼前,不替它筛。
联想节奏(recallMidTask)
设计目标是「看见一个东西,想起一段本来已经忘了的事」——所以注入是被动的:由线索撞上来,而不是模型主动去查。
| 模式 | 行为 | 适合 |
|---|---|---|
false(默认) |
你开口时联想一次;本轮不再重复检索 | 日常。成本约每轮几百 token |
true |
每个步骤都按当前正在做的事重新检索,能陆续想起不同片段 | 想要「边干活边被勾起回忆」的体感 |
代价:true 更像人,也更容易跑题(话题跳脱、ADHD 感),而且每步都要额外注入,一个十步任务会多花几千 token。建议先用默认值,觉得“它怎么什么都想不起来”时再打开。
线索从哪来(这是两种模式真正的区别):
false:最近 3 轮用户消息拼接(截断 800 字)。话题延续时记忆跟着延续,但一轮之内线索不变;true:额外读取agent/pre-step事件里的messages(当前步骤的 tool 结果、助手输出), 用它覆盖线索。所以你在任务中途查了别的东西,联想的方向也会跟着拐。
只放大候选池不算"中途联想"——线索不变的话,多给的还是同一批片段,只是分几次贴完而已。
被动自检索(selfRetrieval)
除了查集合里的其它会话,也可以让被动注入查当前会话自己的完整历史:
默认开(
selfRetrieval: true);只取
selfMinAgeMinutes(默认 15 分钟)以前的片段,最近的消息还在 prompt 里;本地拿最近 24 条消息 + 当前线索做前缀去重,当前上下文里已经有的内容不再注入;
本地建索引、本地筛选,不额外调 API;只有真正捞到「新」旧片段时才多花注入 token;
先取候选、再按时间过滤:自检索的片段全属于同一个会话,而集合检索默认「每会话最多 2 段」。 若直接复用那个上限,得分最高的 2 段往往就是刚说的话,再被 15 分钟规则滤掉 → 一条不剩(0.9.39 修);
默认
selfTopK: 2、selfMaxChars: 600,比集合检索更克制;插件不内置任何模板规则;哪些 user 消息不该进记忆,由
sessionSkipPatterns按你的 agent 情况配置(见下)。
设置页「自己的历史(被动)」里可以关掉或调这三个值。
跳过模板消息(sessionSkipPatterns)
会话日志里有很多「不是她说过的话」的 user/message:生物钟唤醒、任务模板、人设注入等。插件默认不猜这些前缀,sessionSkipPatterns 默认是空数组;需要时自己加正则:
{
"sessionSkipPatterns": [
"^【生物钟】",
"^【当前时间】",
"^[统一人设卡",
"^【管理端手动任务"
]
}
- 数组里每项是一个正则字符串,匹配到的 user/message 不进入自检索索引,也不进入集合索引;
- 只过滤 user 消息,assistant 的正常发言不受影响;
- 不同 agent 的唤醒模板不一样,按自己的日志调,不要照抄;
- 坏正则会被忽略,不影响其它规则;
- 修改后由设置页保存或直接改
~/.dsh/dsh-memory-retrieval/config.json,重启 DSH 生效。
本机当前配置里已经放了一组 SurfCore 唤醒模板的规则;那只是本机配置,不是插件默认值。
单独的全量记忆卡(memoryCardPath)
除了会话日志,可以直接挂一份角色自己的 全量记忆卡 JSON,作为独立检索源:
{
"memoryCardPath": "C:/Users/you/.dsh/<角色插件>/characters/<角色>/memory/memory-card.json",
"memoryCardRetrieval": true,
"memoryCardTopK": 3,
"memoryCardMaxChars": 900
}
- 支持顶层数组,或
{ records: [...] }/{ memories: [...] }/{ items: [...] }; - 每条记录至少要有
text/content/detail/summary之一,title、tags、importance可选; - 这份文件不经过会话日志,所以不会被唤醒模板污染;
sessionSkipPatterns只需要处理会话来源; - 记忆卡、自检索、集合检索共用一份「纯文本前缀」去重表:同一段内容不会因为同时命中三个来源就注入三遍;不需要为了记忆卡去手动关掉该会话的插件开关;
memoryCardSessionIds可以标出这张卡属于哪些会话;memoryCardAutoDisableSelf(默认开)会在本轮记忆卡真的命中并注入后,暂停这些会话的被动自检索,避免卡和会话重复;- 这只暂停被动自检索,主动
memory_recall({ scope: "self" })仍然可用;用户取消勾选「注入记忆卡时暂停关联会话的自检索」即可恢复被动自检索; - 如果这一轮记忆卡没有命中,自检索照常运行;
- 设置页「全量记忆卡」里可以填路径、关联会话、开关、topK 和字数上限;
- 不知道路径时,可以让 DeepSeek 在磁盘上找角色的
memory-card.json,把绝对路径填进来;插件不猜角色目录,也不联网。
图形设置页
装进 DSH web profile 后,打开:
设置 → 记忆检索
里面有开关和所有参数:总开关、标签、记忆文件、topK、maxChars、minScore、单条截断、拿不准是否询问、按工作区分组的会话开关(含历史会话)。会话列表直接读 DSH 的 SessionController,显示 DSH 自己的会话大标题(不是 session-id);未加载的历史会话也会列出来,勾选后等它恢复时生效。
每个工作区标题旁还有「全局检索」开关:
- 开:这个工作区的会话可以跨工作区检索全局记忆;
- 关(默认):只检索
workspace等于该工作区的记忆;没标workspace的记录不会被用。
下面的正则过滤仍然保留,作为高级用法(批量包含/排除)。
CLI 菜单仍然保留,适合没有 Web UI 的场景。
菜单
不想手改 YAML 时,用交互菜单:
node menu.mjs
可调:
- 总开关
- 标签(注入块里的记忆名字)
- 记忆文件路径
- topK
- maxChars
- minScore
- 单条截断字数
- 拿不准时是否询问用户
- 只在这些会话启用(正则)
- 排除这些会话(正则)
- 保存
非交互:
node menu.mjs --show
node menu.mjs --set topK=8 --set askWhenUnsure=false
配置写入 ~/.dsh/dsh-memory-retrieval/config.json,插件在下次加载/重启 DSH 时读取;它覆盖 cordis.patch.yml 里的同名配置。
记忆格式
一行一条 JSON:
{"id":"m1","title":"称呼","text":"用户希望被叫作示例名。","tags":["称呼","示例名"],"workspace":"/path/to/your-workspace","importance":4,"at":"2026-01-01T00:00:00+08:00"}
text必填;title/tags会参与检索;workspace可选;工作区关闭“全局检索”时只匹配这个字段;importance用于同分排序;at用于同分时新记录优先。
工具调不到怎么办(重要)
memory_recall、memory_note、memory_links 都是插件注册的工具。如果模型说"我没有这个工具"或者明明调了却没反应,按顺序排查:
0. 先调 memory_status(0.9.70 起)
它是专门为这件事准备的自检工具,会直接告诉你答案:
插件版本:0.9.70
本 agent 可见工具:62 个
memory_recall=✅ memory_note=✅ memory_links=✅
- 三个都是 ✅ → 工具在,是模型没找对名字,或它看到的工具清单有缓存滞后(清单滞后 ≠ 调不到,直接按名字调用通常就能通);
- 有 ❌ → 才继续往下走。
1. 换一个新会话再试
工具表在会话创建/恢复时装配。0.9.69 把注册路径上的 await import 去掉了(工具在 agent/created 当场注册完),重启后旧会话一般也能直接拿到;仍不行就开新会话对比,能调 → 只是会话装配问题,不是插件坏了。
2. 还是不行?看名字有没有被"钉死"
会话会按名字缓存第一次见到的工具定义。如果某个名字曾经用不合法的定义注册过(比如早期版本),之后同名注册会被当成"已存在"跳过——表现是:新会话能用、老会话永远调不到,且换定义/重装/重启都无效。
判断方法:在工具表里看名字对不对得上版本(memory_links 是 0.9.46 起的新名字)。如果名字还是旧的,说明插件没升级到对应版本。
解法只有一个:换名字。 这不是 DSH 的 bug,是插件早期留下的脏状态;memory_search → memory_recall → memory_links 两次改名都是这个原因。换名字前先用 memory_status 确认到底是"看不见"还是"调不到"——两者表现很像,但只有前者需要改名。
3. 最后才怀疑版本/安装
node -e "console.log(require('./package.json').version)"
对照 README 里写的最新版本号;profile 里是 link: 安装的话改完即生效,但需要重启 DSH。
限制
| 项 | 说明 |
|---|---|
learned.json 会增长 |
每次 memory_links 落盘一条,没有上限也没有清理界面。模型只能增和改(重复的会合并),不提供删除——想清理就 memory_links(list=true) 看内容、直接删文件里的条目。不提供删除是有意留的接口形状:将来词表大了要按内容哈希做增量(只处理新增和变更),物理删除会重排文件、让哈希与位置失效,只能整份重传;现在统一"只增 + 作废",以后不用改契约。 |
| 只在 Windows + DSH 0.2.2-rc.1 验证过 | macOS / Linux 未测;sessions.mjs 依赖 DSH 的会话日志格式,升级 DSH 后需复测。 |
| 联想默认开 | relatedHops: true。觉得话题容易跑偏就关掉,或把 relatedTopK 降到 1(默认已是 1)。 |
自动写入记忆 = 笔记层:会话日志、记忆卡、集合正文全部只读;模型可以调用
memory_note往notes.jsonl追加结论(不是原文)。写入由模型显式发起,插件不做后台自动抽取;会写文件的只有两处:
notes.jsonl(笔记,结论,可作废)和learned.json(同义词与关联词词典)。会话日志、记忆卡、集合正文全部只读;全量索引 ≠ 全量注入:集合内所有正文都进索引,但一次只注入几段(默认 ≤1400 字),不是把整个集合的对话塞进 prompt;
关键词/BM25,不是向量语义检索(有意不做,理由见「被否决的方案」);同义词/改写可能召回不到(专有名词、编号、项目名效果最好);
注入是软约束,模型仍可能误用候选;
索引是增量的:会话日志只追加,插件记住"解到第几帧、分到第几段",下一轮只处理新增内容。 实测 17.9MB / 452 万字的会话:冷启动约 590ms,之后每轮 6–10ms;文件被重写或分块参数变化时自动全量重建;
集合检索依赖本地会话日志
session.v4.jsonl.zstd的格式,DSH 升级若改格式需跟着改lib/sessions.mjs;默认所有 agent 生效,可用
includeSessionPatterns/excludeSessionPatterns限制;未在多个 DSH 版本上验证;
agent/inbox/claimed、agent.ctx.systemPrompt属于 DSH 插件接口,升级后需复测。工具名不要复用旧定义:会话会按名字缓存第一次见到的工具定义。如果早期版本用过同名工具、且定义不合法, 之后同名的重新注册会被当成"已存在"跳过(表现为:新会话正常、老会话永远调不到)。 排查手段:换一个全新的工具名注册,若立刻生效即为此因。当前工具名为
memory_recall。
扩充词表:先量,再决定要不要学(0.9.41)
词表相关有两个诊断工具。先说结论:用统计方法自动学词,在个人语料上不成立,两个工具都验证过:
# 1) 候选挖掘:从语料里找「X(也叫 Y)」这种明确定义
node tools/synonym-candidates.mjs
# 2) 覆盖率:现有词表覆盖了语料里多少词
node tools/synonym-coverage.mjs --top 30
实测(22 份会话 / 168 万词项):
| 方法 | 结果 | 为什么不行 |
|---|---|---|
| 覆盖率 | 2.5%,top 全是「自己/可以/因为/如果」 | 中文里出现最多的永远是泛词,它们不需要同义词 |
| 术语挖掘(不加引号) | 抽到「保存 ≈ 清空」「出头 ≈ 两百万」 | 中文没有词边界,正则会把动宾短语当词 |
| 术语挖掘(引号锚定) | 0 组 | 你的语料里没人写「X(也叫 Y)」 |
所以「统计自动学词」这条路是死的。但现在有第四条路,而且已经跑通:让模型自己记。
| 路 | 谁来做 | 覆盖什么 | 现状 |
|---|---|---|---|
| 内置 55 组 | 我 | 通用相近说法(网速慢/卡顿) | 够用 |
| 导入外部词库 | 你 | 某个领域的专业词 | 注意许可 |
| 手工补几组 | 你 | 零散但重要的 | 最划算 |
| 模型自己记 | 它自己 | 它真正见过的说法与概念关系 | 已实测生效 |
实测(不是模拟):另一个会话在分析音乐时,自己写入了一组同义词和一个联想——
{ "groups": [["调性", "key", "调式"]],
"links": [["网络", "路由器"], ["和弦", "调性"]] }
这三条都不是我写的,是它判断「这三个词说的是同一件事」「这两个概念本来就相关」之后自己记下的。 下次检索就能接上——判断语义本来就是模型的活,不该交给统计。
两个工具留着当诊断:换了一个领域的新语料时,先跑一次看看该不该补词,比凭感觉加词靠谱。
让模型自己往词表里加(memory_links)
统计方法学不出同义词(见上一节),但判断"这两个词是不是一回事"本来就是模型的活。所以给了一个工具:
memory_links(words="网速慢,卡顿,延迟", note="用户习惯说网速慢")
memory_links(list=true) # 看看已经学到了什么
- 写到
~/.dsh/dsh-memory-retrieval/learned.json,不动你的synonyms.json(那个会被导入工具整份覆盖); - 与已有组有交集时并进那一组,不会分裂成两张表;
- 保存后立即生效,下一次检索就会用新词扩展;
- 只接受两个以上、长度 ≥2 的词;单组最多 8 个,防止一次写进来十几个把扩展面撑爆。
联想词:kind="related"(0.9.43)
同义词是"替换",联想是"下一跳"。后者不能直接拿来扩词——问「网络卡顿」时扩成「路由器」,会把一堆路由器配置拉进来。
所以联想只在语境已经提到其中一方时才启用:
memory_links(words="网络,路由器", kind="related")
生效方式:
你问「网络卡怎么回事」
→ 第一轮用原话检索,命中「网络最近特别卡,测速只有 90 兆」
→ 语境里出现了「网络」→ 顺藤摸一次「路由器」
→ 注入块里多一段:
【顺带想起 · 由「网络」联想到】
- 《会话乙》:路由器的 WAN 口如果协商成百兆,速度就上不去…
为什么不再加"次数上限"
试过一版「每轮最多一跳」,后来撤了。原因是:联想天然延后一步——它发生在后续步骤, 而那时的线索里已经包含当前在想什么,模型读到时就在判断"这个词跟现在聊的事有没有关系"。
所以在注入时砍次数,等于用减少信息去替代判断:既砍掉了本来合理的第二跳, 也没让判断变准(一跳就足以带偏)。该守的只有两件事:
- 播块时把判断交还给模型("没关系就自己咽回去");
- 每轮注入有字数上限(
relatedMaxChars),别让候选挤占正文。
连锁本身不靠代码切——人思绪飘远也是自己拽回来的。
四条约束:
- 单向触发:语境里出现 A 且没有 B,才去摸 B(两者都出现就不重复摸);
- 只摸一跳:不会 A→B→C 连锁,避免话题越跑越远;
- 自己拽回来:注入块里明确要求——先判断这段跟当前任务有没有关系, 没关系就自己咽回去,不要说"我想起…",也不要为了用上它而改话题。
开关:relatedHops(默认 true)、relatedTopK(默认 1)、relatedMaxChars(默认 300)。
主动记,不用你开口
这一步的关键不是工具,是行为:模型在对话里注意到「网络和路由器被一起谈起」时,应当自己调 memory_links(kind="related") 记下来——而不是等用户说"你记一下"。
所以注入块尾部会带一句提示(autoLearnWords,默认开):
如果你注意到「同一个东西的不同说法」(网速慢/卡顿),或「两个本来就有关系的概念」(网络/路由器、磁盘/固态硬盘),可以用 memory_links 记下来——同类用默认,关联用 kind="related"。下次检索就能接上。
只在真的注入了内容时才提一句,不占额外的常驻 token。
什么时候该调
同义词(kind 省略,默认)——同一个东西的不同说法:
- 用户说「网速慢」,你的笔记里写的是「卡顿」;
- 用户纠正你的用词(「我们叫它 X,不是 Y」);
- 两个词在同一个语境里被混用。
联想(kind="related")——两个不同的概念,本来就有关系:
- 网络 ↔ 路由器、磁盘 ↔ 固态硬盘、登录态 ↔ Cookie;
- 它和"用户怎么叫"无关——这是世界的事实,不是用词差异;
- 只在对话里已经提到其中一边时才会顺藤摸一下另一边。
两个都别记:只是同一次对话里偶然同现的词(咖啡 ↔ 报错),那不是关系,是巧合。
同义词词典与许可
内置表:lib/synonyms.mjs 里的 DEFAULT_GROUPS(55 组 / 167 个词条)是本项目自己写的,
随包以 MIT 发布,可自由使用、修改、再分发。
外部词库请自备:本仓库不包含任何第三方词库,也不提供下载地址、不内置抓取脚本。 原因很实际:第三方词库通常是学术共享协议或自定义许可,不是 MIT/CC 这类可自由再分发的授权, 把它打进包里会让所有使用者一起承担许可风险。
不需要外部词库也能用。 内置 55 组覆盖了常见技术/运维说法(卡顿、报错、崩溃、插件、依赖、仓库、提交……)。 下面这段只在你想扩充词表时才需要看;不扩充的话,跳过即可,不会影响任何功能。
真要扩词表时,按对方许可自行取得数据,再用转换器变成插件能读的 JSON:
node tools/import-synonyms.mjs --in "你下载的词表.txt" --out "$env:USERPROFILE\.dsh\dsh-memory-retrieval\synonyms.json"
本项目格式上兼容「编码前缀 + 一行一组」的词表(Aa01A01= 词 词 这种),
以及一行一组、空格/逗号分隔的纯文本:
- 学术用途与商用适用不同的许可条件,请以对方协议为准;
- 本仓库不附带、不镜像、不自动下载任何第三方词表。
支持三种输入:
| 格式 | 例子 |
|---|---|
| 带编码前缀 | Aa01A01= 安装 部署 装好 |
| 纯文本(一行一组) | 安装 部署 装好(空格 / 逗号 / 顿号分隔皆可) |
| 本插件 JSON | {"groups": [["安装","部署"]]} |
转换时会丢掉单字和超长词、按 --max-group(默认 6)切分大组、去重,
避免一个词扩出几十个变体把检索搅浑(buildQueryUnits 另有 8 个变体的硬上限)。
外部词库的许可只在用户与权利人之间成立,商用请自行确认;本插件只读转换后的本地 JSON,不联网。
测试
node test-lexical.mjs # 分词、严格度、近义词扩展、记忆检索一致性
node test-import.mjs # 外部词表转换:三种格式、切块、脏数据过滤
node test-mock.mjs # section 注册、memory 注入、集合注入、排除规则
node test-self-card.mjs # 自检索(15 分钟/24 条去重)+ 记忆卡归属闸门
node test-incremental.mjs # 增量索引:追加后不重解全量、文件重写退回全量
node test-api.mjs # 配置读写、会话/工作区开关、集合增删改查、试检索
node test-ui.mjs # 用 React 桩把设置页真渲染一遍,抓白屏类问题
这些都不需要 DSH 进程。集合相关测试用 test-fixtures.mjs 造 zstd 压缩的假会话日志,
通过 DSH_SESSIONS_DIR 指向临时目录,不会读到你的真实会话。
必须用宿主运行时跑一遍,不能用系统 node —— 插件实际跑在 Electron 的 node 里,两者行为会不一致
(例如 Node 24 的 zstdDecompressSync 只解第一个 zstd 帧,Node 26 解全部):
$env:ELECTRON_RUN_AS_NODE='1'
$dsh = '<DSH 安装目录>\DeepSeek Harness.exe' # 换成你自己的安装路径
& $dsh test-lexical.mjs
& $dsh test-import.mjs
& $dsh test-mock.mjs
& $dsh test-api.mjs
& $dsh test-ui.mjs
& $dsh test-incremental.mjs
& $dsh test-self-card.mjs
& $dsh test-synonyms.mjs
& $dsh test-words.mjs
& $dsh test-hop.mjs
& $dsh test-notes.mjs
提交前自动跑测试
仓库带一个 pre-commit 钩子:提交前跑全部 12 套测试,任何一套失败就中止这次提交。
npm run hooks:install # 一次性安装(.git/hooks 不进版本库,克隆后要跑一次)
npm test # 手动跑全部
只想改文档、或本机没装 DSH 时可跳过:
git commit --no-verify
钩子优先用宿主的 DeepSeek Harness.exe 跑(插件实际跑在 Electron 的 node 里,行为可能和系统 node 不同),
找不到才退回系统 node;可用 DSH_BIN 指定路径。
License
MIT
No comments yet. Be the first to write one.