dsh-instruction-memory 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.4] — 2026-11-12
11
+
12
+ 2026-11-12 的三方评审(架构 / 安全 / 验收,桌面端 0.2.2-rc.1 + 网页端同进程)落地的一轮修复。
13
+ 没有新增功能,存储格式、注入文本与面板行为在正常路径上不变;改的是**一处承诺与实现相反**、
14
+ **一处静默丢事件**、**一处验收脚本假绿**,以及三处缺失的回归护栏。
15
+
16
+ ### 修复
17
+
18
+ - **面板与 JSON Schema 仍在说「形象独立于指令字数上限、完整注入、不做裁剪」**(P1,已复现):
19
+ 这是 1.2.0 修「形象绕过注入预算」时漏改的文案——`lib/index.js` 从那时起就把形象段夹在
20
+ `MAX_PROFILE_INJECT_CHARS`(4000)与共享 budget 之内,而 `lib/client.js`(中/英两套字典)和
21
+ `memory.schema.json`(两处 description,会随 store 复制到 `~/.dsh/instruction-memory/`)还写着
22
+ 相反的话。实测 2 张 3900 字形象 + 4000 预算:只注入 4000 字符、第二张整段丢弃,面板却逐字宣称
23
+ "完整注入"。现已改为「与指令共用同一注入字数上限;超出时按序截断,段末会说明」。
24
+ - **形象被截断/被丢弃时,面板上没有任何标记**(P2):宿主早在 `injection.profiles[]` 里发布了
25
+ `truncated`,其注释写着"必须披露",而 Client 对该字段零引用——条目有「已截断」红标、形象卡却
26
+ 什么都不显示,用户只能从「查看实际注入内容」里自己发现。现在形象行会渲染「已截断」/「本轮未包含」,
27
+ 数据全部来自既有快照字段,旧宿主缺该字段时不渲染标记。
28
+ - **`?lite=1` 的帧只带 `rev`,两条不写盘的变更推不出去**(P1):`rev` 是磁盘修订号,"暂停注入到期"
29
+ (状态变化、按规定不落盘)与"手改 `memory.json` 但保留 rev"都改变宿主看到的内容却不改 rev,
30
+ 于是推出去的帧与上一帧逐字节相同,面板静默停在旧状态(仍显示静音、用量条仍是 0)。帧里现在带
31
+ 单调递增的进程内计数器 `seq`,rev 未变而 seq 前进时面板同样回拉一次;`seq` 不落盘,旧客户端
32
+ 忽略它、旧宿主不发它,两个方向都兼容。
33
+ - **`live-host-smoke.mjs` 同时给 `--web` 与 `--desktop` 会一个宿主都不探测,却打印
34
+ `ALL CHECKS PASSED`**(P1,已复现):两个 flag 各自"排除对方",同时给就互相抵消,`PORTS` 为空,
35
+ 但汇报阶段只看 problems 是否为空。现在冲突直接报错退出;`PORTS` 为空时打印 `NO HOST PROBED`
36
+ 并退出 2,绝不再打印成功。另加 `--base <origin>`(单宿主同时服务两端的真实拓扑,parity 显式
37
+ 跳过)、`--both`;401/403 不再被说成"无法连接"。
38
+
39
+ ### 测试
40
+
41
+ - `contract-test.mjs` 新增 5 条断言:lite 帧带 `seq`;**rev 不变而内容变化时必须产生可判定的帧**;
42
+ `res.write()` 恒返回 `false` 且不抛错的"慢但健康"订阅者**不得**被当成死连接(1.5.3 的回归护栏);
43
+ 宿主 watcher 报 `error` 后 5 秒轮询仍能发现外部改动(1.5.2 的回归护栏,经 `apply(ctx, { watch })`
44
+ 注入假 watcher)。
45
+ - `verify.mjs`:`host: no longer depends on the harness fs service` 这条断言名与断言体不符(只检查函数
46
+ 存在),改为真正断言"存储解析在 `<DSH_HOME>/instruction-memory` 下"。
47
+ - 上述两条护栏都做过变异验证:把对应修复回滚后,新断言必须转红。
48
+
49
+ ### 文档
50
+
51
+ - README「外部改动自动刷新(SSE)」补 `seq` 与"不写盘的变更";「用户形象」补面板截断标记;
52
+ `live-host-smoke.mjs` 一行补 `--base` / `--both` 与单宿主拓扑的说明。
53
+
10
54
  ## [1.5.3] — 2026-11-09
