dsh-dhe-lore
已验证dsh-dhe-lore · v0.5.1 · MIT · Web 界面
DSH plugin: project-level shared memory for DeepSeek Harness — every session in a project shares one human-readable, git-committable memory store. · DSH 项目级共享记忆插件。
安装
dsh plugin add dsh-dhe-lore 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
发布到 npm 但没有公开仓库。安装前请检查包内容。
标签
说明文档
dsh-dhe-lore
给 DeepSeek Harness 用的项目级共享记忆插件。
同一个项目里的所有会话共享同一份记忆。记忆是纯 Markdown,放在项目内的 .dsh/lore/,
可以读、可以改、可以提交进 Git、可以 code review。
会话 A:记住这个仓库用 pnpm,npm 会写坏 lockfile
└─ 写入 <项目>/.dsh/lore/entries/use-pnpm--k3f9x2.md
会话 B(第二天、另一台机器、换个人):打开项目
└─ 上下文里已经带着这条记忆了
目录
它解决什么问题
会话是短命的,项目是长命的。每个新会话都要重新发现一遍:构建命令是什么、为什么当初选了 这个方案、那个看起来多余的分支是在绕哪个坑、测试为什么要连数据库。
这些知识现在活在已经关掉的会话记录里。dsh-dhe-lore 把它挪到一个项目级的、人和模型都能读
写的地方——就是项目自己的目录树。
四条设计取舍,都是有意的:
共享,不是个人。 没有全局作用域。记忆属于项目,跟着仓库走,跟着 Git 走。队友拉下代码 就拿到了同样的记忆。
可读,不是黑盒。 一条记忆一个 Markdown 文件,带一小段 front matter。你可以用编辑器打开、
改、删、git diff、在 PR 里讨论。没有数据库,没有二进制 blob,没有 embedding 索引。
人写和模型写是同一种东西。 origin 字段只区分来源(tool / auto),不区分待遇。你手写的
条目和模型提取的条目在同一张列表里,用同一套规则参与注入。
过时就退役,不是删掉。 每条记忆带一个状态:当前有效的照常注入,stale(旧了但没有替代品)
只在固定时注入,superseded(已被新条目取代)从不注入但留在盘上、并且仍能搜到。"我们以前这么
做、以及为什么改掉"本身就是下一个会话最需要的东西。细节见用法 → 退役。
和 AGENTS.md 的分工
两者都会被注入上下文,但回答的问题完全不同,不要互相替代:
AGENTS.md |
dsh-dhe-lore |
|
|---|---|---|
| 是什么 | 项目章程,人写给模型的 | 会话积累的事实 |
| 谁写 | 人(通常只在建项目时写一次) | 人和模型都写 |
| 怎么改 | 改文件、提 PR | memory 工具,或直接改文件 |
| 增长方式 | 稳定、少量 | 持续增长、会说错话 |
| 权威性 | 高——是规则,必须遵守 | 低——是线索,可以过时,用前先验证 |
| 典型内容 | "不许用 any"、"测试必须连真实数据库" | "pnpm build 要加 --filter,否则 OOM" |
注入块里明确写了记忆是低权威的、可能过时的。这是刻意的:一条被盲目相信的过时记忆, 比没有记忆更糟。
安装
# 从 registry
dsh plugin --profile web add dsh-dhe-lore
# 或从本地目录
dsh plugin --profile web add E:\path\to\dsh-dhe-lore
dsh plugin 是一个薄薄的 pnpm 转发器。退出码为 0 时,它会把声明了 dsh.bundle 的依赖自动追加
进该 profile 的 dsh.profile.bundles,插件随之下次启动时挂载。
然后重启 dsh web(宿主半边的改动需要重启才生效),并刷新浏览器(客户端 bundle 是内容
寻址的,重启后 URL 会变)。
依赖关系
宿主半边从 profile 的模块树解析 @deepseek-ai/* 与 zod——它们都是 peerDependencies(全部标了
optional),由运行时提供,不会被装成第二份。用 link: 装本地目录时 Node 会 realpath 到你的源码
目录,从那里向上找不到 profile 的 node_modules;此时可参考 开发 一节的做法。
react 是例外:它在宿主 Node 进程里根本解析不了(profile 里那条是指向不存在的
…/@deepseek-ai/dsh/node_modules/react 的坏 junction)。客户端 bundle 的 React 来自 web shell 的
静态模块表——所以绝不能把 React 打包进去,那会让 React 身份分裂、hooks 在插槽出口里失效。
用法
工具
只有一个工具 memory,用 action 区分操作。五个操作共用一套参数词汇,合成一个工具是为了省
上下文——每多一个工具,每个请求的前缀里就多一整份 schema。
| action | 作用 |
|---|---|
add |
记录一条。重复会被拒绝,并返回已存在条目的 id。加 supersedes: "<id>" 表示这条取代了旧的那条(见下「退役」) |
search |
按关键词和/或标签查。返回匹配条目的 id、标题、标签和正文(正文截断)。先搜再写 |
list |
列出条目(id、标题、摘要)。项目记忆与本会话便签合成一个列表统一排序:固定的在前、最近的在前 |
update |
按 id 改写一条。别人同时改了同一字段时拒绝写入,并把当前版本交回来(见「存储布局」) |
forget |
按 id 删除一条 |
search 和 list 都把项目记忆与本会话的临时便签合成一个视图再排序、再按 limit 裁剪:limit
是合并后的总数上限,不是"每个来源各 limit 条"。同一个 query 在两边含义也相同——任一个词命中即计分,
标题 3 分 > 标签 2 分 > 正文 1 分。
两者都可以加 origin 只看某一来源:origin: "auto" 是自动提取写的,origin: "tool" 是人或会话写的。
这是审计和清理提取噪声的入口。不传就是全部。
两者默认只返回"当前有效"的条目(status: "active")。要看退役过的,用 statuses:
memory { action: "search", query: "构建", statuses: ["all"] } # 连退役的一起看
memory { action: "search", query: "构建", statuses: ["superseded"] } # 只看被取代的
被状态挡掉的行数会写在结果里("另有 N 条被状态筛选隐藏"),不会让你以为那些记忆不存在。
memory { action: "add", content: "这个仓库用 pnpm;npm 会写坏 lockfile。",
title: "包管理器是 pnpm", tags: ["build", "tooling"], pinned: true,
refs: ["pnpm-lock.yaml"] }
memory { action: "search", query: "构建" }
memory { action: "list", origin: "auto" } # 只看自动提取的
memory { action: "update", id: "k3f9x2", content: "……", pinned: false }
memory { action: "forget", id: "k3f9x2" }
退役:比忘记更好的是标记
记忆会过期。过期有两种,处理方式不同,而且两者都不删除文件——"我们以前这么做,以及为什么改掉" 本身就是知识:
| 状态 | 什么时候用 | 还会进上下文吗 |
|---|---|---|
active(默认) |
当前为真 | 会 |
stale |
可能不再为真,但没有替代品 | 只有 pinned 的会("这个坑别再踩"值得一直留着) |
superseded |
已被更新的条目取代 | 从不(新的那条已经在上下文里了,两条一起给等于给了一对矛盾) |
# 记录替代品:写新的一条,同时把旧的那条标成 superseded
memory { action: "add", content: "改用 pnpm build;Makefile 已删除。",
supersedes: "k3f9x2" }
# 只是旧了,没有替代品
memory { action: "update", id: "m7p1qa", status: "stale" }
superseded 不能用 update 直接设:状态和"被谁取代"的指针必须一起写,否则会出现"宣称被取代
却没说被谁取代"的条目,读者只能猜。想退役就用上面两条路径。删除仍然只有 forget(Git 是你的恢复路径)。
退役是两次写(先写新的,再改旧的),不是事务。第二写失败时结果里会明确写出来:新条目已经落盘, 旧条目仍是 active,所以那两条都在被注入——照实告诉模型,比让它以为矛盾已经解决要好。
refs:这条记忆该去哪儿核对
refs 是最多 5 条仓库相对路径(如 lib/store.js),会在注入块里跟在条目标题后(→ lib/store.js),
也会显示在面板上。它只是标签:插件不会据此读文件,也不检查文件是否存在。价值在于下一个会话拿到
一条记忆时,知道该去哪个文件验证,而不是全仓库瞎找。
加 scope: "session" 写本会话临时记忆:只在这个会话里可见、不落盘、不共享。适合临时草稿。
会话结束时(agent/disposed)这些条目会被释放,所以长跑的 dsh web 不会把它们一直攒在内存里。
临时便签没有状态概念——它们总是可见,要撤下就用 forget。
memory { action: "add", scope: "session", content: "正在排查 #4821,怀疑是缓存未失效" }
直接改文件
memory 工具只是便利入口。文件就是事实来源:
cd <项目>/.dsh/lore/entries
code .
手写的条目和工具写的完全等价。INDEX.md 会在下一次写入时重建。
在提交里带上记忆
把 .dsh/lore/ 提交进 Git 即可(默认就该提交)。没有"个人草稿"需要忽略:scope: 'session' 的条目只存在于进程内存里,从不落盘;.dsh/lore/ 下每个 .md 都是共享记忆。
唯一的坑是 .gitignore。 不少项目(包括本插件的仓库)一开始就写了 .dsh,因为 .dsh/ 下还有 profile、缓存这类纯本机状态。那一行会让记忆永远进不了版本库,而且完全无声:git status 干净,记忆却只在你这台机器上。目录一旦被排除,就无法再排除其中某个文件,所以必须显式用 ! 把 lore/ 捞回来;顺序也有意义——*.tmp 那行必须在两个 ! 之后,否则它又会被重新包含:
.dsh/*
!.dsh/lore/
!.dsh/lore/**
# 原子写会短暂产生 entries/<名字>.md.<pid36><8位hex>.tmp,正常情况下写完立即
# `rename` 掉。残留(进程被强杀)时忽略的是暂存文件本身,而不是 tmp-*(它匹配
# 不到任何东西)。
.dsh/lore/entries/*.tmp
改完确认一下真的生效(check-ignore 会打印命中的那行,!.dsh/lore/** 说明是反排除):
git check-ignore -v .dsh/lore/INDEX.md
git status --short # 应能看到 .dsh/lore/ 是未跟踪(??)
存储布局
<项目根>/.dsh/lore/
├── INDEX.md # 自动生成的索引,请勿手改
└── entries/
├── use-pnpm--k3f9x2.md
└── note--a7b1c9.md # 纯中文标题的 slug 会退化为 note
一条记忆一个文件,这不是为了整齐,是为了并发安全:两个会话同时写不同条目会碰不同的文件,
不存在"读—改—写"的丢失窗口。全项目唯一的整文件重写是 INDEX.md,它是派生产物,允许竞态。
两个会话在同一瞬间记录同一条事实,仍可能各写一份——查重与写入之间没有互斥。所以写入之后
会再读一次对账,判负的一方删掉自己刚写的那份(幂等,对手可能已经删过)。判胜用的是条目的 created,
同毫秒再比 id,所以胜者未必是先写的那个;当判定要求删掉的是对手那份文件时,代码不碰它——
那份 id 已经交给了另一个会话,撤销它比多留下一条重复更糟。
于是这一格会留下两条重复,但不再静默:add 的结果里会带 nearDuplicate,日志写一条点名两个 id
的 warn,工具还会直接告诉模型该 forget 哪一条、或 update 幸存者做合并。重复是可见、可读、一条
命令就能清掉的噪声——不是数据损坏。(为什么闭不上、以及为什么"写入顺序"当不了判据,见 DESIGN §5.5.1。)
并发改同一条则不再是谁后写谁赢。update 在覆盖之前会重读一次文件并逐字节比对,如果这一版
不是你读到的那一版:
- 别人改的字段和你改的不重叠 → 你的改动被施加到他那版之上,两边的工作都留下;
- 重叠(都改了正文/标签/固定状态)→ 一个字节都不写,返回
ok: false与当前版本(含正文), 并点名冲突字段,让你重新施加; - 他删了这条 → 不复活;文件被改到读不出 front matter → 不覆盖,让你去看那个文件。
这不是"永不冲突"的魔法:两个写入者恰好卡在"校验完成"与 rename 之间那一瞬仍会丢失。
要彻底关掉需要跨进程锁,而锁的孤儿状态会阻塞之后所有写入——不值得。
一个条目的样子:
---
id: k3f9x2
title: "包管理器是 pnpm"
tags: ["build", "tooling"]
refs: ["pnpm-lock.yaml", ".github/workflows/ci.yml"]
status: active
scope: project
origin: tool
created: 2026-02-14T10:22:33.123Z
updated: 2026-02-14T10:22:33.123Z
sourceSession: 9f2c1a
sourceCwd: "E:\\work\\my-project"
pinned: true
---
这个仓库用 pnpm。用 npm 会写坏 `pnpm-lock.yaml`,并且 CI 会因为
`--frozen-lockfile` 失败。要重装依赖时用 `pnpm install --force`。
| 字段 | 含义 |
|---|---|
id |
6 位 base36 随机 id,update / forget 用它 |
tags |
小写主题标签,用于检索 |
refs |
最多 5 条仓库相对路径,只用于展示(注入块与面板),插件不会去读它们 |
status |
active / stale / superseded;缺失或写了别的一律按 active 读(打错字不该让一条记忆悄悄消失) |
supersededBy |
只在 superseded 时出现,指向取代它的那条 id |
origin |
tool(人写的)或 auto(模型提取的) |
pinned |
固定:裁剪时最后才丢,排序最前;stale 条目只有固定的还会进上下文 |
sourceSession / sourceCwd |
溯源:哪次会话、哪个子目录写的 |
这些字段手改就生效:摘要在下一次注入时重算,而 status / refs / supersededBy 都被摘要覆盖,所以
把一条标成 status: stale(哪怕不改 updated)也会让模型在下一次注入时收到新版本——否则它会一直拿着
一份你已经标记作废的旧副本。
文件名里的 slug 只是给人看的;唯一性由 --<id> 保证。所以纯中文标题退化成 note--<id>.md
不是缺陷,只是不好看。
文件名在条目创建时定死,之后改 title 不会改文件名。这是有意的:名字跟着标题走的话,每次改名都得
"先删旧文件、再写新文件",写入一旦失败就整条丢失,两个会话同时改同一条还会留下两个同 id 的文件。
名字固定之后,每次更新都是一次原子替换。要看最新标题,读 front matter 里的 title 或 INDEX.md。
id 会被拼进文件名,所以头部里的 id 必须是 6 位 base36 形状;写了 id: ../../x 这类值,该文件会被
当作畸形条目跳过(有 warn),而不是载入。
配置
写在 profile 的 cordis.patch.yml 里:
- id: dsh-dhe-lore
name: dsh-dhe-lore
config:
maxInjectBytes: 16384
injectSummaryOnly: false
injectEnabled: true
extractionStatusEnabled: true
toolEnabled: true
toolName: memory
autoExtract:
enabled: true
everyNTurns: 2
provider: deepseek
model: deepseek-chat
| 键 | 默认 | 说明 |
|---|---|---|
projectRootMarkers |
['.git'] |
向上找项目根时的标记目录/文件 |
memoryDir |
.dsh/lore |
相对项目根的存储目录 |
maxInjectBytes |
16384 |
单次注入的硬上限(UTF-8 字节)。设为 0 表示完全不注入——不发任何消息,而不是发一个最小空框 |
maxEntryBytes |
2048 |
单条正文的 UTF-8 字节上限,写路径与注入路径共用。超长写入被拒绝(报错,不截断、不静默丢弃);手写超长的文件不被改动,只在注入时截断 |
maxSnapshotBytes |
32768 |
落进会话日志的那份快照的 UTF-8 字节上限,覆盖整个包(身份字段在内)。超了丢行(按 pinned → 最近更新的顺序从尾部丢,先项目后会话),面板据此显示"N / 共 M 条"。连身份字段都放不下时整份不发,所以 0 = 一个字节都不写(注入本身不受影响,只少了面板那份数据)。写入拒绝、视图裁剪——两者语义刻意不同 |
injectSummaryOnly |
false |
只注入标题,不带正文 |
injectEnabled |
true |
关掉后完全不注册注入监听器,本插件不再触碰任何一步 |
extractionStatusEnabled |
true |
是否注册只读的提取状态路由(面板里那一行)。关掉只损失面板上的一行诊断,记忆与注入都不受影响 |
toolEnabled |
true |
是否注册工具 |
toolName |
memory |
工具名。注册表对重名会抛错,这是改名逃生口;注入文本与面板都引用解析后的名字 |
autoExtract.enabled |
true |
自动提取总开关 |
autoExtract.everyNTurns |
1 |
每 N 轮提取一次 |
autoExtract.minTurnChars |
200 |
本轮内容短于此值就跳过 |
autoExtract.maxInputBytes |
32768 |
送给提取模型的轮次文本上限 |
autoExtract.maxEntriesPerTurn |
3 |
单轮最多接受几条(0 关闭) |
autoExtract.timeoutMs |
20000 |
提取调用超时 |
autoExtract.provider / .model |
未设 | 显式指定路由;必须同时给,否则回退到会话自身的路由 |
上下文注入的行为
唯一的注入点是 agent/pre-step。 一个注入点就同时覆盖新会话、恢复的会话、以及中途记忆发生
变化的会话。
具体来说,插件不会另外挂 agent/session-start + agent.inject():那两个在
source === 'resume' 时会和 pre-step 一起触发,导致每次恢复都注入两遍。
按内容摘要去重,而不是"注入过没有"。 摘要覆盖模型能看到的每一个字段:每条目的
(id, title, tags, refs, status, supersededBy, pinned, updated, 正文) 排序后求 SHA-1,注入预算、工具名
与错误提示也一并计入(它们决定这段文本长什么样)。摘要没变就不注入,所以:
- 记忆没变 → 一个字节都不花(但仍会读一次 store 做比对)。
- 手工编辑条目文件也算"变了",包括只改正文或标签、
updated原封不动。这正是 0.2.1 修掉的静默 故障:早先摘要只看(id, updated, pinned, title),手改正文永远进不了模型上下文。 - 把一条标成
stale/superseded也算"变了"(0.4.0):手工退役一条不会重写updated,摘要若看不 见status,模型就会继续拿着一条你已经作废的旧副本。 - 记忆变了 → 下一步重新注入完整记忆块(不是增量 diff),无论会话是新的、恢复的,还是已经跑了几十轮。
- 进程重启后 → 摘要从会话可见面里恢复,所以恢复不会重复注入。
压缩会被感知。 压缩可能把注入块从模型上下文里挤掉。插件同时看
session.surface.replaceGeneration(位置替换的单调计数)和节点数,一旦发现可见面被替换或收缩,
就重新扫描并重新注入——不会出现"记忆悄悄消失、直到内容变化才回来"的静默故障。
注入哪些条目:active 总是;stale 只有固定的会;superseded 从不。被挡住的条数会写在注入块
末尾("N 条已过时或被取代,未在此展示"),并告诉你用 search 配 statuses 去读——否则模型会以为
那些记忆不存在,而那句"还有 N 条没展示,用 search 取"对它们并不成立(默认视图同样看不见)。
裁剪顺序:固定条目 → 最近的 → 只剩标题。最具体的内容最后才丢,丢了多少都会在注入块和面板
里明确报出来,绝不静默丢弃。refs 挂在标题行上,所以裁剪到"只剩标题"时它仍然在——"这条记忆该去哪儿
核对"正是裁剪后最不该丢的信息。
防注入。 记忆正文是模型或仓库文件可以书写的内容,所以它被包在
<system-reminder> 框架里,且正文中任何字面量 </system-reminder> 都会被转义成
<\/system-reminder>,无法提前闭合框架、逃逸到指令流里。
自动提取
一轮结束时(agent/turn-stopping)后台跑一次提取,把这一轮里的耐久事实挑出来存档。
- 不阻塞。 监听器同步返回
undefined,提取是 detach 出去的。为了维护记忆而拖慢模型的回答 是不划算的。 - 失败只记日志。 提取坏掉绝不能让会话坏掉。
- 路由跟随会话。 默认从最新的
request/context读会话实际在用的 provider/model,而不是写死 一个默认值。 - 真正的降噪闸门是去重,不是提示词。 提示词里反复强调"宁可返回
[]",但最终拦住重复的是 store 里的三层去重(标题 / 正文 / 近似包含)。重复的记忆是会复利式变坏的那种故障。 - 能纠正旧记忆。 请求里会带上项目现有记忆的清单(id + 标题 + 状态,最多 2000 字节,固定过和最近
的在前面),模型如果发现这轮的事实替代了其中一条,可以指名它。落盘时新条目照常写入,被指名的那条
被标成
superseded(不删除,仍可搜到)。清单外的 id 一律丢弃并记 warn:项目大了清单会被截断,一个 模型没见过的 id 不该有权退役任何东西。提取永远不会把条目标成stale——"这条不再为真、也没有替代 品"是对整个仓库的判断,不是一轮对话能得出的结论。
提取的输入排除插件自己注入的上下文——把记忆再喂回提取器,等于让它把自己知道的东西重新 推导一遍,然后通过复述慢慢漂移。(清单里因此只有 id / 标题 / 状态,不带正文。)
送进模型的转写与那份清单是一个 JSON 对象({ existingMemory: [...], messages: [{ role, text }] },
前面一句固定导语),不是把 [user] / [assistant] 标签拼成一段文字。区别是安全性的:拼字符串时,
用户消息或仓库文件里写一句 [assistant] 就能伪造一个角色边界、在提示词里替模型说话;包成 JSON 之后,
它只能落在某个字符串值里面。
每次提取都会留一行 debug 日志,无论成功、跳过还是超时:
dsh-dhe-lore: extraction turn=3 stage=ok 812ms tokens=in:1200,out:88,total:1288 candidates=2 accepted=1 superseded=1 dropped=1
stage 说明它走到了哪一步(ok / skipped:short-turn / skipped:no-route / timeout / …),
candidates 是模型提议的条数,accepted 是真正落盘的条数,superseded 是成功退役的条数(写进去 2 条
但只退役 1 条时,这两个数字不一样)。没有这行日志就没法调提示词:只看"落盘了几条"分不清"模型什么都没提"
和"全被去重挡下了"。想看就把日志级别开到 debug。
这条日志在每一条出口上都会出现,包括会话结束:aborted:disposed 表示提取还在飞的时候会话就没了
(此时流会被主动取消,结果丢弃,不打任何 warn),aborted:turn 表示这个回合被中止。另外
timeoutMs 是硬的:即使 provider 完全无视取消信号、流永远不结束,我们也会按 timeoutMs 收手,
记 stage=timeout 并写一条 warn,而不是永远挂着。
候选数超过 maxEntriesPerTurn 时会记一条 warn 写明丢弃了多少,不静默截断。自动提取的条目
origin 是 auto,可以用 memory { action: "list", origin: "auto" } 单独翻出来审。
同一份结果也送到面板(0.5.0 起)。上面那行日志是"唯一观测面"这句话在 GUI profile 里其实不成立:
dsh web 和桌面版都不写用户能打开的日志文件。于是「项目记忆」标签页底部会显示本会话最近一次提取的
stage 与计数——提取:ok:no-candidates · 09-21 11:08 就是"模型被问了、什么都没提",而
提取:skipped:no-route · 09-21 11:08 是"根本没调模型"。没测过的数字显示为空,不显示 0,所以
不会出现"从没跑过"被读成"跑了但一无所获"。细节见「只读面板」与 DESIGN.md §8.7。
只读面板
会话视图里会多出一个 「项目记忆」 标签页。它显示:
- 项目根与记忆目录的完整路径
- 已注入条数 / 总条数、固定数、裁剪计数、摘要短哈希
- 按 固定 / 项目记忆 / 本会话临时记忆 分组的条目列表(标题、标签、更新时间、
origin徽标、摘要); 模型还没看到的条目标为「未注入」。列表最多显示 200 条,超过时顶部会给出封顶提示。 - 0.4.0 起,退役的条目另有徽标(
已过时/已被取代)并显示「相关文件」与「已被 [id] 取代」。 面板列出的是磁盘上的全部条目,所以"这行为什么不在上下文里"是自明的:状态徽标说原因,「未注入」 徽标说结果。active不给徽标——每行都写"正常"只是噪声。 - 0.5.0 起底部多一行提取状态,例如
提取:ok · 候选 2 · 采纳 0 · 09-21 11:08:这是宿主进程 本会话最近一次自动提取干了什么。鼠标悬停有阶段释义、耗时、token、实际用的 provider/model。若本 会话还没有记录,它会分两种说法——"本会话尚无记录(宿主已记录 N 个会话)"与"宿主尚未记录任何 提取"——因为前者可能是 id 对不上、后者是本进程确实没跑过,修法完全不同。没有窗口(旧宿主半边) 或请求失败时显示「提取:状态不可用」,绝不用空白冒充"没跑过"。
「已注入 N」里的 N 是模型实际收到的条数,不是列表行数——注入会被字节预算裁剪,两者相差很大是 正常现象。
- 项目根未解析、目录不可读等错误状态——不允许静默空白,而且不会同时说"本项目暂无记忆条目": 读不到库和库是空的不是一回事。
- 面板是
region,分组标题是heading,列表是真正的list/listitem,📌 带可读的aria-label, 空态与错误态是status——屏幕阅读器不会把它读成一大段没有结构的文字。
面板没有任何写按钮。要改就改文件,或者用 memory 工具。
面板的记忆内容走 session projection(props.useProjection('dshLore')),不是自定义 RPC。
官方插件 dsh-context 在 v0.9 明确把自定义 RPC 通道删掉换成了同一条路径。宿主端注册一个纯折叠
单元,客户端只是读——浏览器侧零计算。
唯一的例外是提取状态那一行(0.5.0 起):它观测的是"宿主进程做过什么",不是磁盘上的记忆,因此
不是会话事件,纯折叠送不出来(详见 DESIGN.md §8.7)。它由一条只读路由提供:
GET /dhe-lore/api/extraction?sessionId=<会话 id>
只回答阶段、计数、耗时、token、路由与宿主版本,不返回任何记忆内容、条目 id、正文或文件路径;
只接受 GET/HEAD,拒绝跨站与异源请求,响应 no-store。它不支持跨站调试之外的用途,也没有鉴权
(同机进程直连即可读到那几个数字)——介意的话把 extractionStatusEnabled 设为 false。
开发
cd dsh-dhe-lore
npm test # 两个套件,纯 node,无测试框架、无打包器
npm run test:behavior # 149 项行为测试
npm run test:packaging # 45 项打包契约检查(对着 profile 解析)
这两个套件只能在 DSH 的 profile / checkout 内跑:宿主半边 import 的 @deepseek-ai/* 由运行时
提供(都是 optional peer),所以从 npm 装出来的包直接 npm test 会在解析 peer 时失败。test/ 仍然
打进发布包里(dsh-session-tabbar 也这么做),是为了让这些断言可被核验,而不是承诺它能在裸环境
跑通。
行为测试覆盖真实文件系统(临时目录里的真实读写、24 路并发写入)、注入去重循环(含恢复与压缩两
条失效路径)、并发 update 的合并/报冲突每一格(用"第 n 次读取后再插对手写入"的 io 包装器把
交错钉死,不靠时序运气)、条目生命周期(退役的两条写与失败上报、状态筛选、旧文件按 active 读、
superseded 不能直设)以及渲染→投影的 schema 契约(含 0.3.0 写的快照仍能通过校验)。
打包检查断言 DSH 加载器真正会强制的那几条规则——都是"违了才知道贵"的类型:dsh.client 存在
就必须有 exports['./client'](否则加载器抛错);bundle patch 的行 id、客户端注册 id、npm 包名
三者必须一致;客户端 require() 只能用浏览器种子表里的 id;宿主半边每个裸 import 都必须声明为
optional peer。
模块解析
宿主半边 import 了 @deepseek-ai/dsh-tools、@deepseek-ai/dsh-llm、@deepseek-ai/schemastery
和 zod(@deepseek-ai/dsh-atomic-write 的声明已在 0.2.0 删除——那个 peer 从未被 import)。它们由
运行时提供,所以要在本仓库里跑测试,需要让它们从本包解析到。开发期用一个 junction 指过去即可:
$pkg = "E:\path\to\dsh-dhe-lore"
$src = "$env:USERPROFILE\.dsh\profiles\node_modules"
New-Item -ItemType Directory -Force "$pkg\node_modules" | Out-Null
foreach ($n in @("@deepseek-ai", "zod")) {
New-Item -ItemType Junction -Path "$pkg\node_modules\$n" -Target "$src\$n"
}
node_modules/ 已在 .gitignore 里。
注意:判断"某个 peer 能不能解析"必须从 profile 的模块树发起,而不是从本包目录——
npm run test:packaging 里的 createRequire(profiles/package.json) 就是干这个的。开发期的
junction 只是最小集合,拿它判定会得出相反结论(react 就这么被误判过一次)。
纯逻辑是零依赖的。 entry.js / project-root.js / store.js / render.js / session-scope.js
不引任何第三方包,可以用裸 node 直接测;只有集成层才碰 @deepseek-ai/*。这是刻意的分层,
不是巧合。
没有构建步骤
lib/client.js 是手写的部署产物:一个 window.__ModuleLoader__.load({...}) 调用,经典脚本,
顶层只有这一次调用。改它直接生效,刷新浏览器即可(客户端 bundle 内容寻址,重启后 URL 变化;
再不行就硬刷新)。
这也意味着:不能用 import/export,不能用 JSX,require('react') 必须从加载器取——
打包进第二份 React 会让 hooks 在插槽里失效。所有副作用(包括 CSS 注入)都在 factory 闭包内,
因为 factory 会被 HMR 重新物化。
已知边界
诚实清单,不粉饰:
- 面板列出的是"上次注入时的磁盘快照",并逐行标记哪些条目真的进了本会话的上下文。 投影是每会话的;
"已注入 N" 的 N 是模型实际收到的条数,不是列表行数——注入被预算裁剪时两者相差很大。列表本身
最多 200 条,超过时顶部给出封顶提示,
omitted/bodiesOmitted也照常显示。完整条目请看目录。 - 会话级记忆不持久化。 它在进程内存里,进程退出即丢失。这是"草稿纸"该有的语义。
它刻意不写进会话日志:往日志里追加自定义事件类型会让整个会话在下次恢复时不可读
(事件词汇表是构建期生成的、仅限仓库内部的闭集,而
Session.append()没有路径去标记ignorable)。写会成功,故障会推迟到下一次恢复才爆发——这个坑不值得踩。 - 项目根依赖
.git等标记。 不在版本库里的目录会退化到"会话自己的 cwd",此时插件会注入 一条明确的提示,而不是静默地什么也不做。 - 并发安全依赖"一条目一文件"。
INDEX.md是尽力而为的派生产物,极端并发下可能短暂滞后; 它是给人看的,不参与任何逻辑。 update的并发保护有一个微秒级残余。 字段重叠时一定会报冲突、绝不静默覆盖;但两个写入者 若恰好卡在"校验完成"与rename之间那一瞬(不是读-改-写的整段,只是最后一步),仍会丢失。 关掉它需要跨进程锁,而孤儿锁会阻塞之后所有写入,所以不换。forget与update撞上时以删除为准。update发现文件已经被别人删掉会返回missing, 不会把条目重建出来。- 退役是两次写,不是事务。
add { supersedes }先写新条目、再标记旧条目。第二写失败时结果是 "新条目在盘上、旧条目仍是active"——两条都在被注入。这种情况会明确报出来(结果里的supersedeFailure加一条 warn),但不回滚:回滚意味着为了保住一条不再为真的记录,去删掉一条 当前为真的记录。 superseded只能由add { supersedes }(或直接手改 front matter)产生,update会拒绝直接设它, 因为状态与"被谁取代"的指针必须一起写。这不是限制,是为了不让"宣称被取代却没说被谁取代"的条目存在。refs只是标签。 插件不读它们指向的文件、不检查存在性、不做路径解析;绝对路径与任何含..的 片段会被丢弃。别指望它替你验证文件还在不在。- 自动提取是尽力而为的。 失败只 warning。质量主要由去重门控保证,提示词是第二道。
- 提取状态那一行只活在本进程里。 它说的是"这次
dsh web进程里,本会话最近一次提取做了什么": 重启即清空,agent/disposed也会删掉该会话的记录。所以它回答的是「现在有没有在跑」,不是 「历史上跑成过什么」——后者只能看条目里的origin: auto与那行日志。刻意不落盘:从磁盘上恢复 出来的"最近一次提取"会变成对一个已经消失的进程的断言。(设计文档 §11.2 K12) - 那条只读路由没有鉴权,只有同源围栏。
/dhe-lore/api/extraction只接受GET/HEAD,拒绝Sec-Fetch-Site: cross-site与异源Origin,响应体只有阶段、计数、耗时与版本,不含任何记忆内容、 条目 id 或文件路径。但同机上的其他程序可以直连读到"某个会话跑没跑提取"这几个数字。不需要它就把extractionStatusEnabled设为false,面板那行会显示「状态不可用」,其余功能不受影响。 - 记忆是低权威输入。 注入块里写明了它可以过时、用前先验证。别把它当规则用;规则写进
AGENTS.md。 - 第三方客户端插槽不是冻结契约。 DSH 0.1.5-rc 阶段没有发布面向第三方的版本化插件 API 文档, 插槽目录是「生成 + 由模型查阅」的形态。插件为此做了降级:插槽不存在时面板静默不出现, 而不是报错。
- 只支持宿主看得见的工作目录。 读写两条路径都走
node:fs(写路径不能用ctx.fs:它没有 delete,forget就无从实现),所以真正跑在远端/sandbox fs provider 上的工作区不被支持—— 那种环境下本插件读写的会是宿主本地路径,而不是远端工作区。这是刻意的取舍:只把读搬到 provider 会让读写分属两个文件系统,比不做更糟。设计文档 §3.5 与 §14.1 第 4 条记了这笔账。 - 在
read-only沙箱下本插件仍会写盘。 它用node:fs写项目内固定推导出来的路径 (<projectRoot>/<memoryDir>/),不接受模型传入的路径,绕过了ctx.fs的 fence——这正是它能 直接写项目目录的前提。respectSandbox这个开关在 0.2.0 被删除,因为它从来没有读者。 - Windows 上的原子替换重试窗口是 1100 ms(20→200 ms 封顶、8 次,与
@deepseek-ai/dsh-atomic-write一致,win32-only)。更长的争用仍会让写入失败。 - 面板的数据来自投影的
viewSchema.parse()。 该 schema 由框架在每次快照读取时校验,所以render.js产出的快照形状和projection.js的 schema 必须严格一致——测试里有专门一条守住 这个契约。
故障排查
记忆没被注入。
按顺序查三件事:injectEnabled 是否为 true;maxInjectBytes 是否为 0(0 就是"完全不注入");以及会话 cwd 之上是否存在 .git。第 3 项不满足、或会话根本没有 header.cwd 时,注入块里会有一条明确的提示,而不是静默生效于别的项目。
面板没出现。
先确认宿主半边已挂载(profile 的 dsh.profile.bundles 里有 dsh-dhe-lore),再刷新浏览器。
conversation.view 插槽未声明时面板静默不出现,这是设计好的降级。
改了 lib/client.js 但浏览器没变。
客户端 bundle 是内容寻址的,重启 dsh web 后 URL 会变;或者硬刷新。若
@deepseek-ai/dsh-client-hmr 已挂载,/plugins/events 会自动触发重载。
自动提取一条都没写。
先看「项目记忆」面板底部那一行 提取:…(GUI profile 里没有可读日志,这行就是为它准备的;headless
或有日志的场合看 dsh-dhe-lore: extraction turn=… stage=…,把日志级别开到 debug)——它直接写着
为什么没写:skipped:short-turn 是这一轮太短,skipped:no-route 是拿不到 provider/model,
ok:no-candidates 是合法且常见的「这轮没有值得记的」。要更勤快就把 autoExtract.minTurnChars 调低、
everyNTurns 调成 1,并确认会话有过一次 request/context(也就是真的发过一次模型请求)。
dropped 大于 0 说明模型提了但被去重或超限挡下——超限会另有一条 warn。
想清理自动提取的噪声。
memory { action: "list", origin: "auto" } 只列提取写的条目,逐条 forget,或直接删文件。
人工写的条目 origin 是 tool,不会被误伤。
面板底部写着「提取:状态不可用」。
三种可能,按顺序:宿主半边是 0.4.0 或更早(没有这条路由,重启并在 profile 里装 0.5.0 及以上即可);这个
profile 没有 web 服务(headless);或者 extractionStatusEnabled 被设成了 false。若是
「本会话尚无记录(宿主已记录 N 个会话)」,那是面板的会话 id 与宿主记录的对不上,不是没跑过。
整轮对话失败,报 format v4 message requires a producer-owned source kind。
这是 0.5.0 及更早在会话格式 v4(DSH 0.1.7 起)上的故障,不是面板或配置问题:注入消息的
source.kind 写的是已废弃的 V3 包装 'plugin',而 v4 在把事件落盘时直接拒绝它,于是表现为
「本轮运行失败」——整轮对话被拒。升到 0.5.1 即可:它改用 producer-owned kind
plugin:dsh-dhe-lore,也就是 DSH 自己迁移旧事件时会分配的那个身份。历史会话不受影响——v3→v4
的迁移会把旧包装抬成同一个 kind,因此新旧事件同形。
两条记忆看起来重复。
去重是归一化的(忽略大小写、空格、标点),但语义去重不存在。用 memory 工具 forget 掉一条,
或直接删文件。没有 embedding 索引是刻意的——在这个规模上,关键词加标签加时间比向量检索更
可预测,而且不需要任何依赖。
许可
MIT