dsh-instruction-memory 1.0.15 → 1.0.17

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,89 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.0.17] — 2026-10-12
11
+
12
+ ### 新增
13
+
14
+ - **注入预算可视化(F1)**:设置页在「注入字数上限」下方显示用量条与「实际注入 x / y 字符
15
+ (约 z token,估算)」(token 按 0.6 系数估算,与既有说明同一口径,仅作参考);被上限略过的
16
+ **生效中**条目在列表中逐条标注橙色「本轮不注入」(悬停说明原因),极端情况下首条被迫截断时
17
+ 标注红色「已截断」,用量条同时变为警示色并给出条数提示。调高上限或调整优先级保存后标记即时变化。
18
+ - Host 新增导出纯函数 `planInjection(data)`:在**不改变注入文本一个字节**的前提下,返回注入文本、
19
+ 生效上限(clamp 后的 budget)、候选条目数,以及每条 `included`(`{id, chars, truncated}`)
20
+ / `omitted`(`{id, chars}`)的取舍明细与考虑顺序(先「始终」后「按需」、同档优先级/更新时间降序);
21
+ `buildBlock()` 改为它的薄封装。
22
+ - 快照 `injection` 新增 `budget` / `activeCount` / `included` / `omitted` 字段(SSE 帧随之自动携带)。
23
+ 存储格式仍为 v1、**无新增落盘字段**;旧客户端收到新字段自然忽略,新客户端对没有这些字段的旧宿主
24
+ 快照回退到 `data.budgetChars`、不显示条目标记,两侧均不报错。
25
+
26
+ ### 测试
27
+
28
+ - `smoke-test.mjs` 新增 20+ 条 `planInjection` 断言:空/总开关关闭/非法输入、budget 越界夹取、
29
+ 与 `buildBlock()` 逐字节一致、停用与空条目不参与、「始终」先于「按需」、紧预算下 included/omitted
30
+ 恰好分区全部候选、单条超长首条截断标记、计划本身确定性。
31
+ - `contract-test.mjs` 新增独立验证块:经真实路由在 800 字符上限下断言快照预算、`chars === preview.length`
32
+ 且不超上限、取舍明细恰好覆盖全部生效条目且不含停用条目、预览脚注数量与 omitted 一致、提高上限后
33
+ 全部纳入;并把真实/旧版/垃圾三类快照喂给客户端 reducer 验证接缝。
34
+ - `verify.mjs` 新增客户端 `__budgetMeter` reducer 断言(计划字段优先、旧快照回退、非法项忽略、
35
+ 垃圾输入零值不抛错、percent 封顶 100、0.6 token 估算)及 6 个新 i18n 键的双语存在性;
36
+ 键集与占位符一致性仍由既有用例锁定。
37
+ - `docs/demo.html` 的演示宿主改为实时计算注入计划(与 `planInjection` 同一算法),演示数据
38
+ 默认 800 字符上限以直接展示用量条与「本轮不注入」标记;旧的手工静态预览文本移除。
39
+
40
+ ## [1.0.16] — 2026-10-05
41
+
42
+ 一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
43
+ lint、host-range、smoke、contract、verify)。
44
+
45
+ ### 安全
46
+
47
+ - **修复 IPv6 回环被错误 403 的缺陷**:经 `http://[::1]:端口` 打开设置页时,保存 /
48
+ 删除 / 导入 / SSE 实时刷新此前全部不可用。两个成因:① `hostnameOf('::1')`
49
+ 把裸 IPv6 字面量按最后一个冒号截断成 `:`,永远匹配不上回环名单(名单里的
50
+ `::1` 是死条目);② WHATWG URL 解析器给出的 `Origin` 主机名**保留方括号**
51
+ (`[::1]`),而 `Host` 解析去掉方括号(`::1`),同源比较永不相等。现新增
52
+ IP 规范化比较(8 段零填充展开),同时覆盖 IPv4-mapped 地址的两种拼写
53
+ (`::ffff:127.0.0.1` 与 URL 规范化后的 `::ffff:7f00:1` 视为同一地址);
54
+ DNS 重绑定等真正的跨站请求在 IPv6 上同样被拒。默认 `--host 127.0.0.1` 不受
55
+ 此问题影响。
56
+ - 绑定侧的回环判定同步走规范化比较:宿主绑定到 `::ffff:127.0.0.1` 这类等价
57
+ 地址时,Host 防护不再被错误关闭。
58
+
59
+ ### 缺陷修复
60
+
61
+ - **跨进程写锁补上持有者令牌(TOCTOU)**:锁文件 `memory.json.lock` 现在写入
62
+ 本次写入的随机令牌,释放时仅当文件仍属自己才删除。此前一个超时 2 秒、"无锁
63
+ 照写"的慢持有者,其 `finally` 可能把新持有者刚接管的锁删掉,静默放跑第三个
64
+ 写者。过期死锁(超 10 秒)的接管策略不变。
65
+ - **SSE 首帧等待 boot 完成**:在宿主首次磁盘读取尚未结束时打开的设置页,不再
66
+ 先收到一帧空存储、再收到真实快照(一次"没有任何指令"的闪烁)。
67
+ - **SSE 写出异常的连接现在完整清理**:广播时 `res.write` 抛错(对端已消失)会
68
+ 注销并结束该响应,而不是只从广播集合删除、留下半注册的流;其他健康流不受
69
+ 影响。
70
+ - **编辑注入字数上限时不再被实时推送打断**:输入框聚焦期间,其他窗口/进程的
71
+ SSE 快照不再覆盖正在输入的数字;失焦后恢复与已提交值同步。
72
+ - **删除不存在的 id 不再产生空写盘**:不再 bump `rev`、不写文件、不推送回声帧
73
+ (删除一个已被另一窗口删掉的条目时不再制造无意义的写入)。
74
+
75
+ ### 样式
76
+
77
+ - 修复错误状态红点的 CSS 变量名笔误:`--dsh-alias-state-error-primary` →
78
+ `--dsw-alias-state-error-primary`(此前该点不跟随宿主主题,恒取兜底色);
79
+ verify 套件新增全文件变量前缀扫描,杜绝同类笔误再发布。
80
+
81
+ ### 兼容性
82
+
83
+ - **声明范围扩展到 `0.2.1-alpha.1`(2026-10-03 发布)**:新增
84
+ `>=0.2.1-alpha.0 <0.3.0-0` 枚举段。该版本此前在**宿主门禁**(includePrerelease)
85
+ 与市场判定下可通过、但在 npm/pnpm 的默认 peer 解析下会报 ERESOLVE;现两种
86
+ 模式一致放行。三处声明(peerDependencies / dsh.engines / dsh.compatibility)
87
+ 仍由 host-range 测试钉死逐字节一致。对 npm 上全部 30 个已发布版本穷举:
88
+ 范围内 15 个、两模式零分歧;契约矩阵对最近 10 个版本 12/12 通过。
89
+ **0.2.1-alpha.1 仅有源码契约层证据,未做真机启动实测。**
90
+ - README 兼容性统计按当前 registry 更新(28/13 → 30/15),cordis 已发布版本
91
+ 数 6 → 7(新增 `4.0.5-alpha.1`,已核验含 `ctx.inject`/`ctx.effect`)。
92
+
10
93
  ## [1.0.15] — 2026-10-03