11
55
 
12
56
  1.5.2 的发布后修正,两处都是当天发现:一处**判断依据用错**,一处**依赖了初始化顺序**。没有新功能,
package/README.md CHANGED
@@ -66,7 +66,7 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
66
66
  | `smoke-test.mjs` | 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线) |
67
67
  | `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
68
68
  | `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
69
- | `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`(否则回环请求也会被代理转发,读数虚高) |
69
+ | `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 回合」判为失败。单宿主拓扑(桌面应用自己服务 `11685`)用 `--base http://127.0.0.1:11685`,parity 会**显式跳过**而不是假装比过;`--web` / `--desktop` 各选一端,同时给直接报错、零探测时打印 `NO HOST PROBED` 而不是 `ALL CHECKS PASSED`。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
70
70
  | `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
71
71
  | `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
72
72
 
@@ -95,15 +95,9 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
95
95
  「加标签」在浏览器里用原生输入框;**桌面版(Electron)不提供 `window.prompt`**(调用即抛错),
96
96
  此时自动改用面板内的行内输入框(回车提交、Esc 取消),功能不受影响。
97
97
  - **用户形象**:除了"要求 AI 怎么做"的指令,你还可以记录**关于你本人**的信息(身份背景、喜好、性格、
98
- 长期追求等),每张形象卡片可单独勾选「使用该形象」——可以一个都不用、只用一个、或同时勾选多个
99
- (多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在
100
- 指令块**之前**,抬头明确把它限定为"仅供调整回答侧重":AI 应在**保证回答正确、完整、客观且扣题**的
101
- 前提下据此调整解释深浅、详略、举例与建议方向,**不得为了迎合而改变事实、省略风险提示或偏离问题本身**,
102
- 无关时直接忽略。形象不是让 AI 扮演的角色。形象段与指令**共用同一个注入字数上限**:
103
- 形象优先占用,指令只拿剩下的余量(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算还没被
104
- 考虑之前就塞进约 5 万字符)。形象自身另有 4000 字符的硬上限,超出部分按顺序截断并说明。
105
- 页面上的用量条**同时计入**形象与指令,所以它显示的比例就是宿主真正用来裁剪的那个数字。
106
- 总开关关闭时形象与指令一起停用。
98
+ 长期追求等),每张形象卡片单独勾选「使用该形象」。形象段注入在指令块**之前**,与指令共用同一个注入
99
+ 字数上限(形象优先占用、指令拿余量),并且它**不是**让 AI 扮演的角色。字段、注入原文、预算细节与
100
+ 和配置集的区分见[用户形象(关于你本人的画像)](#用户形象关于你本人的画像)。
107
101
 
108
102
  总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
109
103
  总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
@@ -119,6 +113,101 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
119
113
  (`injection.muted`)里。到期判定与注入渲染都是**可注入时钟**的纯函数(`isMuted`、
120
114
  `planInjection(data, now)`),相同输入永远得到相同字节。
121
115
 
116
+ ## 用户形象(关于你本人的画像)
117
+
118
+ 指令回答的是"要 AI 怎么做",**用户形象**回答的是"你是谁":身份与职业背景、专业与经历、喜好与禁忌、
119
+ 性格与沟通偏好、长期目标等。它在设置页的「指令记忆 → 用户形象」分区维护——卡片式,可新增、编辑、
120
+ 删除,每张卡片右侧一个「使用该形象」开关;一张都没有时,该分区只显示空状态提示,注入行为与未安装
121
+ 本插件完全一致。
122
+
123
+ ### 字段与上限
124
+
125
+ | 项 | 上限 | 说明 |
126
+ | --- | --- | --- |
127
+ | 形象名称 | 40 字符 | 单行,作列表标题;留空时自动取内容前 20 字,仍能在列表里分辨 |
128
+ | 形象内容 | 4000 字符 | 多行正文,**原样**注入,插件不改写、不摘要、不生成 |
129
+ | 形象数量 | 50 张 | 全部配置集**合计**,与 200 条指令、20 个配置集共享同一份存储 |
130
+
131
+ - 名称与内容**同时为空**的形象在保存时被拒绝(「形象名称与内容不能同时为空」);从存储文件里读到的
132
+ 这种空形象会被丢弃,并在面板上披露「存储文件中有 N 个用户形象未能载入」。
133
+ - 超长字段在保存时会**截断并点名**是名称还是内容(名称 40 / 内容 4000 字符)。编辑器的输入框本身也
134
+ 按这两个上限限制长度(`maxLength`),所以正常操作走不到截断分支——它主要防手改文件。
135
+ - 形象同样**只能由你本人写入**:本插件不注册任何面向模型的工具,形象内容原样注入、不做语义判断
136
+ (见[只由用户本人维护](#只由用户本人维护))。
137
+
138
+ ### 勾选与多张并存
139
+
140
+ - 可以一个都不用(等价于没有形象段)、只用一个、或同时勾选多个。
141
+ - **多张不是"让 AI 扮演多个角色"**:它们是同一个人不同侧面的材料,注入时合成在一段里,每张一个
142
+ `### <名称>` 小节,按存储顺序排列。
143
+ - 未勾选的形象、以及内容为空的形象**不进入注入文本**,但仍留在列表里(未勾选的卡片标灰),随时可
144
+ 再启用;勾选状态即时写盘,跨重启保留。
145
+ - 名称留空时用内容前 20 字作小节标题,所以"没起名"不影响注入形态。
146
+
147
+ ### 注入形态
148
+
149
+ 启用后,形象段以**固定抬头**注入在指令块**之前**(整段只有这一个抬头,无论启用了几张):
150
+
151
+ ```text
152
+ ## 关于用户(用户形象)
153
+ 以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。
154
+
155
+ ### 职业背景
156
+ (这一张的形象内容,原样保留)
157
+
158
+ ### 沟通偏好
159
+ (另一张的形象内容)
160
+ ```
161
+
162
+ 抬头里的三件事是刻意的,缺一件就会跑偏:**定性**——这是"关于用户的事实",不是人格设定,所以 AI 不该
163
+ "扮演";**定向**——只允许调整解释深浅、详略、举例与建议侧重;**护栏**——不得为迎合而改变事实、省略
164
+ 风险提示或偏离问题核心,与当前问题无关时直接忽略。换句话说,形象只影响**怎么讲**,不影响**讲什么
165
+ 才是对的**。
166
+
167
+ ### 预算:与指令共用同一个上限
168
+
169
+ 这是最容易误解的一点:面板上的「注入字数上限」(默认 4000 字符)约束的是**整段注入文本**,不是
170
+ "指令那部分"。
171
+
172
+ - **分配顺序**:先给形象,指令拿**剩下的余量**;形象还会为"其余指令被略过"的脚注预留空间。因此
173
+ `形象 + 指令 + 脚注 ≤ 上限` 恒成立。(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算
174
+ 还没被考虑之前就塞进约 5 万字符。)
175
+ - **形象段自身另有 4000 字符硬上限**:即使把注入上限调到 40000,形象段最多也只占前 4000,其余才轮到
176
+ 指令。
177
+ - **放不下时按顺序取舍**:某张形象装不进剩余空间,就保留截断的开头并追加
178
+ 「…(此形象因注入字数上限被截断)」,它之后的形象整张略过,段末追加「(另有 N 个形象因注入字数
179
+ 上限未包含,可在「设置 → 指令记忆」中精简其内容。)」。
180
+ - **形象把预算吃光时,指令不会被静默丢掉**:段末仍会输出「(另有 N 条指令因注入字数上限未包含…)」,
181
+ 避免出现"用户的指令悄悄消失、只剩形象"这种最难发现的状态。
182
+ - **面板会把这件事标出来**:被截断的形象带「已截断」,整张没进本轮的形象带「本轮未包含」(悬停看原因)。
183
+ 两个标记直接读宿主的注入计划(`injection.profiles[]`),与条目上的「本轮不注入 / 已截断」是同一套
184
+ 数据——否则面板显示的是全文,而模型只收到截断的头。
185
+ - 面板上的用量条**同时计入**形象与指令(`entryChars + profileChars`),它显示的比例就是宿主真正
186
+ 用来裁剪的那个数字。
187
+
188
+ ### 与配置集不是一回事
189
+
190
+ 配置集(`sets`)是"记忆的分区",用户形象是"关于你本人的材料"——两者容易混,区别在于:形象通过
191
+ `setId` **归属**某个配置集,切换配置集时形象与指令一起换;删除配置集会连同其中的形象一起删(确认框
192
+ 写明条数与形象数);「复制」配置集会以全新 id 把形象复制过去。存储字段上,形象叫 `profiles`
193
+ (1.1.0 引入时先占用了这个名字),配置集叫 `sets`,互不相干。详见下文「多情境配置集(配置集 / sets)」。
194
+
195
+ ### 停用、暂停与空存储
196
+
197
+ - **总开关关闭**或**暂停注入期间**(F7):形象与指令一起停用,系统提示词与未安装本插件时**逐字节
198
+ 一致**。
199
+ - **只有形象、没有任何生效指令**时仍然注入——纯画像存储也生效,不会因为"没有指令"就整段消失。
200
+ - **没有任何启用形象**时,形象段完全**不出现**,不会留下一个空标题。
201
+ - 存储里 `profiles` 字段缺失按空数组处理,旧文件照常载入;文件里指向不存在配置集的形象会被归入
202
+ 当前配置集并披露(与指令同一套容错规则)。
203
+
204
+ ### 手改文件
205
+
206
+ 形象在 `memory.json` 的 `profiles` 数组里,每项形如
207
+ `{ "id": "…", "name": "职业背景", "content": "…", "enabled": true, "setId": "…", "updatedAt": 1730000000000 }`;
208
+ `enabled` 就是那张卡片的「使用该形象」开关(缺失按 `true` 处理)。手改时用 `memory.schema.json` 校验,
209
+ 条目定义见[数据格式](#数据格式)。
210
+
122
211
  ## 存储
123
212
 
124
213
  记忆保存在 **DSH 数据根目录**下(设置页显示折叠后的路径:Windows 为 `%USERPROFILE%`,macOS/Linux 为 `~`;真实绝对路径只用于读写):
@@ -193,10 +282,16 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
193
282
  被外部进程或手工改写(宿主以 `fs.watch` 监听**存储目录**——原子替换在 Windows 上会
194
283
  使文件级监听失效——去抖 250ms 后经与写盘相同的队列重读,内容确有变化才重载并推送;
195
284
  自己的写入盘后磁盘与内存一致,天然被同一比较抑制,不会回声)。
196
- - **推送的是变更,不是全量**:设置页以 `?lite=1` 订阅,宿主只推 `{ rev, lite: true }`
197
- (约 40 字节),面板发现 rev 比自己渲染的更新时才回拉一次 `state`。满配存储的一份快照
285
+ - **推送的是变更,不是全量**:设置页以 `?lite=1` 订阅,宿主只推 `{ rev, lite, seq }`
286
+ (约 50 字节),面板发现 rev 或 seq 比自己渲染的更新时才回拉一次 `state`。满配存储的一份快照
198
287
  约 1.24 M 字符(中文约 3.4 MB),逐次全量推送会把这些字节乘以打开的窗口数。不带该参数
199
288
  的旧客户端仍收到完整快照,两代客户端与两代宿主可任意组合。
289
+ - **不写盘的变更也要能通知**:`rev` 是**磁盘**修订号,只有真正写入才前进;而「暂停注入到期」
290
+ (状态变化,按规定不落盘)和「手改 `memory.json` 但保留 rev」都会改变宿主看到的内容却不改
291
+ rev。早期帧只带 `rev`,这两条路径推出去的帧与上一帧逐字节相同,面板于是静默停在旧状态——
292
+ 暂停到期后仍显示静音、用量条仍是 0、清单还是旧内容。现在帧里另有单调递增的进程内计数器
293
+ `seq`:rev 未变而 seq 前进时面板同样回拉。`seq` 不落盘;旧客户端只比 rev(忽略它),
294
+ 旧宿主不发它(客户端退回只比 rev),两个方向都保持兼容。
200
295
  - **回声抑制**:发起修改的窗口也会收到自己那次写入的推送,但帧里的 rev 与它已吸收的响应
201
296
  快照相同,客户端按 rev 比对直接丢弃(旧客户端的完整帧则按内容逐字节比对)——否则刚出现
202
297
  的「已保存」提示会被空消息的推送帧瞬间冲掉。
@@ -692,6 +787,12 @@ nginx / Caddy 等反向代理之后远程访问,需要注意:
692
787
  5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不带任何鉴权,它只会
693
788
  转用宿主发布的 `connection` 服务(没有该服务时非回环绑定直接 503)。
694
789
 
790
+ > **桌面端渲染器 origin 的一个已知边界**(2026-11-12 实测):宿主对 `Origin: dsh-app://app`
791
+ > 的请求返回 **403**,本插件在没有 `connection` 服务时的自判(`requestOriginVerdict`)同样拒绝
792
+ > 该 origin(它按 Origin ↔ Host 同源比较)。当前桌面窗口发的是 `http://127.0.0.1:<port>`,
793
+ > 所以不受影响;但如果将来的渲染器改用 `dsh-app://` 作为 document origin,设置页会整片 403,
794
+ > 届时需要在两侧各放行该 scheme——这不是配置问题,是代码问题。
795
+
695
796
  ### 在 DSH Go(Android)上:鉴权来自平台补丁,不是本插件
696
797
 
697
798
  DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/route-guard.diff`(钉在
@@ -728,7 +829,7 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
728
829
 
729
830
  | 等级 | 证明什么 | 覆盖 |
730
831
  | --- | --- | --- |
731
- | ① **真实启动** | 在该宿主版本上真的把 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`** ⁽⁵⁾ |
832
+ | ① **真实启动** | 在该宿主版本上真的把 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`** ⁽⁵⁾ · **`0.2.2-rc.1`** ⁽⁶⁾ |
732
833
  | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`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`);后续新增版本按发布补充 |
733
834
  | ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
734
835
 
@@ -742,6 +843,8 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
742
843
 
743
844
  ⁽⁵⁾ **`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`)。
744
845
 
846
+ ⁽⁶⁾ **`0.2.2-rc.1` 的①档**(2026-11-12,插件 1.5.3 → 1.5.4,桌面版 `dsh-desktop-runtime 0.2.2-rc.1` / Electron 44.7.0 / cordis 4.1.0 / Node 24.21.0):1) **注入真的进入了组装后的提示**——该会话的系统提示里含本插件的「用户长期指令记忆」块,条目与磁盘 store 逐条一致(不是"注册了但没被求值");2) 路由在 `127.0.0.1:11685` 上应答,且宿主鉴权门在该版本上真实生效:匿名 GET / POST / SSE 全部 **401**,伪造 `Host` 与 `Sec-Fetch-Site: cross-site` 全部 **403**(这三条同时反证宿主 `connection.requestRejection()` 返回数字状态码,与插件判定一致);3) 设置页在真实窗口里渲染出磁盘数据(「共 0 个」与 `profiles: []` 一致)。**未做**的两项:桌面窗口 DevTools 里抓 `Origin` 实际值(宿主对 `Origin: dsh-app://app` 实测返回 403,见「请求来源防护」——若将来渲染器真改用该 scheme,这一行需要重验);客户端 bundle 下发字节数未重抓(⁽⁴⁾⁽⁵⁾ 的方法可复用)。
847
+
745
848
  此外 `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`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
746
849
 
747
850
  **关于 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 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
package/lib/client.js CHANGED
@@ -357,7 +357,11 @@ window.__ModuleLoader__.load({
357
357
  'profiles.confirmDelete': '确定删除用户形象「{name}」?删除后无法恢复。',
358
358
  'profiles.empty': '还没有用户形象。点击「+ 新增形象」添加关于你本人的信息;不勾选任何形象时,AI 完全按原本方式回答。',
359
359
  'profiles.count': '共 {total} 个,使用中 {active} 个',
360
- 'profiles.chars': '已启用形象合计 {count} 字符(独立于指令字数上限,完整注入、不做裁剪)',
360
+ 'profiles.chars': '已启用形象合计 {count} 字符,与指令共用同一注入字数上限(超出时按序截断,段末会说明)',
361
+ 'profiles.truncated': '已截断',
362
+ 'profiles.truncatedTitle': '这张形象超过注入字数上限,只有开头进入本轮提示',
363
+ 'profiles.omitted': '本轮未包含',
364
+ 'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
361
365
  'profiles.saving': '保存中…',
362
366
  'action.new': '+ 新增指令',
363
367
  'action.reload': '重新载入',
@@ -645,7 +649,11 @@ window.__ModuleLoader__.load({
645
649
  'profiles.confirmDelete': 'Delete the persona “{name}”? This cannot be undone.',
646
650
  'profiles.empty': 'No persona yet. Click “+ New persona” to add facts about yourself; with no persona ticked, the AI answers exactly as it normally would.',
647
651
  'profiles.count': '{total} in total, {active} in use',
648
- 'profiles.chars': 'Active personas total {count} characters (separate from the instruction budget; injected in full, never trimmed)',
652
+ 'profiles.chars': 'Active personas total {count} characters, counted against the same injection limit as instructions (anything over is truncated in order and disclosed at the end)',
653
+ 'profiles.truncated': 'Truncated',
654
+ 'profiles.truncatedTitle': 'This persona exceeds the injection limit; only its head reached this turn',
655
+ 'profiles.omitted': 'Not in this turn',
656
+ 'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
649
657
  'profiles.saving': 'Saving…',
650
658
  'action.new': '+ New instruction',
651
659
  'action.reload': 'Reload',
@@ -2761,6 +2769,15 @@ window.__ModuleLoader__.load({
2761
2769
  let alive = true
2762
2770
  let refreshing = false
2763
2771
  let missed = false
2772
+ /**
2773
+ * Monotonic frame counter carried by lite frames. `rev` only moves when
2774
+ * the store is WRITTEN, so a snapshot that changed without a write (the
2775
+ * F7 pause expiring, an external hand-edit that keeps its rev) used to
2776
+ * produce a frame identical to the last one and the panel stayed stale
2777
+ * for the rest of the session. `null` means the Host predates the field
2778
+ * and the rev comparison alone drives the refetch.
2779
+ */
2780
+ let lastSeq = null
2764
2781
  const refresh = () => {
2765
2782
  // One refetch at a time. A frame that arrives mid-flight means the
2766
2783
  // store moved again, and is re-checked after this response lands —
@@ -2784,6 +2801,9 @@ window.__ModuleLoader__.load({
2784
2801
  const pushed = JSON.parse(ev.data)
2785
2802
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2786
2803
  const current = viewRef.current
2804
+ const seq = typeof pushed.seq === 'number' ? pushed.seq : null
2805
+ const moved = seq !== null && lastSeq !== null && seq !== lastSeq
2806
+ if (seq !== null) lastSeq = seq
2787
2807
  // A null view used to be ignored here on the assumption that the
2788
2808
  // initial POST `state` is always in flight. When that request
2789
2809
  // failed, nothing ever refetched and the panel stayed on its
@@ -2791,6 +2811,7 @@ window.__ModuleLoader__.load({
2791
2811
  // the Host is alive, so it is a safe trigger to retry.
2792
2812
  if (current === null) refresh()
2793
2813
  else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
2814
+ else if (moved) refresh()
2794
2815
  return
2795
2816
  }
2796
2817
  if (sameSnapshot(pushed, viewRef.current)) return
@@ -2937,6 +2958,15 @@ window.__ModuleLoader__.load({
2937
2958
  const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
2938
2959
  const omittedById = new Map()
2939
2960
  const truncatedById = new Map()
2961
+ /**
2962
+ * Persona budget marks, from the same plan. `injection.profiles` lists
2963
+ * every persona that actually reached the prompt, with `truncated` when
2964
+ * the ceiling cut it; an enabled persona MISSING from that list was
2965
+ * dropped whole. Both were published by the Host and never read here, so
2966
+ * the panel kept showing full text the model never received.
2967
+ */
2968
+ const renderedProfileById = new Map()
2969
+ const truncatedProfileById = new Map()
2940
2970
  {
2941
2971
  const planItems = injection && typeof injection === 'object' ? injection : {}
2942
2972
  for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
@@ -2947,6 +2977,12 @@ window.__ModuleLoader__.load({
2947
2977
  truncatedById.set(item.id, item)
2948
2978
  }
2949
2979
  }
2980
+ for (const item of Array.isArray(planItems.profiles) ? planItems.profiles : []) {
2981
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') {
2982
+ renderedProfileById.set(item.id, item)
2983
+ if (item.truncated === true) truncatedProfileById.set(item.id, item)
2984
+ }
2985
+ }
2950
2986
  }
2951
2987
  const keyword = query.trim().toLowerCase()
2952
2988
  const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
@@ -3279,13 +3315,28 @@ window.__ModuleLoader__.load({
3279
3315
  : null,
3280
3316
  profiles.length === 0 && profileDraft === null
3281
3317
  ? h('div', { className: 'im-empty' }, t('profiles.empty'))
3282
- : profiles.map((profile) => h('div', {
3318
+ : profiles.map((profile) => {
3319
+ // Only an ENABLED persona with content can be in the plan, so a
3320
+ // disabled card never wears a stale mark. A Host that publishes no
3321
+ // `injection.profiles` leaves both maps empty and no mark renders.
3322
+ const personaMark = profile.enabled !== true || profile.content === ''
3323
+ ? null
3324
+ : (truncatedProfileById.has(profile.id)
3325
+ ? 'truncated'
3326
+ : (renderedProfileById.has(profile.id) ? null : 'omitted'))
3327
+ return h('div', {
3283
3328
  className: 'im-profile-item' + (profile.enabled === true ? '' : ' im-profile-item-off'),
3284
3329
  key: profile.id,
3285
3330
  },
3286
3331
  h('div', { className: 'im-item-head' },
3287
3332
  h('span', { className: 'im-profile-name' },
3288
3333
  profile.name !== '' ? profile.name : t('profiles.untitled')),
3334
+ personaMark === null
3335
+ ? null
3336
+ : h('span', {
3337
+ className: 'im-tag ' + (personaMark === 'omitted' ? 'im-tag-off' : 'im-tag-cut'),
3338
+ title: personaMark === 'omitted' ? t('profiles.omittedTitle') : t('profiles.truncatedTitle'),
3339
+ }, personaMark === 'omitted' ? t('profiles.omitted') : t('profiles.truncated')),
3289
3340
  h('span', { className: 'im-spacer' }),
3290
3341
  h('label', { className: 'im-switch' },
3291
3342
  h('input', {
@@ -3308,7 +3359,8 @@ window.__ModuleLoader__.load({
3308
3359
  h(EntryBody, { text: profile.content, key: 'body' }),
3309
3360
  profile.updatedAt > 0
3310
3361
  ? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(profile.updatedAt).toLocaleString() }))
3311
- : null)),
3362
+ : null)
3363
+ }),
3312
3364
  activeProfiles > 0
3313
3365
  ? h('div', { className: 'im-meta im-meter-line' }, t('profiles.chars', { count: profileChars }))
3314
3366
  : null),
package/lib/index.js CHANGED
@@ -1774,7 +1774,14 @@ export function buildBlock(data, now = Date.now()) {
1774
1774
  * Plugin
1775
1775
  * ------------------------------------------------------------------ */
1776
1776
 
1777
- export function apply(ctx) {
1777
+ export function apply(ctx, deps = {}) {
1778
+ /**
1779
+ * Test seam for the OS watcher. Production passes nothing and this resolves
1780
+ * to `node:fs`'s `watch`; the regression that matters — the watcher dying
1781
+ * and the 5s poller having to take over — otherwise needs a real directory
1782
+ * to be deleted mid-test, which no contract test can do reproducibly.
1783
+ */
1784
+ const watchStore = typeof deps.watch === 'function' ? deps.watch : watch
1778
1785
  const systemPrompt = ctx.systemPrompt
1779
1786
 
1780
1787
  const state = {
@@ -2209,6 +2216,18 @@ export function apply(ctx) {
2209
2216
  * pushSnapshot).
2210
2217
  */
2211
2218
  const sseStreams = new Map()
2219
+ /**
2220
+ * Monotonic counter of pushed frames. It exists because `rev` is a DISK
2221
+ * revision: it only moves when the store is written. Two legitimate pushes
2222
+ * change what the prompt sees without writing anything — the F7 pause
2223
+ * expiring (see the mute timer) and an external reload that keeps the file's
2224
+ * rev — and a lite subscriber compares rev to decide whether to refetch. Both
2225
+ * therefore produced a frame identical to the one already on screen, so the
2226
+ * panel kept showing the muted/old state while the host had already moved on.
2227
+ * `seq` is process-local and never persisted; a full-frame subscriber never
2228
+ * reads it.
2229
+ */
2230
+ let stateSeq = 0
2212
2231
  /**
2213
2232
  * Shared heartbeat over every live event stream, started with the first
2214
2233
  * subscriber and stopped with the last. Two jobs:
@@ -2264,12 +2283,14 @@ export function apply(ctx) {
2264
2283
 
2265
2284
  const pushSnapshot = () => {
2266
2285
  if (disposed || sseStreams.size === 0) return
2286
+ // Every broadcast bumps the counter, whether or not the disk rev moved.
2287
+ stateSeq += 1
2267
2288
  // A lite stream only needs to know that the store moved: it fetches the
2268
2289
  // snapshot through POST `state` when the rev it sees differs from the one
2269
2290
  // on screen. The full frame is built LAZILY, so a page whose subscribers
2270
2291
  // are all lite (the current client) never pays for serialising the whole
2271
2292
  // store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
2272
- const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
2293
+ const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
2273
2294
  let fullPayload = null
2274
2295
  for (const [send, entry] of Array.from(sseStreams)) {
2275
2296
  try {
@@ -2401,7 +2422,7 @@ export function apply(ctx) {
2401
2422
  const startWatching = () => {
2402
2423
  if (disposed || storeWatcher !== null) return
2403
2424
  try {
2404
- storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
2425
+ storeWatcher = watchStore(dirname(storePath), { persistent: false }, (event, filename) => {
2405
2426
  // Only events naming memory.json matter; the .tmp/.bak/.lock churn of
2406
2427
  // our own writes is filtered out here (the content compare in
2407
2428
  // onStoreFileEvent catches what slips through).
@@ -5313,7 +5334,7 @@ export function apply(ctx) {
5313
5334
  * `?lite=1` is additive on purpose: a client that does not ask (every build
5314
5335
  * before this one) keeps receiving the bare snapshot exactly as documented,
5315
5336
  * so an old client against a new Host is unchanged. A lite subscriber gets
5316
- * `{ rev, lite: true }` and fetches the snapshot itself when the rev differs
5337
+ * `{ rev, lite, seq }` and fetches the snapshot itself when either moved
5317
5338
  * — which is the difference between pushing ~3.4 MB per save (maximal store)
5318
5339
  * and pushing ~40 bytes.
5319
5340
  *
@@ -5342,7 +5363,7 @@ export function apply(ctx) {
5342
5363
  *
5343
5364
  * Frames carry the bare snapshot — the same shape `state` answers with —
5344
5365
  * which the Client's normalizer already accepts. A subscriber that appends
5345
- * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
5366
+ * `?lite=1` gets `{ rev, lite, seq }` frames instead (see pushSnapshot).
5346
5367
  */
5347
5368
  const handleEventStream = (req, res) => {
5348
5369
  // Same fence as the POST path: an EventSource carries the login cookie, so
@@ -5399,7 +5420,7 @@ export function apply(ctx) {
5399
5420
  if (closed) return
5400
5421
  try {
5401
5422
  send(lite
5402
- ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
5423
+ ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
5403
5424
  : 'data: ' + JSON.stringify(snapshot()) + '\n\n')
5404
5425
  } catch {
5405
5426
  cleanup()
@@ -37,7 +37,7 @@
37
37
  "type": "integer",
38
38
  "minimum": 800,
39
39
  "maximum": 40000,
40
- "description": "指令注入字数上限(字符,非 token)。超出部分按优先级取舍,未注入的条目会在设置页标记。用户形象不占用此上限。",
40
+ "description": "指令注入字数上限(字符,非 token)。超出部分按优先级取舍,未注入的条目会在设置页标记。用户形象同样计入此上限:形象先占用、指令拿余量。",
41
41
  "default": 4000
42
42
  },
43
43
  "backupDir": {
@@ -135,7 +135,7 @@
135
135
  "profiles": {
136
136
  "type": "array",
137
137
  "maxItems": 50,
138
- "description": "用户形象:关于**用户本人**的身份背景、喜好、性格与追求。它们不参与指令字数上限,始终完整注入,并带有一段「仅供调整表达侧重、不得为迎合而改变事实」的护栏说明;形象不是让模型扮演的角色。",
138
+ "description": "用户形象:关于**用户本人**的身份背景、喜好、性格与追求。它们与指令共用同一个注入字数上限(形象先占用、指令拿余量),形象段自身另有 4000 字符上限,超出时按序截断并在段末说明;带有一段「仅供调整表达侧重、不得为迎合而改变事实」的护栏说明;形象不是让模型扮演的角色。",
139
139
  "items": {
140
140
  "type": "object",
141
141
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "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. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",