npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-instruction-memory

v1.5.3

Published

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. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。

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

从仓库安装(开发)

不让插件市场参与、直接从本仓库检出运行时装用 install.mjs:

node install.mjs                        # 装进"解析出来的那个 profile"
node install.mjs --profile superagent   # 指定 profile
node install.mjs --profile superagent --remove

profile 的解析顺序是 $DSH_PROFILE_DIR → --profile <名字> → $DSH_PROFILE → web。 $DSH_PROFILE_DIR 是宿主给每个子进程导出的"我正在用的 profile 目录",所以在 DSH 的终端里 直接跑会落到当前 profile(这一点与 dsh plugin 不同,后者要求显式 --profile)。目标 profile 里没有 package.json 时脚本拒绝写入,并列出 <DSH_HOME>/profiles/ 下真正存在的 profile 名——早先版本把 profiles/web 写死,装进不存在(或不会被加载)的目录后仍然打印成功。

只由用户本人维护

这份记忆的内容只属于用户。 本插件刻意不注册任何面向模型的工具,也不会主动生成 条目:写入入口只有设置页(以及用户本人直接编辑存储文件),因此不会出现 AI"顺手"给你 记下一条指令的情况。

但这不等于"模型绝对写不进去":存储文件在你自己 UID 下,任何以同一用户身份运行、 具备文件写入能力的进程(包括被你授予 shell 的 agent,以及本 profile 的 danger-full-access)都能直接改它;插件的外部改动监听(fs.watch + 5 秒轮询签名)还会 在下一轮把它当作正常编辑载入。要挡住这条路,请限制 agent 的文件权限,或把存储目录 留在受控位置——本插件的承诺只是"不提供模型工具",不是"阻止同权限进程"。

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

组成

