dsh-memory-layered
Verifieddsh-memory-layered · v0.8.0 · MIT · Web UI
Layered cross-session memory for DeepSeek Harness: L0 raw turns, L1 LLM-extracted facts with conflict resolution, L2 scenes with maxScenes eviction, L3 project + global persona, hybrid FTS + vectors (built-in 384-dim lexical hashing by default, or a confi
Install
dsh plugin add dsh-memory-layered Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
dsh-memory-layered
npm 包名:
dsh-memory-layered(npm i dsh-memory-layered/dsh plugin add dsh-memory-layered);GitHub 仓库仍叫MYiYang/dsh-memory。插件内部的注入命名空间plugin:dsh-memory、日志标签[dsh-memory]、技能与工具名都跟包名无关,不受影响。
给 DeepSeek Harness (dsh) 加分层跨会话记忆的本地插件,并随包带 4 个 dsh 技能(dsh-plugin-dev / memory-curator / repo-audit / windows-pwsh-ops:插件启动时用 ctx.skills.registerProvider 以 rank 600 注册,装插件即装技能,不用拷 ~/.dsh/skills):
- L0 原文:每轮完整落库(用户/助手文本 + 工具名),不可变、可重放;
- L1 原子事实:按 warm-up 阈值异步调用 LLM 抽取,带 kind / priority / version / 冲突消解(store / update / merge / skip);
- L2 场景块:把同类事实归并成 Markdown 场景(summary + heat + 热度衰减),生成「场景导航」;
- L3 画像:项目画像 + 跨项目全局画像(偏好/约定/决策/环境),由 JEV-lite 判决门控,只在值得时重写;
- 召回:稳定段(画像 + 场景导航,每会话一次)+ 动态段(本轮命中的 L1 事实)两段注入;检索是 FTS5 + 向量 的混合(倒排特征预筛 + 精确余弦)。向量默认是本地 384 维词法向量(无需 embedding 服务),也可以在设置里换成任意 OpenAI 兼容的嵌入模型(见 向量模型);
- 工具:
memory_search/memory_save/memory_forget/memory_stats/memory_extract。
零外部依赖:存储用 Node 自带 node:sqlite(FTS5 + trigram),默认向量是进程内 feature hashing(字符 n-gram + 英文词,384 维 Float32),
不需要 Redis / Docker / 向量库,也不需要 embedding API;只有你显式配置外部向量模型时才会联网。设计文档见 DESIGN-v2.md。
它做什么
| 时机 | 行为 |
|---|---|
| 每轮结束 | L0 落库一轮原文;调度器按 warm-up 阈值(1→2→4→8→everyN)+ idle 60s 决定是否入队抽取 |
| 抽取(异步串行) | 一次 LLM 做「场景切分 + 事实抽取」→ 候选召回 → 二次 LLM 冲突消解 → 写 L1(含 version/history) |
| L1 之后 | 按场景聚合事实 → LLM 归并场景块;每 triggerEveryN 批或 JEV 判决命中时重写画像 |
| 每轮首次请求前 | 首轮注入稳定段(画像 + 场景导航);每轮注入动态段(FTS + 向量混合召回的 L1 事实) |
| 模型需要时 | 主动检索/固化/删除记忆;memory_extract 手动补跑一次抽取 |
原则:记忆是上下文,不是指令。注入块明确标注「可能过时、仅参考、不要复述」; 不是每轮都调 LLM——阈值 + idle 把成本摊薄;LLM 失败降级为「不记忆」,绝不影响会话。
安装
随包发布,装插件就装了技能。三条路径任选,完整步骤、参数表、故障表见 INSTALL.md:
| 路径 | 怎么装 | 说明 |
|---|---|---|
| A. GUI | 侧栏「插件」→「添加插件」→ 输包名 → 重启 | 自动写依赖 + 选入 dsh.profile.bundles,不用手改 JSON |
| B. CLI | dsh plugin --profile web add <包名>,再把包名加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles → 重启 |
只 add 不激活,漏了这一步重启也不会加载 |
| C. 离线 | 解压 dsh-memory-layered-<ver>.zip → pwsh -File .\install.ps1 -From .\dsh-memory-layered-<ver>.tgz(默认 dry-run,加 -Apply 才写盘)→ 重启 |
内网/无 registry;脚本也能直接对源码目录用(link: + 拷技能) |
本文命令里的
<包名>就是 npm 上的dsh-memory-layered;@yourscope/dsh-memory只是「若你改发 scoped 名」的占位写法。两种写法除了安装 spec 不同,别处完全一样。
三种方式的结果相同:profile 的 package.json 里有依赖、dsh.profile.bundles 里有它(写在数组末尾,顺序影响优先级)、node_modules 里是指向实际目录的链接。插件自带的 cordis.patch.yml 负责插入 memory 行——不要再在 profile patch 里手写同 id 的 insert,否则 duplicate entry id。
装完重启 DeepSeek Harness(host 插件不支持热更新;只有 profile 启用 HMR 时配置变化才即时生效)。 首次启动会在
~/.dsh/memory/memory.db建库(WAL 模式)。删掉该文件即可重置全部记忆。 回滚:从 profile 的package.json里删掉包名(dependencies和dsh.profile.bundles两处)→ 重启。
为什么是 bundle(两次真实的翻车)
- 只写 patch insert → 插件管理里看不到。 插件管理列的是 profile 的插件包(
dsh.profile.bundles)与 loader 条目;单靠cordis.patch.yml里一行 insert 只是一个匿名 loader 条目,没有包身份,不会出现在「已安装插件」列表里。 - 拷贝安装 → 目录丢失后插件静默消失。 旧脚本先把
local-plugins/dsh-memory删掉再拷贝;拷贝失败时目录没了,而 profile 的 junction 仍指向它。dsh 启动时该条目enabled: true但fiberPhase: null(disposed),memory_*工具静默消失、没有任何弹窗。
现在改为「直接 link 源码 + bundle 注册」:不拷贝、不删目录,插件身份和挂载都由 profile manifest 声明。
配置
在 profile patch 里覆盖;patch 的 config 是整行替换、不深合并,要覆盖就写全:
运行时改参数不用改这里:装好后 设置 → 记忆 能改同一批 53 个参数并立即生效(5 个字段要重启),优先级 默认 < YAML < 设置页。详见 设置页(记忆)。
- id: memory
name: 'dsh-memory-layered' # loader 模块说明符,必须等于安装后的包名
config:
dbPath: '~/.dsh/memory/memory.db'
maxItems: 6 # 动态段最多几条事实
maxChars: 1400 # 动态段字符预算
minScore: 0.2
capture: true # 是否保留 episode(v1 兼容)
injectGlobalFacts: true
scope: project
stableMaxChars: 1800 # stable 段(长期约定 + 画像 + 场景导航)字符预算
core: # 「每轮都必须生效」的置顶/硬规则,按策略注入、不靠词法命中
enabled: true
maxItems: 6 # 首轮最多注入几条
minPriority: 85 # persona/instruction 达到该优先级即视为硬规则
layers: { l0: true, l1: true, l2: true, l3: true }
extraction:
enabled: true # 关掉就退回纯 FTS 记忆
provider: '' # 空 = 跟随 agentDefaultModel
model: ''
everyN: 5 # warm-up 毕业阈值
idleMs: 60000 # idle 兜底
maxInputChars: 8000
maxMemories: 10
maxAttempts: 3
timeoutMs: 60000
maxTokens: 2400 # 推理模型的 reasoning 也吃这份预算,别再往下调
scenes:
enabled: true
minFacts: 3
maxScenes: 20 # 超出的场景按衰减热度淘汰(persona 引用的保留)
halfLifeDays: 14
persona:
enabled: true
triggerEveryN: 10 # 每 N 个成功批次过一道 JEV-lite 门控
maxChars: 1200 # 硬上限(超出直接截断)
targetChars: 700 # 生成目标长度;超过 1.4 倍即视为膨胀,当场重写压缩
globalTargetChars: 240 # 全局画像的生成目标(它与长期约定重复,宜更短)
personaMaxTokens: 4000 # 画像重写的输出预算(reasoning 先吃预算,别调太小)
personaTimeoutMs: 120000 # 画像重写超时(默认 2 分钟)
personaReasoningEffort: low # 画像重写用低推理档;空字符串 = 跟随模型默认
global: true # 额外维护跨项目全局画像(只吃 global 事实)
vector:
enabled: true # 关掉就只用 FTS/LIKE
provider: hash # hash = 内置词法(默认,零依赖);openai = 上面配的嵌入接口
model: '' # 如 text-embedding-3-small / bge-m3 / nomic-embed-text
dims: 0 # 0 = 自动:模型表 → 探测缓存 → 现场探测一次
baseUrl: '' # 留空 = https://api.openai.com/v1(自动补 /embeddings)
apiKeyRef: '' # 环境变量名(经 DSH 凭据解析);不像标识符按字面密钥
minCosine: 0.5 # 语义召回下限(bge/nomic 约 0.45–0.55,OpenAI 约 0.7)
timeoutMs: 5000 # 单次嵌入超时;查询侧最多等 5 秒
weight: 0.1 # 向量分权重(实测 0.2+ 会掉点,默认 0.1)
scanLimit: 2000 # 语义通道的有界扫描行数 / 词法预筛没命中时的兜底行数
prefilter: true # 倒排特征索引预筛(memory_features,仅内置词法向量)
featureLimit: 96 # 预筛查询侧最多用多少个特征(存储固定 96/条)
skills:
enabled: true # 关掉就不注册包内技能
dir: '' # 覆盖技能目录(默认 = 包内 skills/)
包内技能以 ctx.skills.registerProvider 注册、source: 'bundled'、rank 600(最低档):
项目 .dsh/skills(100) > 项目 .agents/skills(200) > customSkillDirs(300) > ~/.dsh/skills(400) > ~/.agents/skills(500) > 包内 bundled(600)。
同名时用户自己的技能赢,日志会写 from bundled ignored because a higher-priority skill already exists——想用包内版本就删/改名 ~/.dsh/skills 里那份。
设置页(记忆)
这节解决两件事:改参数不用手改 YAML、不用重启;以及「记忆到底存在哪个目录」。
装好插件并刷新页面后,设置 → 记忆(settings.section,id memory)里能看到四块,数据全部来自 POST /memory/api/state,前端不写死任何默认值:
| 区块 | 内容 |
|---|---|
| 存储位置 | 存储目录 + 复制路径 + 打开目录;目录里的文件(名称、体积、修改时间)与数据库体积;「当前数据库路径」(运行中的库)与「设置文件路径」(还没创建时标「尚未创建」) |
| 统计 | 记忆总数、事实、片段、待提炼、场景、人设、词元消耗 |
| 记忆数据 | 一行摘要 + 管理数据 按钮:弹窗里直接对记忆条目做搜索 / 新增 / 编辑 / 删除(见下节) |
| 参数 | 8 张分组卡(合计 53 项),每行「标题 + 说明 + 控件 + 来源徽标(默认/YAML/设置页)+ 未保存徽标 + 重启生效徽标 + 行内恢复默认」 |
| 操作条 | 页头「刷新」;有改动时出现「保存(n)」「撤销」;常驻「恢复 YAML 基线」 |
能改什么
53 个字段分 8 组,组的顺序就是页面上的顺序:
| 分组 | 代表字段(key) |
管什么 |
|---|---|---|
recall(召回) |
recall.scope recall.maxItems recall.maxChars recall.minScore recall.injectGlobalFacts recall.stableMaxChars、core.enabled core.maxItems core.minPriority |
动态段召回条数/字符/阈值、稳定段预算、长期约定(core)层 |
capture(捕获) |
capture、layers.l0 layers.l1 layers.l2 layers.l3 |
是否记原文/episode,四层各自开关 |
extraction(提炼) |
extraction.enabled extraction.everyN extraction.idleMs extraction.maxInputChars extraction.maxMemories extraction.maxAttempts extraction.timeoutMs extraction.maxTokens、extraction.provider extraction.model |
抽取节奏、单批上限、LLM 预算与路由 |
scenes(场景) |
scenes.enabled scenes.minFacts scenes.maxScenes scenes.halfLifeDays |
L2 场景归并与热度淘汰 |
persona(人设) |
persona.enabled persona.global persona.triggerEveryN persona.maxChars persona.targetChars persona.globalTargetChars persona.personaMaxTokens persona.personaTimeoutMs persona.personaReasoningEffort |
L3 画像(项目 + 全局)的节奏与长度 |
vector(向量) |
vector.enabled vector.provider vector.model vector.dims vector.baseUrl vector.apiKeyRef vector.minCosine vector.timeoutMs vector.weight vector.scanLimit vector.prefilter vector.featureLimit |
向量模型(内置词法 / 外部嵌入)、语义阈值与混排权重 |
storage(存储) |
storage.dbPath |
数据库路径(重启才生效) |
advanced(高级) |
debug、skills.enabled skills.dir |
调试开关、包内技能注册 |
debug打开后,每轮召回会把「query 长度 / 命中数 / 检索策略」打到 info 日志(只记尺寸与策略,不记 query 正文);默认仅在 debug 级日志里记同样内容。
每个字段对象(state 返回的 groups[].fields[]):
| 字段 | 含义 |
|---|---|
key |
上表的点号 key,保存/重置都用它 |
type |
number / boolean / enum / text |
min max step |
仅 number:范围与步长 |
options |
仅 enum:可选值 |
default |
内置默认值(来自 resolveConfig({}),和插件启动用的是同一个归一化函数) |
value |
合并 YAML + 设置页后的生效值,也就是下次启动会用的值 |
active |
仅 live:false 字段:正在运行的实例实际在用的值;与 value 不同 = 改了但还没重启 |
source |
default / config / settings(来源徽标) |
live |
false = 需重启 |
数字越界不报错,会被夹到边界(
recall.maxItems: 999存成 20);text最长 500 字。 真正会 400 的是:未知 key、类型不对、枚举不在列表(例如recall.scope传team)、patch/unset不是对象/数组。
保存是立即生效还是重启
- 立即生效(
live:true,53 个里 48 个):保存时直接改插件内存里那份 config —— 召回、捕获、工具都闭包读同一个对象,下一次触发就用新值;抽取节奏额外通过pipeline.refresh()与scheduler.configure()同步到已创建的对象上。已排队的 idle 定时器保留原延迟,下一轮结束才按新值重新计时(保存时清掉它会丢掉这次兜底触发)。 - 需要重启(
live:false,保存响应里的restartRequired会列出来,行上也有「重启生效」徽标):
| key | 为什么必须重启 |
|---|---|
storage.dbPath |
数据库句柄在插件加载时打开,换路径等于换库 |
skills.enabled / skills.dir |
技能在加载时注册进宿主的技能表,生命周期与插件绑定 |
extraction.provider / extraction.model |
LLM 客户端在加载时解析一次路由 |
这些字段的保存照样落盘(重启后生效),页面不会假装它已经生效。想确认「改了但还没重启」:看 storage 组里 storage.dbPath 这一行——value 是下次启动会用的路径,active 是运行中真正在用的路径,两者不同就是待重启;「存储位置」卡片始终指向运行中的库。
还有一个容易踩的边界:抽取管线只在插件启动时满足
layers.l1 && extraction.enabled && 能解析到模型才创建。 如果启动时它就是关的,在设置页把extraction.enabled/layers.l1打开不会凭空创建 LLM 客户端与调度器——要真正开始抽取,改完重启一次。state里的runtime.extraction.scheduled/available能看出管线在不在(这块页面暂不展示)。
配置优先级
resolveConfig 内置默认值(53 个字段各一份)
< profile 的 cordis.patch.yml(loader 行 config)
< $DSH_HOME/memory/settings.json(设置页写的)
- 合并是深合并:YAML 与 settings 的对象层级递归合并,数组与标量整体覆盖(后者赢)。设置页只写「你显式改过的 key」,不整份拷贝默认值 → YAML 里以后调整别的字段仍然生效。
source判定:settings 里出现过该 key →settings;否则 YAML 里出现过 →config;否则default。所以「填一个和默认值一样的数」会把来源变成settings,真想回默认请用行上的「恢复默认」。- 「恢复 YAML 基线」= 清掉 settings.json 里的全部覆盖 → 回到 YAML + 默认。它不是出厂值,也不动记忆库。
文件在哪
| 文件 | 位置 | 说明 |
|---|---|---|
| 数据库 | $DSH_HOME/memory/memory.db(缺省 ~/.dsh/memory/memory.db,可用 storage.dbPath 改) |
schema v5、WAL 模式;同目录还有 -wal / -shm |
| 设置文件 | $DSH_HOME/memory/settings.json |
固定在这个目录,与 storage.dbPath 无关——否则「把库挪到别处」这条设置会把自己弄丢;删掉即回基线 |
| 技能 | 包内 skills/(skills.dir 可改) |
加载时注册进宿主,不落盘 |
存储卡片里列的是数据库所在目录(
storage.dbPath的父目录);改了storage.dbPath以后目录也跟着换,而设置文件仍在$DSH_HOME/memory/,卡片上的「设置文件路径」单独显示它。
{
"version": 1,
"updatedAt": "2026-10-23T08:00:00.000Z",
"settings": {
"maxItems": 8,
"core": { "enabled": false },
"extraction": { "everyN": 10 },
"dbPath": "D:\\dsh\\memory.db"
}
}
settings里是 loader 行 config 的形状,不是页面上的分组名:UI 的recall.maxItems落在顶层maxItems,extraction.everyN落在extraction.everyN,storage.dbPath落在顶层dbPath。好处是这段 JSON 可以直接粘进cordis.patch.yml的config:。- 写盘是「临时文件 + rename」的原子替换;
version/updatedAt只是记账,读回时会剔除,不会混进配置。 - 文件坏掉(不是合法 JSON、顶层不是对象、超过 1 MB)→ 读回
{},插件照常启动,原因显示在storage.error里。 - 手写兼容:顶层没有
settings包装时,整个对象会被当作 settings 层。 - 设置文件的位置可以用 loader 行 config 里的
settingsPath改(测试 / 打包用的口子,不属于设置页字段)。
宿主 HTTP API
页面只调这些端点,全部 POST + content-type: application/json,响应统一 {ok:true,value} / {ok:false,error:{code,message}}:
| 端点 | 请求体 | 响应 value 要点 |
|---|---|---|
POST /memory/api/state |
{} |
plugin{name,version,dbReady,startedAt}、storage{home,dir,dbPath,settingsPath,settingsExists,files[],totalBytes,error}、stats{total,facts,episodes,superseded,projects,byKind,l0Total,l0Pending,scenes,personas,vectorized,featureIndexed,tokensSpent,dbBytes}、runtime{extraction{available,provider,model,scheduled},skills{enabled,dir},vector{enabled,provider,model,dims,dimsSource,tag,endpoint,available,keyRef,embedded,pending,total,lastError}}、groups[].fields[] |
POST /memory/api/save |
{"patch":{"recall.maxItems":8},"unset":["scenes.maxScenes"]} |
{state, applied[], unset[], restartRequired[]} |
POST /memory/api/reset |
{} 或 {"keys":["recall.maxItems"]}(省略 keys = 全部) |
{state, applied[], restartRequired[], reset:true} |
POST /memory/api/embeddingTest |
{} |
{ok,provider,model,dims,dimsSource,tag,endpoint,ms,error}:真发一次嵌入请求,用来区分「地址错 / key 错 / 维度错」;provider=hash 时不联网,直接回 384 |
POST /memory/api/reveal |
{} |
{opened,dir,error?}:在系统文件管理器里打开数据库所在目录(Windows explorer.exe、macOS open、其他 xdg-open);spawn 本身失败才回 opened:false,文件管理器异步启动失败只记日志 |
POST /memory/api/memories |
{query?,kind?,scope?,project?,includeSuperseded?,sort?,page?,pageSize?} |
一页记忆 + 下拉筛选数据:{items[],total,page,pageSize,pages,sort,includeSuperseded,facets{kinds[],scopes[],projects[],total}};query 是子串搜索(LIKE,2 字中文也能命中);默认隐藏 superseded_by 非空的行,includeSuperseded:true 才带出来;sort ∈ updated|created|priority|id,pageSize 上限 100 |
POST /memory/api/memory |
{id} |
{item,history[]}:单行 + 最近 10 条旧正文(version/op/text/createdAt) |
POST /memory/api/memoryCreate |
{text,title?,kind?,scope?,project?,tags?,pinned?,priority?} |
{item,inserted};text 必填(≤ 20000 字);同文本幂等:已存在时更新属性并回 inserted:false(hash 唯一索引决定一行只存一份正文);scope:'project' 必须带 project |
POST /memory/api/memoryUpdate |
{id,text?,title?,kind?,scope?,project?,tags?,pinned?,priority?} |
{item,version};只改传入的字段;改正文才 version+1 并写 memory_history,改标签/置顶/优先级只更新 updated_at;改正文会重算 hash/向量/特征索引;撞上另一行正文回 409 conflict |
POST /memory/api/memoryDelete |
{id} 或 {ids:[…]} |
{deleted,ids[]};不存在的 id 被忽略;FTS 与特征索引由触发器一并清掉 |
错误码:
| code | HTTP | 触发 |
|---|---|---|
bad-request |
400 | 请求体不是 JSON object / 超过 64 KB、未知 key、类型或枚举不合法(message 形如 recall.scope: expected one of "project", "global");数据接口的非法正文/超长/未知 kind/项目缺名同样是 400;不落盘 |
conflict |
409 | 改名后的正文和另一条记忆完全相同(hash 唯一)——message 会给出占用的行号,改成编辑那一行即可 |
forbidden |
403 | 宿主提供 connection 信任校验且请求被拒(跨站)——这一步在判断方法之前 |
method-not-allowed |
405 | 用了 POST 以外的方法 |
not-found |
404 | 未知方法 / 路径不匹配 / memory、memoryUpdate 的 id 不存在 |
not-ready |
503 | 数据库没打开(例如目录只读):参数页照常可用,只有数据接口回这个 |
io-error |
500 | 写 settings.json 失败;内存里的运行时配置保持不变 |
internal |
500 | 兜底异常;细节只进日志 |
纯 CLI / headless profile 没有
webServer,这套路由不会挂载,插件其余功能照常。
恢复默认
| 想做什么 | 怎么做 |
|---|---|
| 单个字段回默认 | 行上「恢复默认」→ 标成「待恢复」,随下一次「保存」以 unset 提交(只删 settings 层里这个 key) |
| 整页回 YAML 基线 | 底部「恢复 YAML 基线」→ 立即调 reset(清空 settings 层),会先确认 |
| 只用命令行 | 删 $DSH_HOME/memory/settings.json,再刷新页面 |
| 连记忆一起重来 | 停 DSH,删 ~/.dsh/memory/(数据库 + 设置一起没,不可恢复) |
记忆数据管理(增删改查)
「设置 → 记忆 → 记忆数据 → 管理数据」打开一个覆盖整个设置面板的弹窗,直接管 memory 表里的条目,不用再让模型调工具:
| 动作 | 怎么做 | 背后 |
|---|---|---|
| 查 | 搜索框(标题/正文/标签子串,2 字中文也命中)+ 类型/范围/项目下拉 + 「含已取代」开关 + 翻页(每页 20) | memories |
| 增 | 「新增记忆」→ 填正文(必填)、标题、类型、范围、项目、标签、优先级、常驻 | memoryCreate |
| 改 | 行上「编辑」→ 同一表单,带最近 10 条旧正文(历史版本) | memory + memoryUpdate |
| 删 | 行上「删除」→ 二次确认 | memoryDelete |
几个刻意设计的行为:
- 改正文 ≠ 改属性:正文变了才
version+1、写memory_history、重算hash/向量/倒排特征,和 LLM 抽取改写走同一套不变量;只改标签/优先级/常驻时version不动(版本号表示「第几版正文」)。 - 同文本幂等:
memory.hash是唯一索引,一行正文只存在一份。新增时命中已有正文 → 更新那条的属性并提示「相同内容已存在,已更新 #id」,不会报错也不会造重复行;编辑时撞上另一行 → 409,提示去改那一行。 - 项目范围的硬约束:
scope='project'必须给project,否则这行在召回里永远不可见(所有读路径都按project = ?过滤)——host 直接 400,不静默改全局。 - 删除是真的删:FTS 行与
memory_features由触发器一并清理;memory_history保留(审计用途),和memory_forget的行为一致。 tags两种写法都收:数组或逗号分隔字符串(中英文逗号均可),最多 20 个、每个 ≤ 64 字。- 弹窗用 portal 挂到
<body>:设置面板可能处于滚动/变换容器里,react-dom可用时用createPortal,不可用时退化为position:fixed。
改完一条,页面顶部的统计卡会重新拉
state,计数立刻更新;弹窗自己的列表也会按当前筛选/页码重读。
向量模型(可选)
一句话:不配就用内置词法(默认),配了就用你指定的模型;配置只影响「语义」那一路,词法那一路永远在,模型挂了也不会让召回全废。
不配置(默认)
vector.provider: hash,走 src/vector.js 的 384 维 feature hashing:进程内计算,不联网、不需要 key、不增加启动耗时,老数据继续命中。它编码的是字词重叠(字符 2/3/4-gram + 英文词),不理解同义词。
配置成外部模型
设置 → 记忆 → 向量:
| 字段 | 填什么 | 例子 |
|---|---|---|
vector.provider |
openai = OpenAI 兼容的 /embeddings |
openai |
vector.model |
模型名 | text-embedding-3-small / BAAI/bge-m3 / nomic-embed-text:latest |
vector.dims |
0 = 自动;填了就当声明/校验 |
自动 / 1536 |
vector.baseUrl |
兼容地址,含 /v1;不以 /embeddings 结尾会自动补 |
http://127.0.0.1:11434/v1 |
vector.apiKeyRef |
环境变量名(经 DSH 凭据解析);不像标识符的值按字面密钥(会明文进 settings.json) | OPENAI_API_KEY |
vector.minCosine |
语义召回下限(模型相关) | bge/nomic 0.5,OpenAI 0.7 |
vector.timeoutMs |
单次请求超时 | 5000 |
维度解析顺序(vector.dims = 0 时):内置模型维度表 → 库内探测缓存 → 现场探测一次(把探测结果写回缓存)。已知表覆盖 text-embedding-3-*、ada-002、bge-*、nomic-embed-text、mxbai-embed-large、all-minilm、jina-embeddings-v*、text2vec、m3e-*、gte-*、text-embedding-v* 等;表里没有的模型会在第一次调用时按返回长度自动确定。返回长度与声明维度不一致 → dimension-mismatch,不写库也不静默截断。
dimensions请求参数不发送:OpenAItext-embedding-3-*支持截断,但多数兼容服务会 400。所以vector.dims只做声明与校验,不改模型输出宽度。
它怎么跑起来
- 写入不等待:保存记忆仍然是同步的(照旧写
memory.vec词法向量),外部模型的向量由后台 worker 分批补齐(32 条/请求、256 行/轮),失败按 5s→10s→…→5min 退避。刚保存的条目在几秒内即可被语义召回。 - 查询一次:每轮 pre-step 把用户这句话嵌入一次(最多等 5 秒,最近 32 条文本有缓存);失败就退回词法,日志 debug 一行,不打断回合。
- 召回:语义通道按
vector.scanLimit「从新到旧」有界扫一遍(无语义意义的字面预筛在这里会漏,所以不用倒排),cos >= minCosine才算候选;分数归一为(cos - minCosine) / (1 - minCosine),再按vector.weight混进原打分。 - 存储:
memory_embeddings(memory_id, model, dims, text_hash, vec),model是 tag(如openai:text-embedding-3-small@1536)。换模型/改维度 ⇒ 新 tag ⇒ 旧行不再参与打分;新模型跑通后才清掉旧 tag 并重算(配错了不会毁掉原来那份),正文被改(hash变)也会重算。
降级矩阵
| 情况 | 结果 |
|---|---|
没配 / 配回 hash |
完全等于旧行为,零网络请求 |
model 为空、key 缺失(401/403)、5xx、超时 |
语义通道不可用,召回走词法;设置页统计下方显示最后一条错误 |
| 维度无法解析 | 同上,直到把 vector.dims 或服务修好 |
| 数据库被旧版插件打开 | 新表不存在时所有语义访问直接跳过,插件照常工作 |
成本与排障
- 1 万条记忆、32 条/请求 ≈ 313 次回填请求;1536 维 ≈ 6 KB/行,只保留当前 tag。
- 每次召回会临时读最多
vector.scanLimit行向量(2000 × 1536 × 4B ≈ 12 MB 上限,打分后丢弃);语料很大时把这个数调小。 memory_stats的sem=x/y model=<tag>是覆盖率;POST /memory/api/embeddingTest会真发一次请求,返回{ ok, dims, model, tag, endpoint, ms, error }——用它区分「地址错、key 错、维度错」。- 详细设计取舍见 ANALYSIS-vector-model.md。
工具
| 工具 | 参数 | 说明 |
|---|---|---|
memory_search |
query, limit?, scope? |
检索记忆(含 L1 事实 / episode),返回 id/日期/类型 |
memory_save |
text, kind?, scope?, tags?, pinned? |
人工固化一条记忆(同文本幂等) |
memory_forget |
ids? / query? |
删除错误/过期记忆 |
memory_stats |
— | 分层计数、L0 待抽取数、场景/画像数、token 花费、库大小 |
memory_extract |
all?, sessionId? |
手动补跑抽取(调试/恢复);all:true 处理所有还有 L0 待抽取的会话(最多 20 个,最老积压优先) |
memory_compact |
scope?(project/global/both) |
手动重写画像,跳过批次阈值与 JEV 门控;用于把膨胀的画像压回去,或按需刷新 |
工作方式
session/event(turn/end)
├─ L0: appendTurn(原文 + 工具名 + seq 范围)
└─ scheduler: warm-up 阈值 / idle 60s
└─ pipeline(全局串行队列)
├─ 取 pending L0 → 一次 LLM 抽取(JSON)
├─ 解析容错(think/围栏/裸 priority/坏条)
├─ 候选召回(FTS) → 二次 LLM 冲突消解 → 启发式兜底
├─ L1: store/update/merge/skip + version/history
├─ L2: 场景归并(事实 ≥ minFacts)→ summary/body/heat → 超 maxScenes 淘汰
└─ L3: JEV-lite 判决 → 重写项目画像 + 全局画像
agent/pre-step(每轮首 step)
├─ 稳定段(每会话一次): 长期约定(pinned / 高优先 persona+instruction)+ 画像 + 场景导航
└─ 动态段(每轮): FTS/LIKE + 384 维词法向量混合召回(text/priority/kind/recency/vector 加权)
长期约定(core):召回是词法的,问候(「你好啊」)与偏好文本零重叠,pinned 偏好会永远进不了上下文—— 所以
pinned=1或priority>=85的 persona/instruction 行改由coreMemories()按策略取, 放进 stable 段最前面(### 长期约定(每轮都要遵守,优先级最高)),与提问内容无关。 有这一层,用户「记住我是谁」类的要求才在每一轮都生效。- 预算:core 段最多占
stableMaxChars的 60%(含标题与第一条),超出按条裁、不挤占画像。 pinned对任意 kind 生效(用户显式置顶即视为「每轮都要」):memory_save只在称呼/语气/硬规则这类 「永远成立」的要求上置顶,别把进行中的任务状态置顶。- 手工
memory_save只能写fact/note,所以工具侧进入 core 的唯一入口是pinned;未置顶的fact只走动态召回。
- 预算:core 段最多占
阈值:新会话 1→2→4→8→
everyN(默认 5),之后按everyN;idle 60s 兜底。失败:LLM 超时/报错/JSON 坏 → 重试,最多 3 次后丢弃该批并记录
l1-last-reason; 抽取失败不影响会话,L0 原文仍在。成本:一次抽取约 2–5k tokens,按阈值摊薄;
memory_stats的tokens=是累计花费。向量:每行写入时算一个 384 维
Float32向量(字符 2/3/4-gram + 英文词,feature hashing + signed hash + L2 归一化), 存成 BLOB(约 1.5 KB/条)。召回时先用倒排特征索引(memory_features,存未哈希的 n-gram/英文词)按稀有特征取候选, 再对候选算精确余弦;索引没有提名时才按updated_at DESC做有界兜底扫描(最多vector.scanLimit行)。 它是词法向量,不是语义向量:能补字词重叠与排序,不理解同义词(「构建」和「编译」仍不同)。规模化:向量召回不再对整表做暴力余弦。
memory_features在每次打开库时渐进补建(每次最多 500 行), 大库首次升级可能需要几次重启才补齐;没补齐的行仍由兜底扫描覆盖。索引模型(feature-index-model)变化时整表重建。注入:
<memory_context type="stable|dynamic" source="dsh-memory">,source.kind='plugin:dsh-memory'(会话格式 v4 只接受 producer-owned kind; 写成{kind:'plugin', plugin:…}会让整轮失败)。全局记忆的三条硬规则(v0.4.2,详见 AUDIT-global-recall.md):
scope只在新建时决定:update/merge改写一条已存在的行时沿用该行自己的 scope, 不会因为「在哪改」而被降级成项目行(除非调用方显式传decision.scope);- 全局偏好每轮都重查一次(按 id 在会话内去重),不是「每会话只开一次门」—— 开场是问候语时也不会把这一整场的全局通道关掉;
- 没有文字的轮(只发附件/图片)仍注入 stable 段(长期约定 + 画像),只是不做词法检索。
另外
scope=project但拿不到项目键的行按global落库,不产生永远召不回的幽灵行。
scope='global'≠ 「每轮都注入」(最容易误解的一条,详见 AUDIT-global-recall.md): global 只表示「不限项目、各项目都可见」,注入仍要靠策略(pinned或persona/instruction ≥85) 或词法命中。实测:库里唯一那条 global 行去掉pinned后,20 条真实短句 × 3 个项目的可见性是 0/60。 想让一句话「每轮都生效」,显式pinned: true。
效果实测(2026-10-22,v0.3.0)
数据:真实记忆库副本(28 条有效记忆:9 episode / 11 work_fact / 3 fact / 2 work_method / 1 work_artifact / 2 work_task), 13 条多词查询 + 4 条 2 字中文查询,人工标注期望命中 id;脚本 notes/vector-eval.mjs。
| 配置 | hit@1 | hit@3 | hit@5 | MRR | 平均召回 |
|---|---|---|---|---|---|
| 向量关闭 | 82.4% | 94.1% | 94.1% | 0.882 | 2.5 ms |
| 向量 weight 0.1 | 82.4% | 94.1% | 94.1% | 0.882 | 2.8 ms |
| 向量 weight 0.2 | 82.4% | 94.1% | 94.1% | 0.873 | 3.3 ms |
| 向量 weight 0.35 | 82.4% | 94.1% | 94.1% | 0.873 | 2.7 ms |
结论:
- 在这个语料上词法向量是中性:命中率不变;权重 ≥0.2 反而轻微掉 MRR(把长文事实顶到前面)。
- 开销:+1.5 KB/条(28 条共 43 KB),查询 +0.3 ms(扫描/反序列化)。
- 它补不了语义:
编译构建流程仍然 miss(没有行包含「编译」)。 - 评测还发现并修复了一个真实 bug:启用向量时,LIKE-only 候选(2 字中文)曾被误扣 0.15 基础分;修复后各模式对齐。
- 默认权重因此设为 0.1;向量保留的价值在于大语料/短查询时的候选兜底,而不是语义理解。
隐私
- 抽取会把会话文本发给当前会话配置的同一个模型 provider(默认跟随
agentDefaultModel); 不想发送就设extraction.enabled: false,插件退回「原文 episode + FTS 召回」,不调任何 LLM。 - 数据全部在本地
~/.dsh/memory/memory.db,不引入第三方服务。
验证是否真的加载了
插件列表:侧栏「插件」页里应出现
dsh-memory-layered(来自dsh.profile.bundles),状态为启用/active。工具层:新会话里说「看下你的记忆库」→ 模型应调用
memory_stats,输出含l0=、scenes=、vec=、feat=、tokens=。抽取层:聊几轮后
memory_stats的l0=0/N(待抽取为 0)且kinds={...}出现work_fact等; 或直接让模型调memory_extract看reason=ok。注入层:消息里出现
<memory_context type="stable|dynamic">;会话日志 request/header 里有memory_*工具:node notes\session-inspect.mjs "$env:USERPROFILE\.dsh\sessions\<项目目录>\<会话目录>\session.v4.jsonl.zstd" --tools
排错
| 现象 | 原因 | 处理 |
|---|---|---|
插件列表里看不到 dsh-memory-layered |
只写了 patch insert,没注册成 bundle | pwsh -File .\install.ps1 -Apply → 重启 |
条目 enabled: true 但 fiberPhase: null,工具消失 |
node_modules/dsh-memory-layered 断链或目录丢失 |
同上(脚本会重建 link) |
本轮失败:format v4 message requires a producer-owned source kind |
注入消息用了旧 source 形状 | 本版已用 { kind: 'plugin:dsh-memory' } |
memory_stats 里 l0 一直不降 |
没有 llm 服务/模型,或抽取一直失败;重启后旧会话不会自动重试 | 看日志 [dsh-memory];l1-last-reason 会写 llm_error/parse/timeout;调 memory_extract 传 {all:true} 补抽积压 |
抽取 reason=parse |
模型把 Windows 路径写成单反斜杠(\U/\m 非法转义),或前面带示例 JSON |
本版 repairStringEscapes 修非法转义与裸控制字符,并在多个 JSON 片段中选 memories 最多的一个;仍失败可用 all:true 重跑 |
| 没有人格/场景 | 事实太少(minFacts)或还没到 triggerEveryN |
多聊几轮,或调小阈值 |
| 召回为空 | 语料太少,或 2 字中文走 LIKE 兜底 | 用 memory_save 固化关键结论(fact 权重高) |
memory_stats 的 vec= 小于总数 |
旧行还没回填(每次 open 最多补 500 行)或写入路径没跑 v3 | 重启一次让它继续回填;或 enabled: false 临时关掉向量 |
memory_stats 的 feat= 小于总数 |
特征索引还在渐进补建(每次 open 最多 500 行) | 重启一次继续;不影响召回,未索引行仍走有界兜底 |
| 向量把不相关记忆排上来 | 词法哈希碰撞或权重过高 | 调低 vector.weight(如 0.2)或 enabled: false |
| 配了向量模型但召回没变化 | 模型没就绪(key/地址/维度),或语料还没回填完 | 看设置页「统计」下那行的「已嵌入 x/y」与红字错误;POST /memory/api/embeddingTest 真发一次请求;memory_stats 看 sem= |
| 语义召回把不相关记忆排上来 | vector.minCosine 对该模型太低(OpenAI 系整体分偏高) |
调高 minCosine(OpenAI 约 0.7,bge/nomic 约 0.5)或调低 vector.weight |
回填很慢 / 日志刷 embedding backfill failed |
接口不可用或限流,worker 正在按 5s→5min 退避 | 修好 key/地址后保存一次设置即可 refresh;或临时切回 vector.provider: hash |
开发与测试
按文件跑(不要用 node --test test/:它会为每个文件 spawn 子进程,在 DSH 沙箱下报 spawn EPERM):
cd dsh-memory
# 全量:265 项,0 fail(含 embedding / embedding-store / embed-worker / 语义召回 / 端到端语义注入 / 设置页渲染)
node --test --test-isolation=none (Get-ChildItem .\test\*.test.js | ForEach-Object FullName)
# 单文件
node test/recall.test.js
CI(.github/workflows/release.yml)用同样的「逐个文件跑」策略,新增测试文件无需改 workflow。
模块:
src/store.js— schema v1→v5 迁移、memory 表、FTS、meta、stats、向量/特征索引回填src/embedding.js— 向量模型运行时(内置词法 / OpenAI 兼容嵌入、维度解析、tag、查询缓存)src/embedding-store.js—memory_embeddings表(按 tag 读写、pending、有界语义扫描、计数、级联删除)src/embed-worker.js— 语义向量后台回填(分批、退避、换模型清旧 tag)src/vector.js— 384 维词法向量(feature hashing、pack/unpack、cosine)src/feature-index.js— 倒排特征索引(向量预筛、df 探测、渐进补建)src/l0.js— L0 原文、游标、pending、transcript 预算src/l1.js— 候选、冲突决策(store/update/merge/skip)、version/historysrc/extract.js— 抽取/去重 prompt、JSON 容错解析、决策落库src/scheduler.js— warm-up 阈值 + idle(可注入定时器)src/llm.js—ctx.get('llm')封装(超时/usage/降级)src/pipeline.js— 串行队列、失败重试/丢弃、L2/L3 串接(项目 + 全局画像)src/profile.js— L2 场景、maxScenes 淘汰、L3 画像、heat 衰减/导航src/consolidate.js— 场景归并、画像重写(项目/全局)、JEV-lite 判决src/recall.js— 查询词、FTS/LIKE + 倒排预筛 + 向量混合打分、稳定/动态注入块src/capture.js— v1 episode 捕获(兼容保留)src/settings.js— 字段目录(53 个 key)、resolveConfig归一化、校验/深合并/来源、原地应用src/settings-store.js—settings.json读写(原子替换、损坏隔离)、存储目录描述src/data.js— 记忆条目 CRUD(分页/筛选、hash 冲突、版本历史、向量与特征重算)src/api.js—POST /memory/api/*路由(POST-only、64 KB 上限、稳定错误码)index.js— 与 dsh 接线(事件、pre-step、工具、设置页路由、降级)
在受限环境里不要用
node --test test/(会 spawn 子进程触发 EPERM),逐个跑文件即可。
限制与后续
- 默认向量是词法向量(字符 n-gram hashing),不是语义 embedding:同义词、跨语言改写仍然召回不到; 要语义就在设置里配一个 OpenAI 兼容的嵌入模型(见 向量模型)——dsh 内核本身没有 embedding 服务,这条路是插件自己发 HTTP。
- 语义通道不做 ANN:每次召回按
vector.scanLimit有界扫描 + 精确余弦,万级语料足够;更大规模要么调小扫描窗口,要么换方案。 词法通道仍是「倒排特征索引预筛 → 精确余弦 → 有界兜底」。 没有引入 LSH/ANN:向量本身就是词法哈希,倒排预筛对真实字词重叠是无损的(哈希碰撞只影响候选排序,最终分仍是精确 cosine), ANN 的近似误差在这个特征空间没有收益。代价:memory_features约 +0.3–1 KB/条;大库升级后每次 open 补 500 行, 未补齐的行仍由兜底扫描覆盖。2 字中文/短拉丁查询的 LIKE 兜底仍是全表扫描(10k 行约百毫秒级),是下一步可优化项。 - L2 场景超过
maxScenes会按衰减热度淘汰(persona 正文引用的场景保留);被淘汰场景用l2-evicted:<project>记录, 在没有更新事实前不会重建,避免「淘汰→重建→再淘汰」反复烧 LLM。引用保护是子串匹配,2 字常用词可能过度保护。 - L3 现在同时维护项目画像与全局画像(
persona.global,默认 true):全局画像只吃scope='global'的事实, 有独立的批次计数l3-batches:@global与 JEV-lite 门控;与 core 长期约定层互补(core 管「每轮必须遵守的规则」, 全局画像管跨项目的背景与决策摘要)。只想要项目画像可设persona.global: false。 - 抽取/场景/画像共用会话模型;想更省可配便宜模型(
extraction.model)。 - 重启后旧会话的 L0 待抽取不会自动重试(idle timer 是进程内的);用
memory_extract {all:true}按最老积压补抽,最多 20 个会话。 - 单机单库;多人共享需换存储后端(接口隔离在
store.js)。