11
94
 
12
95
  本轮为一次系统性缺陷清查后的批量修复,每一项都有对应的回归测试(`npm test` 五套件全绿:
package/README.md CHANGED
@@ -54,6 +54,11 @@ dsh plugin --profile web remove dsh-instruction-memory
54
54
  上限单位是**字符**而非 token——按 DeepSeek 官方分词比例约 1 个中文字符 ≈ 0.6 token、
55
55
  1 个英文字符 ≈ 0.3 token(不同模型分词有差异,实际用量以模型返回为准),上限 40000
56
56
  字符约合 24000 token。
57
+ - **预算用量可视化**:设置页在上限输入框下方显示用量条——「实际注入 x / y 字符(约 z token,
58
+ 估算)」(token 数按 0.6 系数估算,仅作参考);被上限略过的**生效中**条目会在列表里逐条
59
+ 标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。
60
+ 调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的**展示**,不改变注入文本本身;
61
+ 存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
57
62
 
58
63
  条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
59
64
 
@@ -259,24 +264,28 @@ GUI 与路由本就同主机不同端口),`none`(用户直接导航)放
259
264
  同一串范围(三处逐字节一致,由测试锁定):
260
265
 
261
266
  ```
262
- >=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.0-0
267
+ >=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
263
268
  ```
264
269
 
265
270
  **验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
266
271
 
267
272
  | 等级 | 证明什么 | 覆盖 |
268
273
  | --- | --- | --- |
269
- | ① **真实启动** | 在该宿主版本上真的把 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.2.0-rc.1`** ⁽*⁾ |
270
- | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`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` | 声明范围接住的**全部 13 个已发布版本**(`0.1.3-alpha.2` → `0.2.0-rc.1`) |
274
+ | ① **真实启动** | 在该宿主版本上真的把 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.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ |
275
+ | ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`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` | 声明范围接住的**全部 15 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.1`) |
271
276
  | ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
272
277
 
273
278
  ⁽*⁾ **`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` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
274
279
 
275
- 此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2` 与 `0.2.0-rc.1`,并会**读取本机实际安装的宿主版本**做实时断言。
280
+ ⁽**⁾ **`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。同样**未做设置页人工视觉确认**。
276
281
 
277
- **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 6 个已发布版本**(`4.0.1-rc.1` → `4.0.4`)——**每一个都具备 `ctx.inject`**,因此声明范围内不存在因它而失效的宿主版本。
282
+ 此外 `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`,并会**读取本机实际安装的宿主版本**做实时断言。
278
283
 
279
- **关于声明范围是否"超范围承诺"。** 已对全部 **28 个**已发布宿主版本穷举核验:范围内 **13 个**版本的 API 面**全部齐备,超范围承诺 0 项**。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
284
+ **关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 7 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.1`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
285
+
286
+ **关于声明范围是否"超范围承诺"。** 已对全部 **30 个**已发布宿主版本穷举核验(2026-10-05,npm 官方 registry):范围内 **15 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 15 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
287
+
288
+ **关于 `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` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
280
289
 
281
290
  **`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` 得到的哈希**不同**,证明这套比对确实能检出变化,而不是恒真。
282
291
 
@@ -295,6 +304,10 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
295
304
  ⚠️ **一个必须知道的上限(注意它是"模式相关"的,别当成普适结论):未声明的未来 minor 的预发布版本,在 npm 的默认语义下无法被任何范围覆盖。**
296
305
  `0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 **npm 默认语义**下**永远匹配不到**——与
297
306
  `^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
307
+ 这个限制不再只是假设:**`0.2.1-alpha.1`(2026-10-03 发布)就是第一个真实撞上它的版本**,
308
+ 1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.2-rc.1`、`0.3.0-rc.1`)
309
+ 仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时
310
+ 直接让测试失败并给出可直接套用的修复行。
298
311
 
299
312
  **但在 `includePrerelease: true` 下这条上限不成立。** 实测同一串范围:`0.1.8-rc.1` 在默认语义下 `false`、
300
313
  在 `includePrerelease: true` 下 **`true`**(已用 DSH 自带的 semver 7.8.5 验证)。而这个区别是**有后果**的,
