Skip to content

dsh-instruction-memory

Verified

dsh-instruction-memory · v1.4.2 · MIT · Web UI

User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。

Install

dsh plugin add dsh-instruction-memory

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-instruction-memory

在 DSH 设置界面中维护长期指令记忆:保存后自动注入此后每一轮对话的系统提示词。

设置页截图

想先看看界面?在 GitHub 仓库中打开 docs/demo.html,无需安装 DSH,浏览器直接体验真实设置页(内置演示数据)。

安装

dsh plugin --profile web add dsh-instruction-memory

宿主版本必须落在兼容性声明的范围内。范围之外宿主会拒绝安装/启动(1.0.14 起, 因为 peerDependencies 已声明);确需强行安装可用 dsh plugin allow-version 授予该精确版本的 豁免,风险自负。

装完重启 DSH,设置页会出现「指令记忆」入口。卸载:

dsh plugin --profile web remove dsh-instruction-memory

只由用户本人维护

这份记忆的内容只属于用户。 本插件刻意不注册任何面向模型的工具——模型没有任何 途径自行添加、修改或删除记忆条目。写入入口只有设置页(以及用户本人直接编辑存储文件), 因此不会出现 AI 悄悄给用户"记下"指令的情况。

条目内容由用户输入并被原样注入,本插件不对其做语义判断或自动生成。

组成

文件 作用
lib/index.js Host 半:读取/保存记忆,注册 systemPrompt 段落,暴露同源 JSON 路由
lib/client.js Client 半:在 settings.section 注册「指令记忆」设置页(手写模块,无需构建)
cordis.patch.yml bundle patch:把 Host 行插入宿主组合
install.mjs 开发安装脚本:从本仓库检出安装时,把包链接进 web profile 的 node_modules(junction)、注册 dsh.profile.bundles,并在 profile dependencies 里声明 link:<本仓库路径>(缺这一步插件能跑,但插件市场的「已安装」列表看不到它——市场只读 dependencies);--remove 三步完整回滚,改 profile 前自动备份
contract-test.mjs 契约测试:驱动真实路由,验证两半之间的响应格式(含配置集、版本历史、源码模式、备份位置)
ui-test.mjs 设置页行为测试:用一个 ~60 行的 React 垫片真实渲染面板、点按钮/填输入框,再断言注入内容随之变化(不出图、不依赖浏览器)
verify.mjs 装载验证:按 DSH 的方式加载两半,并逐项断言 memory.schema.json 与代码中的上限一致
smoke-test.mjs 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线)
host-range-test.mjs 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 peerDependencies(被执行的那个)、dsh.engines.dsh、dsh.compatibility.dsh 三处声明锁到同一个生成值
lint.mjs 语法门禁:node --check 解析所有随包发布与本仓库运行的 JS 文件,已并入 npm test
live-host-smoke.mjs 双端冒烟:对正在运行的 web / 桌面宿主各测 state p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 injection.pulls 外须逐字段相同);npm run smoke:hosts,--require-pulls 可把「从未经历真实 Agent 回合」判为失败。刻意不进 npm test——CI 没有桌面端。请求走 Node http 直连,绕开 http_proxy(否则回环请求也会被代理转发,读数虚高)
docs/demo.html 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布)
memory.schema.json 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 memory.json 旁边)