| 文件 | 作用 | | --- | --- | | 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 | 静态门禁,已并入 npm test:node --check 解析所有随包发布与本仓库运行的 JS 文件,并断言 lib/ 里不出现 @deepseek-ai/* 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) | | live-host-smoke.mjs | 双端冒烟(1.4.8 起宿主有鉴权门,需 --token <启动 token> 换会话 cookie,否则全 401):对正在运行的 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 扮演的角色。形象段与指令共用同一个注入字数上限: 形象优先占用,指令只拿剩下的余量(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算还没被 考虑之前就塞进约 5 万字符)。形象自身另有 4000 字符的硬上限,超出部分按顺序截断并说明。 页面上的用量条同时计入形象与指令,所以它显示的比例就是宿主真正用来裁剪的那个数字。 总开关关闭时形象与指令一起停用。

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

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

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

存储

记忆保存在 DSH 数据根目录下(设置页显示折叠后的路径:Windows 为 %USERPROFILE%,macOS/Linux 为 ~;真实绝对路径只用于读写):

<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)会在 启动时按当前目录解析为绝对路径,因此设置页显示的始终是完整路径。

插件创建 instruction-memory/ 与 history/ 时按 0700 建目录(只给当前用户;umask 只会让它更严):这些文件里存的是会被注入每一轮对话的长期指令,同一台机器上的其他用户 没有理由读得到。

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 可用,会从备份恢复并写回 主路径,而不是从零开始。

覆盖 .bak 之前会先确认主文件确实可用:只有解析得出合法存储(或主文件为空/缺失)才覆盖。 早期版本只判断"字节数不为 0",于是一份被截断的 memory.json(非空但解析不了)会把唯一完好的 .bak 覆盖掉——恰好在最需要它的时刻销毁了它。现在"非空但不可解析"的主文件不会动 .bak。

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

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

  • 跨进程写锁:每次写盘前创建 memory.json.lock(独占创建),写完即删。锁存在超过 10 秒视为持锁进程已崩溃、直接接管;等待 2 秒仍拿不到锁不会导致保存失败——宁可继续 写入(last-write-wins)也不让用户的保存被卡死。原子替换保证任何交错下文件本身不会损坏。
  • 修订号(rev)与冲突拒绝:每次成功写入使 data.rev 加一并落盘。客户端的每次修改 请求都带上它看到的 rev(baseRev);若与服务端当前 rev 不一致(另一个窗口/进程刚 提交过),服务端拒绝本次修改并返回最新快照,面板自动刷新并提示重试——防止旧窗口 把别的窗口刚删除的条目"复活"回去。不带 baseRev 的旧客户端保持原有行为。
  • 内存与磁盘的修订号比对:写盘前先读一次磁盘上的 rev。若磁盘已经被另一个进程推到更高的 rev,说明本进程内存里的那份快照已经过期,此时拒绝写入并提示"另一个 DSH 进程刚保存过", 而不是继续写。早期版本只把 rev 递增到 max(自己, 磁盘)+1,看起来修订号单调,实际上是把 过期内存整份盖回磁盘——另一个窗口刚存的东西会静默消失,调用方还收到"已保存"。 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 提交,外部若已抢先修改 会被冲突拒绝拦下。
  • 兜底轮询:fs.watch 在 WSL 的 drvfs(/mnt/c、/mnt/d)、部分网络盘和容器挂载上 可能从不触发,而"从不触发"与"存储没变"在事件层面无法区分——没有事件可以用来触发降级。 因此宿主总是同时跑一个 5 秒轮询(比较 size:mtime:ino 签名),签名变化时走与 watcher 完全相同的重读路径;在原生文件系统上它看到的签名正是 watcher 刚处理过的那个,什么也不做 (内容比较会再挡一层)。定时器 unref(),不会把宿主进程留住。

响应信封中的 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(面板打开时就会读)。

快照里的路径是折叠过的(1.4.4 起):storage.path / backupPath / historyDir / schemaFile / backupDir / migratedFrom,以及 export-to-dir 成功消息里的目标路径(1.4.6 起)都把家目录折叠成平台可用的占位符——1.4.5 起 Windows 为 %USERPROFILE%(资源管理器、CMD、PowerShell 都会展开;1.4.4 曾用 ~,但资源管理器不展开它),macOS/Linux 为 ~——只用于显示;读写、备份、监听仍用绝对路径。 折叠在 Windows 上大小写不敏感(c:\users\me 与 C:\Users\me 是同一个目录,早期实现按字符串前缀比较,换个大小写就把原始家目录漏了出去)。 install.mjs 先读 package.json 再动文件系统(1.4.6 起):装/卸都如此。早期先把 junction 建好(或删掉)再解析 manifest,一个损坏的 package.json 就能留下"依赖还在、junction 没了"(或反过来)的半状态,而脚本当时只抛一个 JSON 解析错误。 injection.available 也不再恒为 true——提示词区块注册失败时它是 false。 设置页依赖宿主的 slots 服务:@deepseek-ai/cordis 没有"可选注入",所以这是硬依赖, 宿主不提供时插件不装载设置页(只打印一行说明)。

在这之前: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 始终留在存储目录,因为那是"下次启动必须能找到"的东西。
  • 设置时会当场校验:必须是绝对路径、目录存在、且实际写入一个探针文件成功;不满足就直接 拒绝并说明原因(例如"目录不存在或不可访问"),而不是等到某次保存悄悄失败。
  • 默认只允许 $DSH_HOME 之内的路径:在没有 connection 服务的 profile 上,这条路由 等于"仅回环可访问",而备份位置此前接受任意绝对路径(设置时探针写入、之后每次保存都写 一份副本)。那是一个"把磁盘写满"的原语,所以现在非 $DSH_HOME 的路径一律拒绝,并说明要 把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开: DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1(重启宿主生效)。导出目录受同一条限制; 手改存储文件把 backupDir 指到外面也同样被拒——这条路不能变成创建任意目录的通道。 这条规则在写入时也执行,不只在校验时:设置页、导出、以及每次保存附带的 .bak 与历史 快照都走同一个判断(1.5.1 起)。检测到越界的外来 backupDir 会被忽略并在面板上披露, 备份与快照写回默认位置,而不是在外目录里悄悄留下文件。
  • 导出有 2 秒节流:两次「导出到备份目录」之间要隔 2 秒,否则提示"还需等几秒"。导出写的是 整份存储(满配约 1.2 M 字符),连点不该变成写放大。
  • 报错文本会折叠路径:自定义备份目录 → <备份目录>(云盘路径常带账号名)、记忆目录 → <记忆目录>、家目录 → ~ / %USERPROFILE%,与设置页其他位置显示的一致。
  • 它是本机设置:从备份文件"整体替换"或回滚历史时不会被改写——否则一台机器上的路径 会被带到另一台机器上,把备份悄悄写到一个不存在的目录里。
  • 多设备建议:把备份位置指向本地云盘目录,只让导出文件与 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
  • 绑定到非回环地址(只能是具体 IP——当前宿主在加载期就拒绝 0.0.0.0 / :: 这类通配绑定, 所以"绑通配对外发布"在本宿主上起不来)时,本插件的 Host 校验自动关闭,此时有没有 connection 服务决定一切:
    • 有:每个请求先过宿主的鉴权门(Host / Origin / Sec-Fetch-Site + 登录 cookie), 本插件不再重复判 Origin。插件只知道自己绑定的地址,不知道你对外用的域名;再判一次 只会把合法的反代客户端全部 403(1.4.8 早期版本就是这样,属可用性缺陷,非漏洞)。 要让域名访问通过,去宿主那边把域名写进 dsh-client-connection 的 trustedHosts。
    • 没有:非回环绑定对任何请求都是 503(见下面「宿主的鉴权门」一节),别只按这一行判断 能不能对外发布。
  • 那套"Origin 必须等于绑定地址"的规则(1.4.6 引入)现在只用于没有 connection 服务的 profile:那里它是唯一防线,Host 受攻击者控制,把 Origin 与它相比等于自己跟自己比, 所以改为与绑定地址比(::1 / ::ffff:127.0.0.1 这类同一地址的不同写法仍等价)。回环绑定 不受影响:那里 Host 已被限制在回环名单内,客户端用 127.0.0.1、localhost、[::1] 或 [::ffff:127.0.0.1] 访问都能继续工作。
  • 无 Host 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
  • 端口参与 Origin 比对(早期版本忽略端口,于是 127.0.0.1:1 这种同主机不同端口的页面也算同源):比对前先把默认端口归一(http:80 / https:443 视同未写端口),其余情况要求 scheme、主机名、端口三者一致。Host 末尾的 FQDN 点(localhost.)会被忽略,避免同一台机器的两种写法被当成跨源。

此外路由还读取 Sec-Fetch-Site(Fetch Metadata):现代浏览器给每个请求都打上它, 即使某些技巧(如 no-cors fetch)不带 Origin,evil.com 发来的请求仍会被标记为 cross-site——命中即 403。在回环绑定(受保护)时 cross-site 与 same-site 都拒绝: "同站"判定不看端口,而回环上的任何其他端口都是别人跑的本地服务,放行 same-site 等于让任意 本地页面改写你的长期指令。none(用户直接导航)放行;curl、Node 与 2020 年前的老浏览器根本 不发这个头,也不受影响(它们由 Host 与 content-type 校验兜底)。

注意这一整套 Host / Origin / Sec-Fetch-Site 判定只在没有 connection 服务时执行。 有门时它由宿主做(宿主接受回环、trustedHosts 与监听地址,并校验 Origin 与 Host 同源、 Sec-Fetch-Site 不是 cross-site),本插件不再重复判——重复判会把反代域名全部 403; 没有门时非回环绑定直接 503,根本走不到这里。

宿主的鉴权门(connection):非回环绑定没有它就直接拒绝

路由挂载后会向宿主申请 connection 服务(DSH 的 Web / Go 端都提供,负责把浏览器会话换成 "这个人登录了"):

  • 有这个服务:每个请求先过 connection.requestRejection(req)。宿主返回 4xx 就按同样的 状态码回绝(401 未登录 / 403 未被信任),请求不进入任何逻辑;鉴权组件自己抛错按 401 处理——坏掉的守卫不能变成敞开的门。通过之后不再重复本插件自己的 Host / Origin 判定: 宿主那道门已经用它的 trustedHosts 与绑定地址判过同一件事,而插件只知道绑定地址, 再判一次会把反代域名访问全部 403(1.4.8 修复)。Sec-Fetch-Site、content-type、请求体上限 这些只属于本路由的规则照旧生效。
  • 没有这个服务:回环绑定照旧工作(默认部署,靠本地那三道防线);非回环绑定一律 503: 该路由在非回环绑定下需要宿主的 connection 服务提供鉴权;当前 profile 没有该服务,已拒绝请求。 早先没有这一条,把服务器绑到局域网地址(--host <具体 IP>)的机器等于把"改写此后每一轮 注入内容"的写入口敞开给整个局域网。设置页在这种状态下会直接显示这条 503 的原因,而不是装作存储是空的。

可信主机名(DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS)

⚠️ 这一项只放宽主机名校验,不提供任何身份认证。在没有宿主 connection 服务的 profile 上,如果一个反代把请求转成"合法主机名",这条路由就是无登录可读写的。面板在该组合 (security.hostGuarded=false、security.authFence=false、trustedHostCount>0)下会显示 专门的橙色警告;对外发布请改用隧道,或在代理层自己做认证。

回环绑定下 Host 必须是回环名。若你的访问方式让浏览器发出的 Host 不是 127.0.0.1 / localhost / [::1]——hosts 里把 dsh.lan 指向 127.0.0.1、Tailscale 的 MagicDNS 名、 或隧道把域名透传进来——可以在启动宿主的环境里显式放行(仅在没有 connection 服务的 profile 上生效;有那道门时,对外的域名要写进宿主的 dsh-client-connection → trustedHosts,那是宿主自己的配置):

DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS="dsh.lan,app.example:8443,https://notes.lan"

逗号分隔;写完整 origin、带端口、带 IPv6 方括号都行,解析时统一折成小写、去端口、去方括号的 主机名,与 Host / Origin 解析出来的形式一致。它只放宽回环绑定下的这两处校验 (Host 白名单、Origin 的主机名),不改变"非回环绑定必须带鉴权门"这条;Origin 的端口 比对仍然生效。它也不放宽 Sec-Fetch-Site:回环绑定下 same-site 依旧拒绝("同站"不看端口, 回环上的任何其他端口都是别人的服务),所以经反代 + 子域访问时若浏览器发出 same-site, 即使主机名已可信也仍会拿到 403——这种情况请改用隧道(全程回环同源),或让代理保持 页面的同源形状。

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

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

先说清楚代价:宿主没有发布 connection 服务时,该路由就没有任何鉴权,而且非回环 绑定会被插件直接 503——所以下面的步骤要能用,前提是宿主发布了这个服务。发布之后,鉴权 完全是宿主的责任:插件强制走 connection.requestRejection(),不再自己判 Host / Origin / Sec-Fetch-Site(它只知道绑定地址,不知道你的对外域名)。宿主那道门拦的是"未登录的浏览器 会话";带着有效会话 Cookie 的请求,以及你宿主配置里信任的来源,都能直接读写这份记忆。 更安全的替代是隧道方案(SSH 端口转发 / Tailscale / WireGuard):宿主仍绑定 127.0.0.1,远程客户端通过隧道以回环身份访问,默认部署的那套校验原样生效。

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

  1. 原样转发 Host(nginx:proxy_set_header Host $host;)。宿主鉴权门把 Host 与 "监听地址 + 它自己的 trustedHosts"比对,所以对外域名要么等于绑定地址,要么写进 dsh-client-connection 的 trustedHosts;改写或剥掉 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——插件本身不带任何鉴权,它只会 转用宿主发布的 connection 服务(没有该服务时非回环绑定直接 503)。

在 DSH Go(Android)上:鉴权来自平台补丁,不是本插件

DSH Go 给官方 Web 服务器包了一层构建期补丁 runtime/patches/route-guard.diff(钉在 0.2.1-rc.4,pins.json 记了 before/after SHA-256,锚点对不上时构建直接失败):它在 route.handler 之前对每一条命名路由调用 connection.requestRejection()——exact 与 prefix 都算,本插件的 /instruction-memory/api 属后者;放行名单只有 /connect 与 /oauth/callback;缺 cookie → 401,connection 尚未挂载 → 503(fail-closed),拒绝响应 带 x-dsh-go-guard: reject。因此 Android 上"同机其他 App 直接打本插件路由"这条路已被平台挡住; 上面几节描述的"无鉴权"缺口只适用于桌面端 / 网页端的端口。

⚠️ 这层保护是平台属性,不是插件属性:在 guest shell 里手动 dsh web(不带 --patch), 或在 guest 内把内核升级到补丁锚点对不上的版本,鉴权就回退成"仅回环、无鉴权"。判断装机是否 仍受保护,只看启动命令行里有没有 --patch /etc/dsh-app/dsh-web.patch.yml。

兼容性

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

| 位置 | 谁读它 | 作用 | | --- | --- | --- | | 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.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.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 ⁽⁴⁾ · 0.2.1-rc.4 ⁽⁵⁾ · 0.2.2-alpha.2 ⁽⁵⁾ | | ② 契约核验 | 抓该版本真实发布的源码,逐个核验本插件实际用到的每个符号: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 | 截至 2026-10-16 审计时,声明范围接住的 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。仍未做设置页人工视觉确认。

⁽⁵⁾ 0.2.1-rc.4 / 0.2.2-alpha.2 的①档(2026-10-29,插件 1.4.4 → 1.4.5):隔离 DSH_HOME + 真实 dsh web 宿主,两个版本各跑 21 项 HTTP 断言;两端均 21/21 PASS(1.4.4 首次跑出的 2 项 FAIL 是验证脚本未展开折叠路径造成的假阴性,1.4.5 修正脚本后复验通过)。客户端 bundle 实际下发:rc.4 2,211,701 字节、alpha.2 2,370,024 字节,正文均含 Client 半源码与「指令记忆」文案。首次做了设置页人工视觉确认——两个宿主上「指令记忆」栏目均完整渲染。桌面端 0.2.1-rc.3 活体 pulls=653、p50 0.95 ms、problems=[]。仍未跑真实 Agent 回合(隔离宿主无模型凭证,pulls=0)。

此外 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、0.2.1-rc.2~0.2.1-rc.4 与 0.2.2-alpha.2(回归守卫把最新验证过的宿主逐名钉住),并会读取本机实际安装的宿主版本做实时断言。

关于 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 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。

关于声明范围是否"超范围承诺"。 截至 2026-10-16 已对当时全部 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.3-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 只声明这一个包名—— 三处任一被手改都会在测试里失败。

在 DSH Go(Android)上运行

DSH Go(Android 客户端)内置的引擎就是真实 DSH,锁定 @deepseek-ai/dsh 0.2.1-rc.4 (runtime/locks/dsh-0.2.1-rc.4/package.json),在 PRoot rootfs 内以 dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open 运行; 环境经 /usr/bin/env -i 清空后只注入 HOME=/root、DSH_HOME=/root/.dsh——App 侧设置的 HOME 对引擎无效,别按字面读 DshProcess.kt。

  • 兼容性:0.2.1-rc.4 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的 DSH_HOME 上复跑一遍 21/21(compat-1016-live-check.mjs;1.5.1 起该脚本第四个参数给启动 token 时会先 GET /?token= 换会话 cookie,不再在带鉴权门的宿主上假失败),注入实测 chars=292 / budget=4000、state p95 1.09 ms、SSE 首帧 0.71 ms;证据 dsh-memory-verification/out/compat-1016-live-rc4-audit.json。
  • 数据位置:/root/.dsh/instruction-memory/memory.json,在 App 私有目录的 rootfs 内, 与桌面端不是同一份;快照里的路径以 ~ 显示,不泄露绝对路径。卸载 App 会连 rootfs 一起清空, 卸载插件保留数据。
  • 配置入口:原生设置页依赖 schema 表单,本插件没有 config schema,所以那一行点进去是 网页岛(WebView 里加载的官方 Web UI),settings.section 的落点在网页客户端里。 若从未打开过任何网页岛,这一行可能尚未出现。
  • 市场版本可能滞后:DSH Go 的发现页只读 api.dshfind.com/market/dsh-index.json,该文件对本 插件在 2026-10-31 仍只登记 1.4.2(npm latest 已是 1.4.6)。要装最新版,在 guest shell 里执行 dsh plugin --profile web add dsh-instruction-memory,直接取 npm 上的版本。
  • 双端记忆不共享:手机与"遥控 PC"各自维护自己的 memory.json,没有同步——这是当前形态, 不是缺陷。

发布(维护者)

正规流程是推 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 字符。