package/lib/client.js CHANGED
@@ -58,6 +58,12 @@ window.__ModuleLoader__.load({
58
58
  .im-tag-always { color:var(--dsw-alias-brand-primary,#3b6cff); border-color:currentColor; }
59
59
  .im-tag-auto { color:var(--dsw-alias-state-success-primary,#18a058); border-color:currentColor; }
60
60
  .im-tag-high { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
61
+ .im-tag-off { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
62
+ .im-tag-cut { color:var(--dsw-alias-state-error-primary,#d94838); border-color:currentColor; }
63
+ .im-meter { height:6px; border-radius:999px; overflow:hidden;
64
+ background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
65
+ .im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
66
+ .im-meter-fill-warn { background:var(--dsw-alias-state-warn-primary,#d97706); }
61
67
  .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
62
68
  border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
63
69
  .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
@@ -75,10 +81,11 @@ window.__ModuleLoader__.load({
75
81
  .im-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-label-secondary,#8a9099); }
76
82
  .im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
77
83
  .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
78
- .im-dot-err { background:var(--dsh-alias-state-error-primary,#d94838); }
84
+ .im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
79
85
  .im-body-open { max-height:none; }
80
86
  .im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
81
87
  .im-search-meta { margin-top:5px; }
88
+ .im-meter-line { margin-top:5px; }
82
89
  .im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
83
90
  border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.14)); background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); }
84
91
  .im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
@@ -140,6 +147,12 @@ window.__ModuleLoader__.load({
140
147
  'budget.unitHint': '单位是字符,不是 token',
141
148
  'budget.rangeError': '上限需在 {min} ~ {max} 之间',
142
149
  'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
150
+ 'budget.used': '实际注入 {used} / {budget} 字符(约 {tokens} token,估算)',
151
+ 'budget.omitted': '有 {count} 条生效指令因超出上限本轮未注入,可调高上限或调整优先级',
152
+ 'tag.omitted': '本轮不注入',
153
+ 'tag.truncated': '已截断',
154
+ 'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
155
+ 'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
143
156
  'rules.label': '生效规则',
144
157
  'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
145
158
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
@@ -217,6 +230,12 @@ window.__ModuleLoader__.load({
217
230
  'budget.unitHint': 'The unit is characters, not tokens',
218
231
  'budget.rangeError': 'The limit must be between {min} and {max}',
219
232
  'budget.note': 'Characters, not tokens: with DeepSeek tokenization roughly 1 Chinese character ≈ 0.6 token (English ≈ 0.3), the {max}-character ceiling is about {tokens} tokens; actual usage depends on the model.',
233
+ 'budget.used': '{used} of {budget} characters actually injected (≈{tokens} tokens, estimate)',
234
+ 'budget.omitted': '{count} active entries are not injected this turn because the limit is exceeded; raise the limit or adjust priorities',
235
+ 'tag.omitted': 'Not injected',
236
+ 'tag.truncated': 'Truncated',
237
+ 'tag.omittedTitle': 'This entry currently stays out of the system prompt because the injection size limit is exceeded',
238
+ 'tag.truncatedTitle': 'This entry is too long; only its beginning reaches the system prompt',
220
239
  'rules.label': 'How instructions apply',
221
240
  'rules.note': '“Always” applies unconditionally; “On demand” lets the model judge by relevance. When the limit is exceeded, higher-priority entries are kept and the rest are skipped.',
222
241
  'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
@@ -485,6 +504,53 @@ window.__ModuleLoader__.load({
485
504
  }
486
505
  }
487
506
 
507
+ /**
508
+ * What the budget input should show after a snapshot arrives.
509
+ *
510
+ * A push from another window used to reset budgetText unconditionally
511
+ * inside absorb(), so editing the injection-size field while any other
512
+ * panel saved wiped the half-typed number. While the input is focused the
513
+ * user's draft wins; the committed value re-syncs on blur.
514
+ */
515
+ function nextBudgetText(currentText, incoming, focused) {
516
+ return focused ? currentText : String(incoming)
517
+ }
518
+
519
+ /**
520
+ * Reduce a snapshot's injection plan to the values the header meter and
521
+ * the entry badges render. Pinned as a pure export so the fallback math is
522
+ * testable without React.
523
+ *
524
+ * Tolerates an older Host whose snapshot predates the plan fields: the
525
+ * ceiling then falls back to data.budgetChars and there are simply no
526
+ * per-entry marks (included/omitted). Garbage input degrades to zeros
527
+ * instead of throwing mid-render.
528
+ */
529
+ function budgetMeter(snapshot) {
530
+ const inj = snapshot !== null && typeof snapshot === 'object'
531
+ && snapshot.injection !== null && typeof snapshot.injection === 'object'
532
+ ? snapshot.injection : {}
533
+ const data = snapshot !== null && typeof snapshot === 'object'
534
+ && snapshot.data !== null && typeof snapshot.data === 'object'
535
+ ? snapshot.data : {}
536
+ const budget = typeof inj.budget === 'number' && inj.budget > 0
537
+ ? inj.budget
538
+ : (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
539
+ const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
540
+ const countFlagged = (list, wantTruncated) =>
541
+ Array.isArray(list)
542
+ ? list.filter((item) => item !== null && typeof item === 'object'
543
+ && typeof item.id === 'string' && item.id !== ''
544
+ && (wantTruncated ? item.truncated === true : true)).length
545
+ : 0
546
+ const omitted = countFlagged(inj.omitted, false)
547
+ const truncated = countFlagged(inj.included, true)
548
+ const percent = budget > 0 ? Math.min(100, Math.round((used / budget) * 100)) : 0
549
+ // The same rough ratio the budget note quotes; displayed as an ESTIMATE.
550
+ const tokens = Math.round(used * 0.6)
551
+ return { budget, used, omitted, truncated, percent, tokens }
552
+ }
553
+
488
554
  async function callHost(method, args) {
489
555
  // A hung host used to pin the panel on 保存中… forever; fail visibly
490
556
  // after a generous timeout instead.
@@ -613,10 +679,18 @@ window.__ModuleLoader__.load({
613
679
  const [busy, setBusy] = React.useState(false)
614
680
  const [draft, setDraft] = React.useState(null)
615
681
  const [budgetText, setBudgetText] = React.useState('')
682
+ // Latest live value of the draft, because the SSE onmessage closure below
683
+ // is created once and must not read a stale first-render budgetText.
684
+ const budgetTextRef = React.useRef('')
685
+ budgetTextRef.current = budgetText
616
686
  const [showPreview, setShowPreview] = React.useState(false)
617
687
  const [query, setQuery] = React.useState('')
618
688
  const [undo, setUndo] = React.useState(null)
619
689
  const importInputRef = React.useRef(null)
690
+ // Whether the budget input is being edited right now. A live SSE push
691
+ // from another window must not replace the number under the cursor;
692
+ // the ref (not state) is read inside absorb so no re-render is needed.
693
+ const budgetFocusedRef = React.useRef(false)
620
694
  // The latest snapshot this panel has absorbed. The SSE effect runs once
621
695
  // (empty deps), so it cannot read the `view` state of a later render —
622
696
  // the ref is how its frames are compared against what is on screen.
@@ -668,7 +742,11 @@ window.__ModuleLoader__.load({
668
742
  }
669
743
  setView(normalized.snapshot)
670
744
  viewRef.current = normalized.snapshot
671
- setBudgetText(String(normalized.snapshot.data.budgetChars))
745
+ // Keep the user's in-progress budget edit when a push arrives while
746
+ // the input is focused; re-sync only once editing is not active. The
747
+ // ref is the live draft — absorb may run from the SSE closure created
748
+ // on the first render.
749
+ setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
672
750
  if (!normalized.ok && normalized.message) {
673
751
  setError(normalized.message)
674
752
  setNotice('')
@@ -813,6 +891,23 @@ window.__ModuleLoader__.load({
813
891
  const injection = view.injection || { registered: false, chars: 0, error: null }
814
892
  const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
815
893
  const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
894
+ // Budget usage + per-entry marks straight from the Host's injection plan.
895
+ // Older hosts simply publish none of these fields: the meter falls back
896
+ // to data.budgetChars and the maps stay empty.
897
+ const meter = budgetMeter(view)
898
+ const omittedById = new Map()
899
+ const truncatedById = new Map()
900
+ {
901
+ const planItems = injection && typeof injection === 'object' ? injection : {}
902
+ for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
903
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') omittedById.set(item.id, item)
904
+ }
905
+ for (const item of Array.isArray(planItems.included) ? planItems.included : []) {
906
+ if (item !== null && typeof item === 'object' && item.truncated === true && typeof item.id === 'string') {
907
+ truncatedById.set(item.id, item)
908
+ }
909
+ }
910
+ }
816
911
  const keyword = query.trim().toLowerCase()
817
912
  const visible = keyword === ''
818
913
  ? entries
@@ -858,8 +953,11 @@ window.__ModuleLoader__.load({
858
953
  value: budgetText,
859
954
  disabled: busy,
860
955
  title: t('budget.unitHint'),
956
+ onFocus: () => { budgetFocusedRef.current = true },
861
957
  onChange: (ev) => setBudgetText(ev.target.value),
862
958
  onBlur: () => {
959
+ // Editing is over: later pushes may sync the field again.
960
+ budgetFocusedRef.current = false
863
961
  const parsed = Number(budgetText)
864
962
  if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
865
963
  setBudgetText(String(data.budgetChars))
@@ -875,6 +973,19 @@ window.__ModuleLoader__.load({
875
973
  h('label', { className: 'im-label' }, t('rules.label')),
876
974
  h('div', { className: 'im-meta' },
877
975
  t('rules.note')))),
976
+ (data.enabled === true && activeCount > 0)
977
+ ? h('div', { key: 'meter' },
978
+ h('div', { className: 'im-meter' },
979
+ h('div', {
980
+ className: 'im-meter-fill' + (meter.omitted > 0 ? ' im-meter-fill-warn' : ''),
981
+ style: { width: meter.percent + '%' },
982
+ })),
983
+ h('div', { className: 'im-meta im-meter-line' },
984
+ t('budget.used', { used: meter.used, budget: meter.budget, tokens: meter.tokens })),
985
+ meter.omitted > 0
986
+ ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
987
+ : null)
988
+ : null,
878
989
  entries.length > 0 ? h('div', { key: 'search' },
879
990
  h('input', {
880
991
  className: 'im-input',
@@ -960,6 +1071,17 @@ window.__ModuleLoader__.load({
960
1071
  ]
961
1072
  if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
962
1073
  else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
1074
+ // Budget marks from the Host's injection plan. Only active entries can
1075
+ // appear in the plans, so a disabled entry never gets a stale mark.
1076
+ if (omittedById.has(entry.id)) {
1077
+ tags.push(h('span', {
1078
+ className: 'im-tag im-tag-off', key: 'off', title: t('tag.omittedTitle'),
1079
+ }, t('tag.omitted')))
1080
+ } else if (truncatedById.has(entry.id)) {
1081
+ tags.push(h('span', {
1082
+ className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
1083
+ }, t('tag.truncated')))
1084
+ }
963
1085
  return h('div', {
964
1086
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
965
1087
  key: entry.id,
@@ -1108,6 +1230,8 @@ window.__ModuleLoader__.load({
1108
1230
  exports.__sameSnapshot = sameSnapshot
1109
1231
  exports.__confirmDiscard = confirmDiscard
1110
1232
  exports.__importSizeVerdict = importSizeVerdict
1233
+ exports.__nextBudgetText = nextBudgetText
1234
+ exports.__budgetMeter = budgetMeter
1111
1235
  exports.__strings = STRINGS
1112
1236
  exports.__ns = NS
1113
1237
  exports.__dictionaryT = dictionaryT
package/lib/index.js CHANGED
@@ -10,8 +10,10 @@
10
10
  * plugin's fiber, so unmounting the row removes all of them.
11
11
  */
12
12
 
13
- import { copyFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
13
+ import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
14
14
  import { watch } from 'node:fs'
15
+ import { randomUUID } from 'node:crypto'
16
+ import { isIP } from 'node:net'
15
17
  import { homedir } from 'node:os'
16
18
  import { basename, dirname, join } from 'node:path'
17
19
 
@@ -326,6 +328,13 @@ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127
326
328
  *
327
329
  * `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
328
330
  * Exported so the parsing itself can be pinned by tests.
331
+ *
332
+ * HTTP requires IPv6 literals in Host to wear brackets, so the bracketed
333
+ * form is the one real traffic uses; the bare form still has to parse instead
334
+ * of being chopped at the last colon (the old code turned a bare `::1` into
335
+ * `:`, which never matched the loopback set). A bare value carrying MORE than
336
+ * one colon is an IPv6 literal with no port — with at most one colon it is
337
+ * host:port.
329
338
  */
330
339
  export function hostnameOf(value) {
331
340
  if (typeof value !== 'string') return ''
@@ -333,12 +342,100 @@ export function hostnameOf(value) {
333
342
  if (trimmed === '') return ''
334
343
  if (trimmed.startsWith('[')) {
335
344
  const end = trimmed.indexOf(']')
336
- return end === -1 ? trimmed : trimmed.slice(1, end)
345
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
337
346
  }
347
+ // Unbracketed IPv6 literal: the colons are part of the address, not a port.
348
+ if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
338
349
  const colon = trimmed.lastIndexOf(':')
339
350
  return colon === -1 ? trimmed : trimmed.slice(0, colon)
340
351
  }
341
352
 
353
+ /**
354
+ * Normalise a hostname the same way on BOTH sides of the Origin comparison.
355
+ *
356
+ * `new URL('http://[::1]:8080').hostname` keeps the brackets (`[::1]`), while
357
+ * `hostnameOf('[::1]:8080')` strips them (`::1`) — comparing the two raw forms
358
+ * rejected every same-origin request that reached the GUI over IPv6 loopback.
359
+ * Strip one surrounding pair of brackets here so both sides spell an IPv6
360
+ * literal identically.
361
+ */
362
+ const normalizeHostname = (value) => (
363
+ typeof value === 'string' && value.startsWith('[') && value.endsWith(']') && value.length >= 2
364
+ ? value.slice(1, -1)
365
+ : value
366
+ )
367
+
368
+ /**
369
+ * Expand an IPv6 literal to eight lower-cased zero-padded groups, translating a
370
+ * trailing embedded IPv4 suffix to two hex groups. Returns null for anything
371
+ * that is not a valid literal.
372
+ *
373
+ * Needed because the two sides of the Origin check spell the SAME address
374
+ * differently: a browser sends Host `[::ffff:127.0.0.1]` while the URL parser
375
+ * canonicalises Origin to `[::ffff:7f00:1]` — text comparison says these are
376
+ * different; address comparison must say they are the same.
377
+ */
378
+ export function canonicalIp(value) {
379
+ if (typeof value !== 'string') return null
380
+ let addr = normalizeHostname(value.trim().toLowerCase())
381
+ const percent = addr.indexOf('%')
382
+ if (percent !== -1) addr = addr.slice(0, percent)
383
+ if (isIP(addr) === 4) {
384
+ return 'v4:' + addr.split('.').map((part) => Number(part)).join('.')
385
+ }
386
+ if (isIP(addr) !== 6) return null
387
+
388
+ // Translate a trailing dotted-quad (e.g. ::ffff:127.0.0.1) into two groups.
389
+ const v4 = /(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr)
390
+ if (v4 !== null) {
391
+ const octets = v4[1].split('.').map((part) => Number(part))
392
+ if (octets.some((octet) => octet > 255)) return null
393
+ const prefix = addr.slice(0, v4.index)
394
+ const hi = (octets[0] << 8 | octets[1]).toString(16)
395
+ const lo = (octets[2] << 8 | octets[3]).toString(16)
396
+ // Keep the trailing ':' of the prefix as the separator before the two
397
+ // translated groups; it may also be the second ':' of a '::' compression,
398
+ // which must survive (so do not strip a leading colon afterwards).
399
+ addr = (prefix.endsWith(':') ? prefix : prefix + ':') + hi + ':' + lo
400
+ }
401
+
402
+ const halves = addr.split('::')
403
+ if (halves.length > 2) return null
404
+ if (halves.length === 1) {
405
+ const groups = halves[0].split(':')
406
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
407
+ return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
408
+ }
409
+ const [leftRaw, rightRaw] = halves
410
+ const left = leftRaw === '' ? [] : leftRaw.split(':')
411
+ const right = rightRaw === '' ? [] : rightRaw.split(':')
412
+ const missing = 8 - left.length - right.length
413
+ if (missing < 0) return null
414
+ const groups = left.concat(Array(missing).fill('0'), right)
415
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
416
+ return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
417
+ }
418
+
419
+ /** Whether two hostnames are the same endpoint: exact text or same IP literal. */
420
+ const sameHostname = (a, b) => {
421
+ if (a === b) return true
422
+ const ca = canonicalIp(a)
423
+ const cb = canonicalIp(b)
424
+ return ca !== null && cb !== null && ca === cb
425
+ }
426
+
427
+ /** Loopback by exact name or by equivalent IP spelling (mapped/hex forms). */
428
+ const isLoopbackName = (name) => {
429
+ if (LOOPBACK_HOSTNAMES.has(name)) return true
430
+ const canonical = canonicalIp(name)
431
+ if (canonical === null) return false
432
+ for (const known of LOOPBACK_HOSTNAMES) {
433
+ const knownCanonical = canonicalIp(known)
434
+ if (knownCanonical !== null && knownCanonical === canonical) return true
435
+ }
436
+ return false
437
+ }
438
+
342
439
  /**
343
440
  * Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
344
441
  * all, when it already refuses any content-type that is not
@@ -380,7 +477,7 @@ export function requestOriginVerdict(headers, guarded) {
380
477
  const requestHost = read('host').trim().toLowerCase()
381
478
  const requestHostname = hostnameOf(requestHost)
382
479
 
383
- if (guarded && !LOOPBACK_HOSTNAMES.has(requestHostname)) {
480
+ if (guarded && !isLoopbackName(requestHostname)) {
384
481
  return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
385
482
  }
386
483
 
@@ -393,14 +490,19 @@ export function requestOriginVerdict(headers, guarded) {
393
490
  if (origin !== '') {
394
491
  let originHostname = ''
395
492
  try {
396
- originHostname = new URL(origin).hostname.toLowerCase()
493
+ // URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
494
+ // takes them off so it can meet the bracket-free Host parsing on equal
495
+ // terms.
496
+ originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
397
497
  } catch {
398
498
  originHostname = ''
399
499
  }
400
500
  // Compare hostnames, not host:port: a proxy may rewrite the port, and the
401
501
  // hostname is the part rebinding actually attacks. `Origin: null`
402
502
  // (sandboxed iframe, file://) parses to no hostname and is refused.
403
- if (originHostname === '' || originHostname !== requestHostname) {
503
+ // Canonical IP comparison covers the two spellings of one address:
504
+ // ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
505
+ if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
404
506
  return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
405
507
  }
406
508
  }
@@ -416,19 +518,41 @@ function formatEntry(entry, tag) {
416
518
  }
417
519
 
418
520
  /**
419
- * Render the injected block. Deliberately deterministic: identical memory
420
- * produces byte-identical text on every step, so the prompt prefix stays
421
- * cacheable across a session.
521
+ * Build the injection plan: the exact text assembled into the system prompt
522
+ * PLUS the per-entry decisions behind it, so the settings page can show how
523
+ * much of the character budget is in use and flag the enabled entries the
524
+ * current budget leaves out.
525
+ *
526
+ * Deliberately deterministic: identical memory produces a byte-identical
527
+ * plan on every step, so the prompt prefix stays cacheable across a session.
528
+ *
529
+ * Returned shape:
530
+ * {
531
+ * text: string — exactly what buildBlock() returns
532
+ * budget: number — the clamped budgetChars in effect
533
+ * activeCount: number — enabled, non-blank candidates considered
534
+ * included: [{ id, chars, truncated }] — entries that reach the prompt,
535
+ * in consideration order (始终 first, then 按需; within
536
+ * each, priority desc then updatedAt desc); chars is the
537
+ * rendered length of that entry's block (its cut length
538
+ * when truncated)
539
+ * omitted: [{ id, chars }] — active entries the budget could not fit;
540
+ * chars is the full rendered block length they would need
541
+ * }
542
+ *
543
+ * Disabled or blank entries are candidates for neither list: they are not
544
+ * competing for the budget at all. When nothing renders, `text` is '' and
545
+ * `included` is [] even though `budget` still reports the effective ceiling.
422
546
  */
423
- export function buildBlock(data) {
424
- if (data === null || typeof data !== 'object' || data.enabled !== true) return ''
547
+ export function planInjection(data) {
548
+ const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
549
+ const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
550
+ if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
425
551
  const active = (Array.isArray(data.entries) ? data.entries : [])
426
552
  .filter((entry) => entry.enabled === true && entry.content !== '')
427
- if (active.length === 0) return ''
553
+ if (active.length === 0) return blank()
428
554
  active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
429
555
 
430
- const budget = clampBudget(data.budgetChars)
431
-
432
556
  // Richest preamble that still leaves room for one real entry plus the
433
557
  // worst-case omission footer. A tight budget degrades the preamble instead of
434
558
  // silently injecting nothing.
@@ -463,6 +587,8 @@ export function buildBlock(data) {
463
587
  }
464
588
 
465
589
  const blocks = []
590
+ const included = []
591
+ const omittedPlan = []
466
592
  let used = header.length
467
593
  let omitted = 0
468
594
 
@@ -474,6 +600,7 @@ export function buildBlock(data) {
474
600
  if (used + block.length + 2 + reserve <= budget) {
475
601
  used += block.length + 2
476
602
  blocks.push(block)
603
+ included.push({ id: entry.id, chars: block.length, truncated: false })
477
604
  return
478
605
  }
479
606
  // The first entry always gets in, truncated if the budget demands it: a
@@ -491,17 +618,36 @@ export function buildBlock(data) {
491
618
  const body = head + note
492
619
  blocks.push(body)
493
620
  used += body.length + 2
621
+ included.push({ id: entry.id, chars: body.length, truncated: true })
494
622
  return
495
623
  }
496
624
  }
497
625
  omitted += 1
626
+ omittedPlan.push({ id: entry.id, chars: block.length })
498
627
  }
499
628
 
500
629
  for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
501
630
  for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
502
631
 
503
- if (blocks.length === 0) return ''
504
- return header + blocks.join('\n\n') + footerFor(omitted)
632
+ if (blocks.length === 0) {
633
+ return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
634
+ }
635
+ return {
636
+ text: header + blocks.join('\n\n') + footerFor(omitted),
637
+ budget,
638
+ activeCount: active.length,
639
+ included,
640
+ omitted: omittedPlan,
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Render the injected block text. A thin wrapper over planInjection: the
646
+ * decision process lives in one place, and callers that only need the text
647
+ * (the prompt section) keep this byte-stable entry point.
648
+ */
649
+ export function buildBlock(data) {
650
+ return planInjection(data).text
505
651
  }
506
652
 
507
653
  /* ------------------------------------------------------------------ *
@@ -623,6 +769,22 @@ export function apply(ctx) {
623
769
  }
624
770
  }
625
771
 
772
+ /**
773
+ * The full injection plan (text + per-entry budget decisions) behind the
774
+ * registered section. The snapshot publishes it so the settings page can
775
+ * show budget usage and mark the enabled entries that currently do not fit.
776
+ * A render failure degrades to an empty plan at the effective ceiling
777
+ * rather than throwing into the route/SSE path.
778
+ */
779
+ const sectionPlan = () => {
780
+ try {
781
+ return planInjection(state.data)
782
+ } catch (err) {
783
+ console.error('[instruction-memory] render failed:', err)
784
+ return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
785
+ }
786
+ }
787
+
626
788
  /**
627
789
  * The thunk handed to the harness.
628
790
  *
@@ -692,23 +854,25 @@ export function apply(ctx) {
692
854
  /* ---------------- external-change watching (SSE) ---------------- */
693
855
 
694
856
  /**
695
- * Live subscribers to the settings page's event stream. Each entry is the
696
- * `send(payload)` writer for one open response.
857
+ * Live subscribers to the settings page's event stream. Each entry maps a
858
+ * stream's `send(payload)` writer to its own teardown, so a write that fails
859
+ * (the socket went away) can dispose THAT stream instead of just dropping
860
+ * it from the broadcast set while its response stays open.
697
861
  */
698
- const sseClients = new Set()
699
- /** Per-connection teardown, so unmounting the plugin can end every stream. */
700
- const sseCleanups = new Set()
862
+ const sseStreams = new Map()
701
863
  let storeWatcher = null
702
864
  let watchDebounce = null
703
865
 
704
866
  const pushSnapshot = () => {
705
- if (sseClients.size === 0) return
867
+ if (sseStreams.size === 0) return
706
868
  const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
707
- for (const send of Array.from(sseClients)) {
869
+ for (const [send, cleanup] of Array.from(sseStreams)) {
708
870
  try {
709
871
  send(payload)
710
872
  } catch {
711
- sseClients.delete(send)
873
+ // The response is dead: run its full teardown (end + unsubscribe),
874
+ // not just a set delete that leaves the stream half-registered.
875
+ cleanup()
712
876
  }
713
877
  }
714
878
  }
@@ -788,15 +952,14 @@ export function apply(ctx) {
788
952
  }
789
953
 
790
954
  const disposeStreams = () => {
791
- for (const cleanup of Array.from(sseCleanups)) {
955
+ for (const cleanup of Array.from(sseStreams.values())) {
792
956
  try {
793
957
  cleanup()
794
958
  } catch {
795
959
  // The response is already gone.
796
960
  }
797
961
  }
798
- sseCleanups.clear()
799
- sseClients.clear()
962
+ sseStreams.clear()
800
963
  }
801
964
 
802
965
  /* ---------------- storage ---------------- */
@@ -807,7 +970,8 @@ export function apply(ctx) {
807
970
 
808
971
  /**
809
972
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
810
- * (`wx`), held for the duration of one atomic replace, then removed.
973
+ * (`wx`) holding a random owner token, kept for the duration of one atomic
974
+ * replace, and removed afterwards ONLY BY ITS OWNER.
811
975
  *
812
976
  * The in-process diskQueue serialises this plugin's own writes, but two DSH
813
977
  * processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
@@ -821,19 +985,32 @@ export function apply(ctx) {
821
985
  * write proceeds anyway (disclosed in the debug log). A lock older than
822
986
  * LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
823
987
  * process cannot wedge every later save behind a stale marker.
988
+ *
989
+ * The token matters: stealing used to be a plain unlink, and the slow
990
+ * holder's `finally` then unlinked the lock the NEW owner had just created —
991
+ * a classic TOCTOU that silently unlocked the third writer. The marker
992
+ * carries this attempt's UUID, and release deletes it only when the file
993
+ * still carries that UUID, so a finished holder can never remove a lock it
994
+ * no longer owns.
995
+ *
996
+ * @returns `{ path, token }` while held, or `null` when the wait elapsed
997
+ * (the save proceeds unlocked by design).
824
998
  */
825
999
  const acquireStoreLock = async () => {
826
1000
  const lockPath = storePath + '.lock'
1001
+ const token = randomUUID()
827
1002
  const deadline = Date.now() + LOCK_WAIT_MS
828
1003
  for (;;) {
829
1004
  try {
830
- const handle = await open(lockPath, 'wx')
831
- await handle.close()
832
- return lockPath
1005
+ await writeFile(lockPath, token, { flag: 'wx' })
1006
+ return { path: lockPath, token }
833
1007
  } catch (err) {
834
1008
  if (err.code !== 'EEXIST') throw err
835
1009
  const info = await stat(lockPath).catch(() => null)
836
1010
  if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
1011
+ // Crashed-holder debris. It may be recreated by a live process
1012
+ // between this unlink and the next wx attempt — that case simply
1013
+ // loops and re-evaluates freshness instead of forcing its way in.
837
1014
  await unlink(lockPath).catch(() => {})
838
1015
  continue
839
1016
  }
@@ -843,6 +1020,21 @@ export function apply(ctx) {
843
1020
  }
844
1021
  }
845
1022
 
1023
+ /**
1024
+ * Release a lock only when THIS attempt still owns it. Reading the marker
1025
+ * back and comparing the token closes the window in which a finished slow
1026
+ * holder deleted a lock the next owner had already taken over.
1027
+ */
1028
+ const releaseStoreLock = async (lock) => {
1029
+ if (lock === null) return
1030
+ try {
1031
+ const owner = await readFile(lock.path, 'utf8').catch(() => null)
1032
+ if (owner === lock.token) await unlink(lock.path).catch(() => {})
1033
+ } catch {
1034
+ // Cleanup must never throw into the save's finally.
1035
+ }
1036
+ }
1037
+
846
1038
  const writeToDisk = async () => {
847
1039
  // Atomic replace (write a sibling temp file, then rename) with a rolling
848
1040
  // one-version backup: a crash mid-write must never leave a truncated
@@ -859,12 +1051,12 @@ export function apply(ctx) {
859
1051
  // any step leaves the previous store readable at its own path.
860
1052
  const tmpPath = storePath + '.tmp'
861
1053
  const bakPath = storePath + '.bak'
862
- let lockPath = null
1054
+ let lock = null
863
1055
  let previousRev = null
864
1056
  try {
865
1057
  await mkdir(dirname(storePath), { recursive: true })
866
- lockPath = await acquireStoreLock()
867
- if (lockPath === null) {
1058
+ lock = await acquireStoreLock()
1059
+ if (lock === null) {
868
1060
  logDebug('store lock still held by another process; writing anyway (last write wins)')
869
1061
  }
870
1062
  // The rev is bumped before serialising so the file on disk and the state
@@ -912,7 +1104,7 @@ export function apply(ctx) {
912
1104
  state.storageError = '保存失败:' + describeError(err)
913
1105
  return false
914
1106
  } finally {
915
- if (lockPath !== null) await unlink(lockPath).catch(() => {})
1107
+ await releaseStoreLock(lock)
916
1108
  }
917
1109
  }
918
1110
 
@@ -1104,11 +1296,19 @@ export function apply(ctx) {
1104
1296
  // Load the store, then register the section if anything must be injected.
1105
1297
  // Every entry point awaits this, so a request that arrives before the disk
1106
1298
  // read settles can never overwrite the file with an empty in-memory state.
1299
+ //
1300
+ // `bootSettled` flips only after the read lands. The SSE connect frame is
1301
+ // gated on it: a stream opened in the gap between apply() and the read
1302
+ // finishing must wait, or its first frame would describe the EMPTY boot
1303
+ // state and the panel would flash "no instructions" before the real
1304
+ // snapshot arrived (POST /state already awaited boot; the stream did not).
1305
+ let bootSettled = false
1107
1306
  const boot = readFromDisk()
1108
1307
  .catch((err) => {
1109
1308
  state.storageError = '读取失败:' + describeError(err)
1110
1309
  })
1111
1310
  .then(() => {
1311
+ bootSettled = true
1112
1312
  syncSection()
1113
1313
  // The one always-on line: it answers "is memory mounted, where does it
1114
1314
  // live, and how much is being injected?" without any further digging.
@@ -1147,7 +1347,8 @@ export function apply(ctx) {
1147
1347
  /* ---------------- snapshots ---------------- */
1148
1348
 
1149
1349
  const snapshot = () => {
1150
- const preview = sectionText()
1350
+ const plan = sectionPlan()
1351
+ const preview = plan.text
1151
1352
  return {
1152
1353
  data: {
1153
1354
  version: 1,
@@ -1170,6 +1371,16 @@ export function apply(ctx) {
1170
1371
  available: true,
1171
1372
  error: state.sectionError,
1172
1373
  chars: preview.length,
1374
+ /**
1375
+ * The budget decision behind `preview`, for the settings page's
1376
+ * usage meter and per-entry marks. `budget` is the effective ceiling;
1377
+ * `included`/`omitted` carry one item per active entry in
1378
+ * consideration order; disabled or blank entries appear in neither.
1379
+ */
1380
+ budget: plan.budget,
1381
+ activeCount: plan.activeCount,
1382
+ included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
1383
+ omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
1173
1384
  /**
1174
1385
  * How many times the harness has pulled this section into a prompt since
1175
1386
  * boot. 0 means "prepared, never assembled" — the distinction the old
@@ -1301,6 +1512,13 @@ export function apply(ctx) {
1301
1512
  const deleteEntry = (id, baseRev) => enqueue(async () => {
1302
1513
  if (state.versionRejected) return versionRefusal()
1303
1514
  if (isStaleBaseRev(baseRev)) return conflictResponse()
1515
+ // Deleting an id that is not there changes nothing, so it must not touch
1516
+ // disk, bump rev, or push an echo frame — otherwise two panels deleting
1517
+ // the same entry one after another would manufacture a pointless write
1518
+ // (and an external process watching the file would wake for nothing).
1519
+ if (!state.data.entries.some((entry) => entry.id === id)) {
1520
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
1521
+ }
1304
1522
  const saved = await commit(() => {
1305
1523
  state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
1306
1524
  })
@@ -1487,23 +1705,38 @@ export function apply(ctx) {
1487
1705
  // restarted) instead of hammering the route.
1488
1706
  res.write('retry: 5000\n\n')
1489
1707
  const send = (payload) => { res.write(payload) }
1490
- sseClients.add(send)
1491
- // A snapshot on connect, so a late subscriber synchronises immediately
1492
- // instead of waiting for the next change.
1493
- send('data: ' + JSON.stringify(snapshot()) + '\n\n')
1708
+ let closed = false
1494
1709
  const cleanup = () => {
1495
- sseClients.delete(send)
1496
- sseCleanups.delete(cleanup)
1710
+ closed = true
1711
+ sseStreams.delete(send)
1497
1712
  try {
1498
1713
  res.end()
1499
1714
  } catch {
1500
1715
  // The response is already gone.
1501
1716
  }
1502
1717
  }
1503
- sseCleanups.add(cleanup)
1718
+ sseStreams.set(send, cleanup)
1504
1719
  req.on('close', cleanup)
1505
1720
  req.on('error', cleanup)
1506
1721
  res.on('error', cleanup)
1722
+ // A snapshot on connect, so a late subscriber synchronises immediately
1723
+ // instead of waiting for the next change. It must wait for boot: a stream
1724
+ // opened while the initial disk read is still in flight would otherwise be
1725
+ // handed the empty boot state as its first frame, flashing "no
1726
+ // instructions" before the loaded store arrives.
1727
+ const sendConnectFrame = () => {
1728
+ if (closed) return
1729
+ try {
1730
+ send('data: ' + JSON.stringify(snapshot()) + '\n\n')
1731
+ } catch {
1732
+ cleanup()
1733
+ }
1734
+ }
1735
+ if (bootSettled) {
1736
+ sendConnectFrame()
1737
+ } else {
1738
+ boot.then(sendConnectFrame, sendConnectFrame)
1739
+ }
1507
1740
  }
1508
1741
 
1509
1742
  const handler = (req, res) => {
@@ -1613,7 +1846,7 @@ export function apply(ctx) {
1613
1846
  // disabled for all non-loopback binds and the trade-off is documented in
1614
1847
  // the README.
1615
1848
  const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
1616
- guardHost = boundHost === '' || LOOPBACK_HOSTNAMES.has(boundHost)
1849
+ guardHost = boundHost === '' || isLoopbackName(boundHost)
1617
1850
  try {
1618
1851
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
1619
1852
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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",
@@ -49,7 +49,7 @@
49
49
  "node": ">=20"
50
50
  },
51
51
  "peerDependencies": {
52
- "@deepseek-ai/dsh-system-prompt": ">=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.0-0"
52
+ "@deepseek-ai/dsh-system-prompt": ">=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"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"
@@ -72,10 +72,10 @@
72
72
  "platform": "web"
73
73
  },
74
74
  "engines": {
75
- "dsh": ">=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.0-0"
75
+ "dsh": ">=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"
76
76
  },
77
77
  "compatibility": {
78
- "dsh": ">=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.0-0"
78
+ "dsh": ">=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"
79
79
  }
80
80
  }
81
81
  }