生效方式

  • 始终 条目:无条件遵守。
  • 按需 条目:由模型按相关性自行判断是否适用,不相关时忽略;可填「适用场景」辅助判断。
  • 优先级 高 > 普通 > 低,决定注入顺序。
  • 注入字数上限:超出时按优先级保留,被略过的条目数会在注入文本末尾注明。 上限单位是字符而非 token——按 DeepSeek 官方分词比例约 1 个中文字符 ≈ 0.6 token、 1 个英文字符 ≈ 0.3 token(不同模型分词有差异,实际用量以模型返回为准),上限 40000 字符约合 24000 token。
  • 预算用量可视化:设置页在上限输入框下方显示用量条——「实际注入 x / y 字符(约 z token, 估算)」(token 数按 0.6 系数估算,仅作参考);被上限略过的生效中条目会在列表里逐条 标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。 调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的展示,不改变注入文本本身; 存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
  • 标签、筛选与排序:每条指令可加若干标签(回车或逗号添加,最多 8 个、单个不超过 24 字符)。 标签只用于设置页分组与筛选,不会进入注入文本;标签缺失的旧记忆照常加载(等同无标签)。 列表可按标签、生效方式(始终/按需)、启用状态组合筛选,并在「按优先级 / 按更新时间 / 按标题」 之间切换排序;列表排序纯为查看方便,不改变宿主侧的注入顺序。
  • 批量操作:点「批量操作」进入多选,可对勾选条目一次性启用、停用、设置优先级、加标签或删除; 多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是单次写入 (只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。 批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。 「加标签」在浏览器里用原生输入框;桌面版(Electron)不提供 window.prompt(调用即抛错), 此时自动改用面板内的行内输入框(回车提交、Esc 取消),功能不受影响。
  • 用户形象:除了"要求 AI 怎么做"的指令,你还可以记录关于你本人的信息(身份背景、喜好、性格、 长期追求等),每张形象卡片可单独勾选「使用该形象」——可以一个都不用、只用一个、或同时勾选多个 (多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在 指令块之前,抬头明确把它限定为"仅供调整回答侧重":AI 应在保证回答正确、完整、客观且扣题的 前提下据此调整解释深浅、详略、举例与建议方向,不得为了迎合而改变事实、省略风险提示或偏离问题本身, 无关时直接忽略。形象不是让 AI 扮演的角色。形象段独立于指令字数上限,始终完整注入、不做预算裁剪; 页面会显示已启用形象的合计字符数,便于你权衡提示词体积。总开关关闭时形象与指令一起停用。

总开关关闭时,该提示词段落(指令与用户形象)整个注销,系统提示词与未安装本插件时逐字节一致; 总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。

临时静音(暂停注入一段时间,F7)

总开关旁提供「暂停注入…」:30 分钟 / 1 小时 / 到今天结束(本地时区 24:00)。暂停期间 指令与用户形象都不注入,系统提示词与「未安装本插件」逐字节一致;到期由插件内的 宿主侧定时器自动恢复——无需用户操作,也不产生写盘(存储文件与修订号不变)。暂停 状态保存在存储文件的 disabledUntil 字段里(仍是格式版本 2 的可选字段,缺失即未暂停), 因此跨重启保留。手动切换总开关会立即清除暂停(显式开关优先于定时)。设置页在暂停中 显示剩余时间倒计时,并可随时「立即恢复」;倒计时只是展示,权威判定始终在宿主快照 (injection.muted)里。到期判定与注入渲染都是可注入时钟的纯函数(isMuted、 planInjection(data, now)),相同输入永远得到相同字节。

存储

记忆保存在 DSH 数据根目录下(设置页会显示完整绝对路径):

<DSH_HOME>/instruction-memory/memory.json              # DSH_HOME 默认为 ~/.dsh
<DSH_HOME>/instruction-memory/memory.json.bak          # 上一版(崩溃恢复用)
<DSH_HOME>/instruction-memory/memory.schema.json       # 手改用的 JSON Schema 副本
<DSH_HOME>/instruction-memory/history/                 # 版本历史快照(见下文 F14)

路径优先级与官方 @deepseek-ai/dsh-home-paths 一致:显式 $DSH_HOME → ~/.dsh。 空白的 $DSH_HOME 视为未设置,绝不会退回当前工作目录——否则用户换个目录启动 DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 $DSH_HOME(如 ./data)会在 启动时按当前目录解析为绝对路径,因此设置页显示的始终是完整路径。

1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(.dsh-instruction-memory.json)。 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 *.migrated 保留。

当前存储格式为版本 2(1.1.0 及更早写的是版本 1,见下文「多情境配置集」)。 两者都能载入:版本 1 的文件会平滑迁移——其中的条目与用户形象全部归入一个名为 「默认」的配置集,文件在下次保存时写为版本 2(载入本身不写盘),并明确提示这一点。 反过来,用 1.1.0 及更早的版本打开版本 2 的文件会被拒绝载入并暂停修改(原文件保留、 数据完好),这是刻意设计:旧版看不到 sets 字段,若按版本 1 读进去,下一次保存就会把 配置集信息抹掉。升级回新版即可继续。

该文件可手工编辑,保存后已打开的设置页会自动刷新(见下文「外部改动自动刷新」); 流式更新不可用时「重新载入」按钮仍可手动读回。每次保存先写临时文件再原子替换, 进程崩溃不会留下损坏的半截 memory.json;保存前把上一版复制为 memory.json.bak, 若 memory.json 解析失败会自动临时回退到该备份并明确提示。

备份为什么是"复制"而不是"改名":曾经的做法是先把 memory.json 改名成 .bak、 再改名临时文件顶上。这两步之间 memory.json 是不存在的——只要第二步失败(文件被占用、 磁盘满、杀软扫描),主路径就一直空着,下次启动找不到文件就会建一份空存储,用户的 记忆看起来凭空消失,而真实数据其实好好躺在 .bak 里。改为复制后主文件在原子替换成功 前始终待在原位;同时启动时若发现 memory.json 缺失而 .bak 可用,会从备份恢复并写回 主路径,而不是从零开始。

多实例与多窗口(并发保护)

两个 DSH 进程共享同一 $DSH_HOME(如 web 与 tui profile 同时运行),或两个设置页 窗口同时打开时,数据可能被并发修改。两层保护:

  • 跨进程写锁:每次写盘前创建 memory.json.lock(独占创建),写完即删。锁存在超过 10 秒视为持锁进程已崩溃、直接接管;等待 2 秒仍拿不到锁不会导致保存失败——宁可继续 写入(last-write-wins)也不让用户的保存被卡死。原子替换保证任何交错下文件本身不会损坏。
  • 修订号(rev)与冲突拒绝:每次成功写入使 data.rev 加一并落盘。客户端的每次修改 请求都带上它看到的 rev(baseRev);若与服务端当前 rev 不一致(另一个窗口/进程刚 提交过),服务端拒绝本次修改并返回最新快照,面板自动刷新并提示重试——防止旧窗口 把别的窗口刚删除的条目"复活"回去。不带 baseRev 的旧客户端保持原有行为。

外部改动自动刷新(SSE)

设置页打开期间通过 EventSource 订阅同一路由上的事件流(GET + Accept: text/event-stream),存储一旦在面板之外发生变化,已打开的面板自动更新, 无需手动「重新载入」:

  • 触发来源:其他窗口经同一宿主保存(写盘成功即向所有订阅者推送变更);memory.json 被外部进程或手工改写(宿主以 fs.watch 监听存储目录——原子替换在 Windows 上会 使文件级监听失效——去抖 250ms 后经与写盘相同的队列重读,内容确有变化才重载并推送; 自己的写入盘后磁盘与内存一致,天然被同一比较抑制,不会回声)。
  • 推送的是变更,不是全量:设置页以 ?lite=1 订阅,宿主只推 { rev, lite: true } (约 40 字节),面板发现 rev 比自己渲染的更新时才回拉一次 state。满配存储的一份快照 约 1.24 M 字符(中文约 3.4 MB),逐次全量推送会把这些字节乘以打开的窗口数。不带该参数 的旧客户端仍收到完整快照,两代客户端与两代宿主可任意组合。
  • 回声抑制:发起修改的窗口也会收到自己那次写入的推送,但帧里的 rev 与它已吸收的响应 快照相同,客户端按 rev 比对直接丢弃(旧客户端的完整帧则按内容逐字节比对)——否则刚出现 的「已保存」提示会被空消息的推送帧瞬间冲掉。
  • 降级而非报错:宿主不支持或连接中断时 EventSource 按 retry: 5000 自动重连, 重连失败期间面板照常可用(「重新载入」仍是兜底),不会弹出错误。
  • 编辑器中未保存的草稿不受推送影响;保存时按当前 rev 提交,外部若已抢先修改 会被冲突拒绝拦下。

响应信封中的 saved 字段语义为「本次操作是否把存储写入了磁盘」,只反映真实写入: 普通读取(state/reload/export-data)一律 false;首次运行时 state 物化文件、 或 reload 触发从 .bak 恢复/旧位置迁移这类"载入即写回"的分支才为 true。早先版本 对读取操作也回 true,使该字段不携带任何信息。

若手工编辑后的文件有条目为空或超过 200 条上限,设置页会明确提示哪些条目未能载入 (它们将在下次保存时从文件中移除),不会静默丢弃。手改的选项值同样有披露:enabled 写成非布尔值、或 budgetChars 超出 800–40000 范围/不是数字时,载入会按规则修正 (回退到 true / 夹到边界),并在设置页明确提示原值 → 修正值——否则下次保存会把 修正值悄悄写回文件,你以为改过的设置其实从未生效。导入时若某条超出长度上限 (标题 120 / 内容 6000 / 适用场景 200 字符),会明确提示有几条、哪个字段被截断, 而不是悄悄改短。文件若声明了未来的格式版本(version 非 1),插件会拒绝载入并 暂停一切修改(此时保存会用 v1 格式覆盖文件,可能损坏数据),直到你升级插件或把 文件改回版本 1 后点「重新载入」。

设置页支持导出 / 导入:导出为自带说明的 JSON 文件,导出的是整份存储(所有配置集, 不是只看当前激活的那一套)——备份只带走一半就没有意义了;导入采用合并语义——在同一个 配置集内按 id 与「标题+内容」去重,只新增、绝不覆盖或清空现有条目。导入文件里带 sets 时会按需创建对应配置集并把条目放回原集合(本插件的导出天然带上);指向不存在集合 的条目会归入当前激活集合,并在结果里说明有几项如此处理。要"整体还原"请用上文 「从备份恢复…」,导入不做覆盖。

请求体上限由各项上限推导(`(MAX_ENTRIES × (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 8×24 + 200)

  • MAX_PROFILES × (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200) + MAX_SETS × (MAX_SET_NAME + 200)) × 2 + 100000`,另加形象与配置集的开销),保证本插件导出的文件一定能被自己导回——早先 写死的 1,000,000 小于满配导出(200 × 6000 = 1.2 M 字符),导致满配记忆导出后无法导入。

首次运行不写文件(惰性创建)

全新安装、尚无任何记忆时,插件不会在宿主启动那一刻创建空的 memory.json。

这里曾主动物化一份空存储,只为让设置页有个真实路径可显示。但那意味着插件在进程启动、 零会话、零任务的状态下执行了一次磁盘写入——这正是行为审计中唯一能被正当称为 "没活干还在动"的副作用。现在文件由第一个真正需要它的动作创建:一次保存、一次导入, 或设置页读取 state(面板打开时就会读)。

在这之前:storage.path 仍会显示路径,但 storage.onDisk 为 false,设置页显示 「尚未写入磁盘」(该文案本就存在),state.injection.registered === false,pulls === 0。 挂载日志相应为 from=first run (no store on disk yet) onDisk=no。

不受影响:从 .bak 恢复、以及从旧位置迁移——这两条分支只有在磁盘上已有真实数据时才会走, 它们仍然会立即写回。

多情境配置集(配置集 / sets)

一份记忆装不下"工作 / 个人 / 某客户"三套互不相干的规则,所以设置页新增了配置集: 多个命名集合共存于同一个 memory.json,用户显式一键切换,注入只渲染当前激活的那一套。

命名:存储字段叫 sets(配置集),不叫 profiles——profiles 已经被 1.1.0 的 「用户形象」占用了,两者是完全不同的东西:配置集是"记忆的分区",用户形象是"关于你本人的 画像"。文档里的 "Profiles" 指的就是配置集。

  • 配置集本身只有 id 与名称;条目与用户形象通过各自的 setId 指向它。因此"切换"只是 改一个字段——不会移动、复制或删除任何一条用户文本,也不可能出现"切到一半"的中间态。
  • 设置页顶部列出全部配置集(含各自的条目/形象数量),点击「切换」立即生效并写盘(重启后 保持);「新建」「重命名」「复制」「删除」齐备。新建与复制都不会自动激活——激活是 唯一会改变注入内容的动作,它始终是一次单独的、看得见的点击。
  • 删除当前激活的配置集会被拒绝(请先切换);删除其他配置集会连同其中的条目与形象一起 删除,确认框里写明数量。文件里至少要保留一个配置集。
  • 「复制」用于从模板派生:条目与形象会以全新 id 复制到新集合,客户A → 客户B 就是这么来的。
  • 全部配置集合计最多 200 条指令、50 个用户形象、20 个配置集。总开关与注入字数上限是 全局的,不分集合。
  • 提示词里不会出现配置集名称或任何"当前属于哪套"的标记——注入的就是激活集本身,和只有 一套记忆时的文本逐字节一致。切换集合会改变提示词前缀,这是预期的(缓存会随之失效一次)。

红线:绝不按进程 cwd 隐式建集或切换。 这条规则来自一次真实事故:早期构建把数据文件 解析到宿主进程的工作目录下,用户换个目录启动 DSH 就得到第二份空记忆,看起来像"记忆凭空 消失"。配置集沿用同一条纪律:activeSetId 只能由用户显式命令写入,载入时它只从文件里读, 任何"根据当前项目目录推断集合"的想法都不要加进来。

版本历史与回滚(F14)

现在只有一份 .bak,误改或批量误删之后回不到更早的状态。每次成功保存后,插件会把这一版 原样写入 <备份位置>/history/ 下的一个快照文件:

20261015T152355123Z-r12-laptop.json     # UTC 时间戳 - rev - 写入设备
  • 命名是固定宽度的:按文件名排序就是按时间排序,列出历史只需读目录,不必逐个打开文件。
  • 每台设备各保留最近 20 份(按文件名里的设备标签分组),逐出最旧的。两台机器共用一个 备份目录时,谁也不会把对方的历史剪掉。不符合该命名的文件(手工放的、别的工具写的、 云盘的冲突副本)一律不碰。
  • 份数之外还有32 MB 字节预算(同样按设备计算):满配存储(200 条 × 6000 字符)的一份 快照约 3.6 MB,只按份数保留会到约 72 MB;超出预算时从最旧的开始逐出,但最新一份永远 保留(哪怕它自己就超过预算)。
  • 为什么放在 history/ 子目录:memory.json 所在目录被 fs.watch 监听以做外部改动 自动刷新,快照绝不能混进去触发重载;子目录不在非递归监听的范围内,文件名过滤也会兜底。
  • 设置页「版本历史」里可展开列表(时间、rev、条目/形象数、体积、是否来自其他设备), 一键回滚或删除某一份快照。回滚是把快照内容作为新的一版写回(rev 只增不减),并且 它自己也会留下一份快照——所以回滚本身也能再回滚回来;被替换掉的内容仍然躺在 memory.json.bak 里。
  • 快照写失败不会让这次保存失败(存储已经安全落盘),只会在面板上说明"历史快照未写入"。

另外,导入(见下文)是只增合并,无法还原整份记忆;因此另有一个**「从备份恢复…」入口: 选择本插件导出的文件,确认后整体替换**当前记忆。同样是"可反悔"的——替换前的内容已经在 .bak 与版本历史里。

手改与 JSON Schema(F15)

随包发布一份 memory.schema.json(JSON Schema draft-07),描述版本 1/2 两种文件形状与全部 长度上限。宿主还会把它的副本写到 memory.json 旁边,而 memory.json 里写死了:

{ "$schema": "./memory.schema.json", "version": 2, … }

于是把 memory.json 拖进任何支持 JSON Schema 的编辑器(VS Code 等),就能直接得到字段校验、 补全与悬停说明,不需要知道这个包装在哪里。Schema 与代码的一致性由 verify.mjs 逐项断言 (上限、枚举、最大条数),因此它不会和实现悄悄漂移。

CLI 管理命令不在本次承诺范围内。 它依赖宿主是否向插件开放命令能力,目前未经验证; tui / headless 环境下设置页不可用这一点没有改变,手改 JSON + 这份 Schema 是当前 官方支持的路径。

源码模式(F16)

设置页的「源码模式」把整份存储文件(与 memory.json 内容一致)放进一个文本框直接编辑:

  • 输入时实时做 JSON 语法校验(防抖 250ms),并摘要显示"几条指令 / 几个形象 / 几个配置集"; 语法不通过时保存按钮直接禁用。
  • 保存走的是与手改文件完全相同的一条路:解析 → 归一化 → 披露(未能载入的条目、被修正的 选项值、被截断的字段、指向不存在配置集的 setId、未来格式版本拒绝),再按 rev 提交。 因此它比裸编辑器安全,也不会绕过任何既有校验。
  • 草稿不会因为一次拒绝而丢失:冲突(其他窗口/进程在你编辑期间提交过)、版本拒绝、 语法错误都只显示原因,文本框里的内容原样保留;点「重新载入最新内容」才会放弃本地修改。
  • 提交时带的 rev 是打开源码时读到的那个,而不是当前面板上的最新值——这样"你编辑期间 别人改过"会被如实判为冲突,而不是把你的整份文本盖到别人的修改上。
  • 与卡片编辑互斥:正在编辑某条指令/形象时不能进入源码模式(按钮禁用),避免两份草稿互相覆盖。

自定义备份位置(F18,不含网络同步)

默认一切都在存储目录里。设置页可以把备份位置指向别处(例如本地云盘文件夹):

  • 影响三件事:history/ 快照目录、「导出到备份目录」的去向、以及额外一份 memory.json.bak 副本。存储文件本身与它的跨进程锁不会移动——崩溃恢复用的 memory.json.bak 始终留在存储目录,因为那是"下次启动必须能找到"的东西。
  • 设置时会当场校验:必须是绝对路径、目录存在、且实际写入一个探针文件成功;不满足就直接 拒绝并说明原因(例如"目录不存在或不可访问"),而不是等到某次保存悄悄失败。
  • 它是本机设置:从备份文件"整体替换"或回滚历史时不会被改写——否则一台机器上的路径 会被带到另一台机器上,把备份悄悄写到一个不存在的目录里。
  • 多设备建议:把备份位置指向本地云盘目录,只让导出文件与 history/ 快照同步; 不要同步存储目录本身(memory.json)——两台机器同时写入会互相覆盖,插件那套 rev 冲突检测与写锁都只在同一台机器内有效。换机器时用「导出 → 另一台导入/恢复」。

按需指令的本地预演(F17)

「按需」条目到底会不会被采纳,本质上是模型的判断,插件看不到也猜不准。设置页提供一个 本地预演:输入一句当前任务描述,前端纯字面关键词匹配各条按需指令的 「适用场景 / 标题 / 内容」(场景权重最高、正文最低),列出字面重合度较高的条目并给出 高/中/低分档,同时在列表里给这些条目打上「预演·高/中/低」标记。

它的用途只有一个:帮你把「适用场景」写清楚、预判取舍(例如"没写场景的条目只能靠正文 碰运气"、"这条优先级低、一旦超出字数上限会先被丢下")。因此:

  • 措辞是克制的:界面上明确写着**「本地预演 ≠ 模型的真实判断」**,它不参与注入,也不代表模型 实际会不会采纳;verify.mjs 断言了这句免责声明在两种语言里都存在。
  • 只有「按需」条目参与;「始终」条目无条件生效,与预演无关。
  • 匹配是启发式的:中文按 2/3 字滑窗切词(无分词依赖),英文/数字按词切分,术语上限 40 个, 纯前端计算、不发任何请求。

无 Web 服务器时依然生效

systemPrompt 是本插件唯一的硬依赖。Web 服务器不是前置条件:设置页那部分路由通过 非阻塞的 ctx.inject(['webServer'], …) 子 fiber 注册,服务出现时自动挂上;没有 Web 服务器 的 profile(headless、tui)里,系统提示词注入照常工作,只是设置页不可用。

这里曾声明 inject = ['systemPrompt', 'webServer']。声明式依赖是前置条件,于是没有 Web 服务器的 profile 里整行插件永远停在 pending、apply() 根本不执行——用户同时失去设置页 和记忆注入,而记忆本身根本不需要 Web 服务器。非阻塞注册同时把最初那个竞态修得更彻底: 回调在服务发布时就会执行,而不只是在它"抢在插件挂载之前"发布时才生效。

诊断日志

插件此前只有失败才有输出:每次载入、保存、注入成功都不留任何记录,只有失败分支走 console.error。于是"记忆到底有没有在干活"完全无法从日志回答。现在它挂载时写一行 info,过程细节写 debug(走宿主的 ctx.logger):

[instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json onDisk=yes entries=2 sets=2 activeSet=st-default enabled=true section=437 chars (pending assembly) pulls=0

挂载行含数据来源(memory.json / memory.json.bak (main file missing) / memory.json.bak (main file corrupt) / legacy cwd file (migrated) / first run (no store on disk yet))、是否已在磁盘上(onDisk)、 条目数、总开关状态、段落就绪时的字符数,以及 pulls。

为什么是 section=… (pending assembly) 而不是 injected=…

「段落已就绪」≠「已经注入」。 注入发生在宿主装配提示词时;插件在挂载那一刻什么都没注入过, 打印 injected=437 chars 会让人误以为记忆已经进入了某个提示词。这是本插件此前一处会误导排查的措辞。

真正的"到达了提示词"的信号是 pulls:宿主每次对这段做装配,插件注册的那个 thunk 就被求值一次, 计数器加一。因此

  • pulls=0 = 已准备、从未装配(进程刚起来、或会话空闲);
  • pulls=N = 已被拉进提示词 N 次。

插件看不到会话或轮次,不会报告 sessions= 之类的数字——那会是编造。它只报告自己能真正观测到的东西。 pulls 同时出现在路由的 state 快照里(injection.pulls)。每次拉取另有一条 debug 行: section pulled into a prompt (pull #N)。

这是纯日志,绝不影响注入内容:同一份存储在"有 logger"和"没有 logger"下渲染出的文本 逐字节相同(verify.mjs 有断言锁定这一点)。debug 级别由宿主日志级别过滤; 没有 logger 服务的环境下需要 DSH_INSTRUCTION_MEMORY_DEBUG=1 才会输出到标准错误。

诊断与安全状态面板(F12)

设置页的「诊断信息」卡片把散在快照里的排障信息集中展示(默认收起,纯展示、无写路由):

  • 存储:路径、是否已在磁盘、载入来源(loadedFrom:首次创建 / 采用现有文件 / .bak 恢复 / 旧版迁移等)、最近保存时间、修订号(rev);
  • 注入:是否已注册、pulls 拉取计数(自本次插件启动,含义见上文)、当前字符数 / 上限;
  • 备份与历史:备份位置、是否默认、.bak 覆盖副本路径、版本历史目录与快照数、Schema 文件位置;
  • 错误与警告:存储错误 / 存储警告 / 历史错误 / 注入错误原样显示。

「复制诊断信息」把以上内容序列化成纯文本(附 navigator.userAgent、语言与当前时间, 便于提 issue)。它绝不包含条目正文、形象内容等用户文本;剪贴板 API 不可用时降级为 execCommand 复制,再不可用则把文本放进可手动全选的文本框(序列化逻辑是导出的纯函数 __diagnosticsText,verify.mjs 断言其输出含关键字段且不含任何用户文本)。

卡片同时补上了此前缺失的唯一安全信号:security.hostGuarded。设置路由绑定在非回环 地址(主动对外发布)时,页面顶部会出现醒目橙色警告:同一网络内的设备可无鉴权访问此页面、 请勿在不可信网络暴露,并指向下节「经反向代理对外发布」。该判定与路由实际执行的 Host 校验 是同一个开关;快照同时携带 boundHost(路由注册时捕获的绑定地址),警告会点名具体地址。

反馈邮件(可选配置)

设置页最上方(第一块)新增「反馈」按钮:填写问题类型、标题、正文与可选联系方式后,由 Host 进程 直接通过 SMTP 把邮件发到维护者邮箱。客户端只提交表单,不接触任何服务凭证; 未配置时按钮仍可点开,提交会返回一条带配置指引的错误,并保留已填内容以便重试。

隐私边界:邮件自动附带插件版本、Node/DSH 版本、平台与架构、界面语言、时区、 User-Agent 和提交时间;绝不附带任何条目正文、形象内容或整份 memory.json。

回复路径:From 固定为认证的发件账号;若「联系方式」是一个纯邮箱地址,Host 会把它写成 Reply-To,维护者点「回复」即可直接回到反馈者(微信、GitHub 用户名等自由文本不会进入邮件头)。

配置只认环境变量或仓库外的 JSON 文件,不要写进 cordis.patch.yml 或任何提交进仓库的文件。

环境变量(优先)

变量 必填 说明
DSH_INSTRUCTION_MEMORY_FEEDBACK_TO 是 收件邮箱(单个地址,仅 ASCII;非 ASCII 地址会明确报错而不是发出乱码头)
DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_HOST 是 SMTP 服务器主机名
DSH_INSTRUCTION_MEMORY_FEEDBACK_FROM 否 发件地址,默认取 SMTP 用户名;部分服务商要求与登录账号同域
DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PORT 否 1–65535,默认按 _SMTP_SECURE 取 465 / 587
DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_SECURE 否 1/true/yes 走隐式 TLS(465);默认 0 并用 STARTTLS 升级(587)
DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_USER 否 登录用户名;与密码必须成对出现
DSH_INSTRUCTION_MEMORY_FEEDBACK_SMTP_PASS 否 登录密码 / 应用专用密码
DSH_CRB_SMTP_URL 否 复用的 URL 形凭据(格式见下节),优先级低于上面五个 ..._SMTP_* 变量
DSH_INSTRUCTION_MEMORY_FEEDBACK_CONFIG 否 配置文件路径,默认 <存储目录>/feedback.json
DSH_INSTRUCTION_MEMORY_FEEDBACK_COOLDOWN_MS 否 两次发送的最小间隔,默认 30000,0 关闭
DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET 否 解密 toEncrypted 的密钥,至少 16 个字符;只在环境变量里提供,不要写进配置文件或仓库

存储目录默认是 %USERPROFILE%\.dsh\instruction-memory(受 DSH_HOME 影响), 即默认配置文件为 …\instruction-memory\feedback.json。

复用已有的 DSH_CRB_SMTP_URL(可选)

如果本机其他工具已经在 Windows 用户级环境变量 DSH_CRB_SMTP_URL 里保存了可用的 SMTP 凭据,插件可以直接复用,不必再把主机/账号/密码抄一份到本插件的变量或文件里:

smtps://用户:密码@smtp.qq.com:465
smtp://用户:密码@smtp.example.com:587?secure=0&[email protected]
  • smtps:// 走隐式 TLS(通常 465);smtp:// 默认 STARTTLS,可用 secure=1/0 覆盖; smtp:// 且端口为 465 时按隐式 TLS 处理(服务商常见约定)。
  • 用户名、密码中的 @ : / % 等字符必须 URL 编码,例如 1234567890%40qq.com。
  • 逐字段优先级:本插件专用 ..._SMTP_* 变量 > DSH_CRB_SMTP_URL > feedback.json。 因此可以只把密码留在共享 URL 里,其他字段仍用插件变量覆盖。
  • from 可用 ?from= 指定;缺省取 SMTP 用户名(若像邮箱),否则取收件人。
  • 这个变量携带密码:不要写进仓库、不要截图;修改后需要重启 DSH 才会被新进程读到。

配置文件(与环境变量逐字段合并,环境变量优先)

{
  "to": "[email protected]",
  "from": "[email protected]",
  "smtp": {
    "host": "smtp.example.com",
    "port": 465,
    "secure": true,
    "user": "[email protected]",
    "pass": "应用专用密码"
  }
}

文件权限请自行收紧(Windows 可用 icacls feedback.json /inheritance:r /grant:r "%USERNAME%:R")。 凭据缺失时只报"未配置",不会把已配置的密码回显到页面或日志。

收件邮箱加密(可选)

如果不想让收件邮箱以明文留在 feedback.json 里(例如担心截图、误提交或备份泄漏), 可以把 "to" 换成密文字段 "toEncrypted",密钥只放在环境变量里:

{
  "toEncrypted": "im1:……",
  "smtp": { "host": "smtp.qq.com", "port": 465, "secure": true, "user": "…", "pass": "授权码" }
}
  1. 设置密钥环境变量(至少 16 个字符,不能写进仓库): DSH_INSTRUCTION_MEMORY_FEEDBACK_SECRET=……
  2. 用插件自带的小工具生成密文(密钥从环境变量读取,不会打印到终端): node lib/mailer.js encrypt-to [email protected]
  3. 把输出的 im1:… 粘贴为 toEncrypted,删除 "to"(同时保留两者会被拒绝),然后重启 DSH (环境变量只在启动时读取)。

实现是 AES-256-GCM + scrypt 派生密钥,密文带认证标签:改错一个字符或密钥不匹配都会明确报 无法解密反馈收件邮箱,不会静默换收件人。配置文件里同时留下 to 和 toEncrypted 会被直接拒绝 (否则明文一直有效,加密只是摆设);环境变量 DSH_INSTRUCTION_MEMORY_FEEDBACK_TO 仍优先于 文件里的 toEncrypted,用于临时改收件人。

能力边界(请勿误解):SMTP 信封必须把收件人明文交给服务器,TLS 只保护传输链路; 本加密保护的是配置文件静态副本,不防"能以同一 Windows 用户身份读取环境变量和文件"的攻击者, 也不隐藏邮件服务商看到的收件人。需要更强隔离时,应使用独立的 Windows 账户或独立机器运行 DSH。 node lib/mailer.js encrypt-to 生成的密文与密钥绑定,换机/换密钥后需重新生成。

国内邮箱示例:QQ 邮箱填 smtp.qq.com、端口 465、"secure": true,用户名为完整 QQ 邮箱地址, 密码填在 QQ 邮箱「设置 → 账户」开启 SMTP 后生成的授权码(不是登录密码)。 若收件人是该 QQ 邮箱、发件人另用其他账号,from 要与发件账号同域,否则可能被服务端拒收。

发送行为与排查

  • 465 端口:直接 TLS;587 端口:EHLO → STARTTLS → 再次 EHLO →(有账号时)AUTH LOGIN/AUTH PLAIN。
  • 拒绝在明文连接上发送密码:服务器不提供 STARTTLS 时会直接失败,而不是降级。
  • 主题与正文均为 UTF-8(RFC 2047 编码主题、base64 正文),中文不会乱码。
  • 成功后清空草稿并提示"反馈已发送";失败时保留草稿,页面显示原因,宿主日志记一行脱敏错误。
  • 面板为一次发送最多等待 90 秒,宿主自身的整封投递上限是 60 秒:面板总比宿主晚放弃, 因此"超时"提示出现时邮件不会再迟到地发出去。
  • 默认 30 秒冷却:同一进程内连续提交会被拒绝(防误触重复发信),可用上面的变量调整; 冷却在发送开始前就占位,所以两个同时到达的请求也只有一个会被投递。

常见失败:尚未配置反馈收件邮箱 → 环境变量/文件未生效(环境变量改完要重启 DSH; 配置文件每次发送都会重新读取,无需重启);反馈收件邮箱已加密,但未设置 …SECRET 或 无法解密反馈收件邮箱 → 密钥缺失/不匹配,重新执行 encrypt-to 并同步更新环境变量; 配置文件同时包含 to 与 toEncrypted → 删掉明文 to;反馈收件邮箱含非 ASCII 字符 → 换成 ASCII 地址(未实现 SMTPUTF8);配置文件 smtp.pass 类型无效 → 密码写成 JSON 字符串; SMTP 总耗时超过 60000 ms → 服务端长时间无响应,检查主机/端口/网络; SMTP 连接失败 → 主机/端口/防火墙;STARTTLS 失败 → 服务商要求 465 隐式 TLS; 认证失败 → 用户名/密码或应用专用密码,部分邮箱需先开启 SMTP 服务; 发件地址被拒绝 → 显式设置 FROM 为与账号同域的地址。

请求来源防护(Host / Origin)

设置页路由之所以要求 content-type: application/json,是因为 HTML 表单无法伪造 JSON body, 而跨源 fetch 带 JSON 会先在 preflight 失败。这两条防线都建立在"浏览器认为这是跨源"之上, DNS 重绑定恰好推翻了它:evil.com 解析到 127.0.0.1 之后,页面与服务器在浏览器眼里是 同源——不发 preflight、JSON 也合法,于是任何网页都能改写用户那份"注入到此后每一轮 对话"的长期指令。此时唯一无法伪造的就是浏览器发出的 Host 头。

因此当服务器绑定在回环地址(默认)时,路由要求 Host 是回环,另外校验 Origin(若存在) 与 Host 同源。命中即 403,并记一行日志:

[instruction-memory] route rejected a request: Host 头不是回环地址(可能是 DNS 重绑定攻击):evil.com:8080
  • 绑定到任何非回环地址(--host 0.0.0.0、:: 或具体 IP,即主动对外发布)时 Host 校验自动关闭——那种情况下无法预知合法 Host,强行限制只会打断所有正常客户端;Origin 校验仍然生效。
  • 无 Host 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
  • 端口不参与 Origin 比对(反向代理可能改写端口),主机名才是重绑定攻击的对象。

此外路由还读取 Sec-Fetch-Site(Fetch Metadata):现代浏览器给每个请求都打上它, 即使某些技巧(如 no-cors fetch)不带 Origin,evil.com 发来的请求仍会被标记为 cross-site——命中即 403。仅拒绝 cross-site;same-site 必须放行(同站判定不看端口, GUI 与路由本就同主机不同端口),none(用户直接导航)放行;curl、Node 与 2020 年前的 老浏览器根本不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。

经反向代理对外发布(自行承担风险)

默认部署(回环绑定 + 本机浏览器)不需要这一节。若你把 DSH 的 Web 服务器绑定到非回环地址 (--host 0.0.0.0 或具体 IP)并置于 nginx / Caddy 等反向代理之后远程访问,需要注意:

先说清楚代价:该路由没有任何鉴权。一旦发布到网络上,任何能访问它的人都能增删改你 那份"注入到此后每一轮对话"的长期指令。Host 校验在非回环绑定时自动关闭,Origin 与 Sec-Fetch-Site 只能拦"来自其他网页的请求",拦不了直接发 HTTP 的攻击者。更安全的替代 是隧道方案(SSH 端口转发 / Tailscale / WireGuard):宿主仍绑定 127.0.0.1,远程客户端 通过隧道以回环身份访问,所有校验原样生效。

仍要走反向代理时,代理必须满足:

  1. 原样转发 Host(nginx:proxy_set_header Host $host;)。GUI 页面与 API 走同一个 域名时,浏览器发的 Origin 与转发的 Host 主机名一致,Origin 校验通过;改写或剥掉 Host 会得到 403。
  2. 不要注入 Origin:代理自己添加的 Origin 头若与 Host 不同源,会被当作跨源拒绝。
  3. 透传 Sec-Fetch-Site:代理不应剥掉 Fetch Metadata 头;正常同站访问浏览器发的是 same-origin,不受影响。
  4. 透传 content-type 与请求体:路由只接受 application/json 的 POST, 路径前缀为 /instruction-memory/api,代理转发规则需覆盖它。
  5. 限制来源:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不提供任何鉴权。

兼容性

同一串范围写在三处,而只有一处会被宿主强制执行——这一点以前写错过,现在按实测更正:

位置 谁读它 作用
peerDependencies(@deepseek-ai/dsh-system-prompt) 宿主:evaluatePluginCompatibility()(dsh-app-boot/lib/index.js),在安装与 profile 启动时执行 真正的门禁。不满足即拒绝(或要求显式授予精确版本豁免:dsh plugin allow-version)。门禁把 peer 的范围值与宿主版本(dsh --version)比较,peer 的包名不参与解析,只用来点名报错
dsh.engines.dsh 插件市场(第三方,服务端读取) 市场卡片上的兼容性判定与展示
dsh.compatibility.dsh 未找到读取方(官方文档与 DSH 树内均无) 仅作人类可读的重复声明

1.0.14 之前这里只有后两处,没有 peerDependencies——而宿主的门禁在读到缺失的 peerDependencies 时会直接 return undefined(if (!Object.hasOwn(fields, "peerDependencies")) return void 0),也就是跳过检查。换句话说,范围之外的宿主在此之前可以装上而没有任何拦截或警告;那串范围当时只是市场元数据,不是门禁。1.0.14 补上 peer 之后,它才真正生效。

同一串范围(三处逐字节一致,由测试锁定):

>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0

验证范围与证据等级——三者不可混为一谈,下面每一行都标了它到底证明了什么:

等级 证明什么 覆盖
① 真实启动 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入组装后的提示、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) 0.1.3-alpha.2 · 0.1.5-alpha.1 · 0.1.5-alpha.2 · 0.1.5-rc.1 · 0.1.5-rc.2 · 0.1.5-rc.3 · 0.2.0-rc.1 ⁽⁾ · 0.2.0-rc.2 ⁽⁾ · 0.2.1-alpha.2 ⁽⁾ · 0.2.1-alpha.3 ⁽⁾ · 0.2.1-alpha.4 ⁽⁾ · 0.2.1-rc.2 · 0.2.1-rc.3 ⁽⁴⁾
② 契约核验 抓该版本真实发布的源码,逐个核验本插件实际用到的每个符号:systemPrompt.section()、assemble() 是否求值函数型 text、FILE_REFERENCE=900 / TOOL_BASH=1000 仍在(决定 order 950 是否空档)、重名注册是否抛错、webServer.register() / get host()、ctx.inject() / ctx.effect()、dsh.client.inject 两包在该版本是否存在且声明了 dsh.client 声明范围接住的全部 18 个已发布版本(0.1.3-alpha.2 → 0.2.1-alpha.4)
③ 真实执行 把插件真的挂载进该版本的 @deepseek-ai/cordis,跑装载 / 路由注册 / 卸载共 9 项断言 cordis 4.0.2 与 4.0.4(各 9/9)

⁽⁾ 0.2.0-rc.1 的①档有一条要说明:插件装载、注入进入组装后的提示、HTTP 路由可应答三项*均已实测(路由 HTTP 200;injection.registered=true、chars=437、pulls=38——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 text)。第四项"客户端模块被下发"没有直接抓取:首页需要鉴权(/ 返回 401),我无法取到 window.__DSH_BOOT__。这一项的支撑是间接但强的两条:① dsh-client-modules 在合成阶段对 dsh.client 声明做硬校验,不合格会抛 ClientPackageCompositionError 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(-slots / -renderer / -settings)相对升级前真正在跑的 0.1.7-rc.2 逐字节未变。未做人工视觉确认(没有打开设置页看)。

⁽⁾ 0.2.0-rc.2 的①档:桌面版 0.2.0-rc.2(私有发行版 dsh-desktop-runtime,见仓库 dsh-memory-verification/DSH-VERSION-COMPAT.md §12)上完成过路由 200 + registered=true + 注入段落真实出现在会话提示中的活体实测;网页版宿主 0.2.0-rc.2 上对本插件路由的 state 探测同样返回 200。同样未做设置页人工视觉确认**。

⁽⁾ 0.2.1-alpha.2(2026-10-10)与 0.2.1-alpha.3 / 0.2.1-alpha.4(2026-10-16)的①档:隔离安装该版本宿主树 + 独立 DSH_HOME 装入 npm 正式发布物,真实启动 web 宿主后以 21 项 HTTP 断言驱动(CRUD、落盘持久化、SSE 连接帧、section 注册/注销、4 项请求门禁、IPv6 同源等价与混源拒绝、30 连发稳定性、清理回基线)——三个版本各 21/21 PASS。证据:dsh-memory-verification/out/compat-1016-live-live-alpha2.json、dsh-memory-verification/out/compat-133-live-alpha{3,4}.json。同样未做设置页人工视觉确认*。

⁽⁴⁾ 0.2.1-rc.2 / 0.2.1-rc.3 的①档(2026-10-24,插件 1.4.2):rc.3 上隔离 DSH_HOME + 真实 dsh web 宿主驱动 21 项 HTTP 断言 21/21 PASS,桌面端活体 pulls=5052、p50 0.72 ms;并且首次直接抓到了客户端模块的下发证据——boot HTML 的预加载清单含 dsh-instruction-memory/client.js,该合并 bundle 返回 200 text/javascript(2,204,270 字节),正文含 Client 半源码与「指令记忆」文案。因此上一条 ⁽⁾ 里"无法直接抓到 window.__DSH_BOOT__"的限制*已对 rc.3 解除。rc.2 未单独起宿主,靠它与 rc.3 的逐文件字节比对(仅 package.json 与一行构建号不同)承接结论。证据:dsh-memory-verification/out/compat-1016-live-latest-rc3.json、…-rc3b.json。仍未做设置页人工视觉确认。

此外 host-range-test.mjs 的固定矩阵覆盖 0.1.6-alpha.2、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2 与 0.2.1-alpha.1~0.2.1-alpha.4(回归守卫把最新验证过的宿主逐名钉住),并会读取本机实际安装的宿主版本做实时断言。

关于 1.0.10 新增的 ctx.inject。 这是新版唯一的硬依赖增量:inject 去掉了 webServer,设置页路由改为非阻塞的 ctx.inject(['webServer'], …) 子 fiber(原因见上)。为此逐个核验了 cordis 全部 8 个已发布版本(4.0.1-rc.1 → 4.0.5-alpha.2)——每一个都具备 ctx.inject(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 与 4.0.5-alpha.2 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。

关于声明范围是否"超范围承诺"。 已对全部 33 个已发布宿主版本核验(2026-10-05 穷举 30 个;新增版本随发布补充——0.2.1-alpha.2 于 2026-10-10、0.2.1-alpha.3 / 0.2.1-alpha.4 于 2026-10-16,npm 官方 registry):范围内 18 个版本的 API 面全部齐备,超范围承诺 0 项,且默认与 includePrerelease 两种语义在这 18 个版本上零分歧。范围外的 15 个版本(0.0.1-rc.1 → 0.1.2-rc.1)API 面同样齐备,但它们从未真实启动验证过,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。

关于 0.2.1-alpha.1(2026-10-03 发布,1.0.16 起覆盖)。 它是前述"未来 minor 预发布版本在默认语义下接不住"的第一个真实发生案例:1.0.15 的范围在宿主门禁与市场判定(均用 includePrerelease)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 4.0.5-alpha.1。1.0.16 新增 >=0.2.1-alpha.0 <0.3.0-0 枚举段后两种语义一致放行。该版本仅有契约层证据,未做真机启动实测。

0.2.0-rc.1 的核验口径(2026-09-29 增补):基准取 npm 上真实发布的 0.1.7-rc.2(升级前本机真正在跑的版本),逐文件 SHA256 与 0.2.0-rc.1 比对。本插件的 6 个耦合点里 6 个逐字节未变(dsh-system-prompt 4/4、dsh-client-ui-slots 4/4、-renderer 12/12、-settings 11/11、dsh-host-webserver 3/3、dsh-client-modules 10/10);变动的只有 dsh-agent-loop(注入通路 preStep / assemble 逐行等同)与 dsh-app-boot(兼容门禁函数逐字节等同)。方法带阴性对照:同法比对 0.1.6-alpha.2 得到的哈希不同,证明这套比对确实能检出变化,而不是恒真。

一个运维上值得知道的事实。 早期 DSH 用 caret 范围声明同族依赖(如 ^0.1.5-alpha.2),范围会随时间"往前漂":今天安装旧版 DSH,npm 在 includePrerelease 语义下会把同族子包解析到当前最新的 0.1.x,而不是当年那份(实测 7/10 个采样版本会漂移,3 个因精确锁定不会)。这不影响本插件的结论——漂移目标同样在已核验范围内——但"今天装出来的树"与"当年装出来的树"可能不同。

为什么写成分段形式:node-semver 规定"候选版本带预发布标签时,区间里必须存在同一个 major.minor.patch 且自身带预发布标签的比较符"。这条规则是集合级的——一个 || 分支最多 覆盖一个 minor 的预发布版本。由此产生两个必须遵守的约束:

  • 宽写法接不住预发布:>=0.1.3-alpha.2、>=0.1.3-alpha.2 <0.2.0-0 都匹配不到 0.1.4-0 / 0.1.5-rc.1 / 0.1.6-alpha.2——因为 0.1.3 与 0.2.0 的 tuple 都不等于 它们各自的 tuple(已用 node-semver 7.8.5 实测确认)。
  • 每段的上界不能省:省略会被上一段的比较符"续接"。例如单写 >=0.1.5-alpha.1(不封顶), 在把 0.1.3 当 token 时会放行 0.1.3-alpha.0/1。

⚠️ 一个必须知道的上限(注意它是"模式相关"的,别当成普适结论):未声明的未来 minor 的预发布版本,在 npm 的默认语义下无法被任何范围覆盖。 0.1.8-rc.1 这类版本 tuple 与所有比较符都不同,在 npm 默认语义下永远匹配不到——与 ^0.1.0 无法匹配 0.1.8-rc.1 是同一个限制。正式版(0.1.8、0.2.0、1.0.0 等)不受影响。 这个限制不再只是假设:0.2.1-alpha.1(2026-10-03 发布)就是第一个真实撞上它的版本, 1.0.16 已通过新增 0.2.1 枚举段闭合;下一个未声明 tuple(如 0.2.2-rc.1、0.3.0-rc.1) 仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时 直接让测试失败并给出可直接套用的修复行。

但在 includePrerelease: true 下这条上限不成立。 实测同一串范围:0.1.8-rc.1 在默认语义下 false、 在 includePrerelease: true 下 true(已用 DSH 自带的 semver 7.8.5 验证)。而这个区别是有后果的, 因为两条消费路径用的模式不同:

消费方 模式 0.1.8-rc.1 会被放行吗
npm / pnpm 解析 peerDependencies(包管理器) 默认 否
宿主门禁 evaluatePluginCompatibility includePrerelease: true(dsh-app-boot L300) 是
插件市场(第三方,服务端) includePrerelease: true 是

也就是说:对包管理器而言那个上限成立,对宿主门禁而言它其实已经被宽松模式接住了。 所以"未声明的新 minor 预发布版会不会被拒"不能只看一句话,要问清楚是哪种模式。 (host-range-test.mjs 里那条 0.1.8-rc.1 is unreachable 的断言取的是默认模式,其用例名 因此容易被误读成普适结论——这一点已在 1.0.14 的核验里记录。)

这个限制无法用代码消除,但它的后果已经被消掉了。 范围现在是生成出来的: host-range-test.mjs 顶部的 COVERED_LINES 是唯一事实来源,package.json 里那串长字符串必须 逐字节等于由它生成的结果(有专门的漂移断言),所以手改长串导致"枚举漏了一段"这类错误不可能 再发生——DSH 发新 minor 时只需在列表末尾加一行。

同时实时守卫(直接读本机实际安装的宿主版本)在发现未覆盖的宿主时,会打印精确到行的 修复配方,并且在打印前先验证该配方真的能让那个版本被放行:

FAIL  LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes  -> ...
      Fix: append { floor: '0.1.8-alpha.0', below: '0.2.0-0' } to COVERED_LINES
           and set the preceding line's below to '0.1.8-0'
      [verified: that edit admits 0.1.8-rc.1]

这份配方本身也有测试:host-range-test.mjs 会拿 0.1.8-rc.1 走一遍"确认当前范围拒收 → 套用配方 → 确认放行 → 确认下方版本仍被拒收 → 确认所有已覆盖版本仍被放行"。配方失效会先失败在 测试里,而不是失败在用户的安装上。

历史修复说明(同一个 bug 出现过两次,值得记下来):

  • 1.0.7 及以前,范围末段是 >=0.1.5-alpha.1,使宿主 0.1.6-alpha.2 在 npm 默认语义下 被判为不兼容(预发布门禁要求 tuple 相同:0.1.5 ≠ 0.1.6,>= 的大小比较根本走不到)。
  • 1.0.8 改为逐段枚举,但只枚举到 0.1.6。DSH 随后发布 0.1.7-rc.2,同一个假阴性再次出现: 范围里最后一个预发布 tuple 是 0.1.6,而 0.1.7 ≠ 0.1.6。1.0.9 补上 0.1.7 段。
  • 1.0.10 把枚举抽成 COVERED_LINES 单一事实来源 + 漂移/可达/递增断言,并让实时守卫给出 自验证的修复配方——这个 bug 的成因(手改长串)从此被消掉。

两次都因为插件市场传入 includePrerelease: true 而侥幸放行,但任何标准 semver 判定 (npm / pnpm 的依赖解析)都会拒绝。现在 host-range-test.mjs 同时具备固定矩阵与实时守卫。

未验证范围:macOS / Linux、headless 与 tui profile,以及除上表等级①那几个之外的任何版本的真实启动,均未实测。 等级②只证明"该版本里有这些 API",不证明端到端可用。

关于"市场读哪个字段"这条的证据等级。 这里说的是**插件市场(第三方)**的行为,不是 dsh 自身: 市场从已发布的 npm manifest 读 engines.dsh(顶层优先)或 dsh.engines.dsh,与 @deepseek-ai/dsh* 的 peerDependencies(若有)求交,再用 semver + includePrerelease: true 与宿主版本比较,据此在卡片上 显示兼容性;dsh.compatibility.dsh 不是市场读取的那个字段。

需要如实说明它的来源等级:这一条在官方文档里没有,树内也没有可核验的市场客户端 (dshmarket / alldsh 均为第三方,读取发生在服务端)。它的依据是一份独立的第三方实证记录 ——本机安装的 [email protected] 在其 dsh.compatNote 里逐字写明了上述读取路径与"自用的 dsh.compatibility.dshReleases 市场不读"。所以请把它当作第三方实证,不要当成官方保证。 (1.0.14 之前这段把它与官方事实并列陈述,措辞已按证据分级修正。)

范围之外的宿主会发生什么(1.0.14 起变了)。 在此之前,本插件没有 peerDependencies,宿主门禁 直接跳过检查,范围外宿主可以装上且没有警告。1.0.14 起 peer 生效:

  • 宿主(dsh plugin add / profile 启动)会拒绝,除非用户显式授予该精确版本的豁免 (dsh plugin allow-version),这正是 DSH 设计的正常流程;
  • 市场会把"确认不兼容"的条目隐藏并拒绝安装/更新("未声明"或"无法确认"的条目照常显示)。

host-range-test.mjs 会断言 peerDependencies 与另外两处字段逐字节一致,并断言 peer 只声明这一个包名—— 三处任一被手改都会在测试里失败。

发布(维护者)

正规流程是推 tag,由 CI 通过 OIDC(Trusted Publishing)发布,不需要任何长期令牌:

# 1. 自检(prepublishOnly 也会自动跑;占位符未替换会中止发布)
npm test
node check-metadata.mjs
# 2. 升版本并提交
#    package.json 的 version 必须与 tag 完全一致,否则工作流会拒绝发布
git commit -am "1.0.N: ..."
git push origin main
# 3. 打 tag 推送 → 触发 .github/workflows/publish.yml
git tag -a v1.0.N -m "1.0.N: ..."
git push origin v1.0.N

工作流会先跑 npm test 与"tag ↔ version 一致"校验,再 npm publish --access public; 公开仓库 + OIDC 会自动附带 provenance 存证。

npm 侧的一次性配置(发布前必须完成,且 npm 不会校验你填了什么): Package → Settings → Trusted publishing → Add trusted publisher

字段 值
Organization or user HERO476
Repository dsh-instruction-memory
Workflow filename publish.yml(只写文件名,须完全一致)
Environment name (留空)
Allowed actions 勾选 npm publish

⚠️ 2026-09-03 之后创建的 trusted publisher 默认只允许 npm stage publish,直发必须显式勾选。 字段填错不会在保存时报错,只会在第一次发布尝试时以 ENEEDAUTH 之类的错误暴露。

这就是本仓库 1.0.10 / 1.0.11 / 1.0.12 由本地 npm publish 发布的原因:这三版发布时该配置尚未就绪, Publish 工作流的唯一失败步骤正是 npm publish(其余步骤含 npm test 全部通过),因此那三版没有 provenance、也没有对应的 git tag。 不要为这三个版本补推 tag —— 版本已被占用,重跑只会再得到一次必红的 CI。从下一个新版本起走上面的正规流程。

本地兜底发布(仅在 OIDC 不可用时):npm login 后直接 npm publish --access public。 本机若已有 ~/.npmrc 令牌即可直接发布,但产物不含 provenance。

数据格式

版本 2(当前写入的格式;版本 1 的文件同样能载入,见上文「存储」):

{
  "$schema": "./memory.schema.json",
  "version": 2,
  "rev": 3,
  "enabled": true,
  "disabledUntil": 0,
  "budgetChars": 4000,
  "backupDir": "",
  "activeSetId": "st-default",
  "sets": [
    { "id": "st-default", "name": "默认" },
    { "id": "st-work", "name": "工作" }
  ],
  "entries": [
    {
      "id": "im-xxxx",
      "setId": "st-default",
      "title": "回答语言",
      "content": "所有回答使用简体中文。",
      "mode": "always",
      "when": "",
      "priority": 1,
      "enabled": true,
      "tags": ["沟通"],
      "updatedAt": 1700000000000
    }
  ],
  "profiles": [
    {
      "id": "pf-xxxx",
      "setId": "st-default",
      "name": "职业背景",
      "content": "有 8 年后端开发经验,技术问题可跳过入门概念。",
      "enabled": true,
      "updatedAt": 1700000000000
    }
  ]
}

mode 为 always | auto;priority 为 2 高 / 1 普通 / 0 低。rev 是存储修订号, 由插件在每次成功写入时递增,无需手填(缺失视为 0)。tags 为可选的短标签字符串数组 (最多 8 个、单个不超过 24 字符,仅用于设置页筛选,不参与注入);缺失或非数组一律按无标签处理, 非法项会被剔除、超长会被截断、重复项会去重。

sets 为配置集列表(每项只有 id 与 name,单行不超过 30 字符;最多 20 个), activeSetId 指向当前激活的那一个——注入只渲染它。每条 entries / profiles 用 setId 声明归属:指向不存在的集合时归入当前激活集合(并在设置页披露);留空视为"跟随当前 激活集合"。缺失 sets 字段即版本 1 文件,载入时会合成一个名为「默认」的集合。

backupDir 为本机的自定义备份位置(留空 = 与存储文件同目录,最多 512 字符);它不是 可移植内容,从备份恢复时不会被改写。

disabledUntil 为临时静音(F7)的到期时刻(epoch 毫秒):0 或缺失 = 未暂停;未来值 = 暂停中,到期由插件自动恢复(无需操作、不写盘、rev 不变),跨重启保留。手动切换总开关会 清零它(显式开关优先于定时)。这是格式版本 2 的可选字段:旧文件缺失即未暂停,不触发 迁移;手改成非法值(非数字/负数)会被重置为 0 并在设置页披露。

profiles 为可选的用户形象数组(缺失按空数组处理):name 单行不超过 40 字符, content 不超过 4000 字符,最多 50 个;enabled 控制是否使用该形象。名称与内容同时为空的形象会在 载入时剔除并明确披露;名称为空时自动取内容前 20 字符。

上限一览(memory.schema.json 与 verify.mjs 保证它和代码一致): 指令最多 200 条(全部配置集合计)/ 标题 120 字符 / 内容 6000 字符 / 适用场景 200 字符 / 每条最多 8 个标签、单个 24 字符;用户形象最多 50 个 / 名称 40 字符 / 内容 4000 字符; 配置集最多 20 个 / 名称 30 字符;注入字数上限 800–40000 字符。