HERO476/dsh-instruction-memory

dsh-instruction-memory

在 DSH 设置界面维护常驻指令,自动注入此后每一轮对话的系统提示词。

未分類cordisdeepseek-harnessdsh

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 指向它。因此"切换"只是

改一个字段——不会移动、复制或删除任何一条用户文本,也不可能出现"切到一半"的中间态。