dsh-instruction-memory 1.4.8 → 1.5.1

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,92 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.1] — 2026-11-05
11
+
12
+ 针对 2026-11-05 桌面端 / 网页端复核(架构 / 安全 / 验收三方)的 P1–P3 逐项修复。运行时在
13
+ `0.2.1-rc.5` 与 `0.2.2-rc.1` 上都没有 blocker;这一版修的是**守卫的边界**和**验证工具链本身**。
14
+
15
+ ### 修复
16
+
17
+ - **写入根(P2)**:`backupDir` 的子树的规则此前只在「设置页」与「导出」两处执行,保存路径
18
+ (`.bak` 与历史快照)没有——一个手改的、指向 `$DSH_HOME` 之外的 `backupDir`,会在下一次保存时
19
+ 在外目录里留下 `memory.json.bak` 与 `history/*.json`,与 README 的承诺直接矛盾。现在两处写盘
20
+ 路径都过同一条判断,越界值被忽略并在面板披露,备份与快照写回默认位置。
21
+ - **鉴权门对未知形状 fail-open(P3)**:`requestRejection()` 过去只在返回**数字 ≥400** 时拒绝,
22
+ 其余一律当作通过。宿主今天只返回 `undefined | 401 | 403`,所以不可利用;但这是没写进契约的
23
+ 假设。现在 `undefined/null/false` 才算通过,其余(对象、字符串、Error)一律按 401 拒绝并记日志。
24
+ - **版本号类型误导(P3)**:`{"version":"2"}` 会报「声明为格式版本 2,本插件支持的格式版本为 1 / 2」
25
+ ——自相矛盾。现在按类型区分:非数字版本报「version 字段类型不正确:必须是数字,当前是 "2"」。
26
+ - **拒绝文案泄露绝对路径(P3)**:备份/导出越界的提示里回的是未折叠的 `$DSH_HOME`,现改用
27
+ 与快照一致的 `displayPath()`。
28
+ - **可信主机名不提供认证这件事写进文档与面板(P2)**:README 该节加了醒目说明,快照新增
29
+ `security.trustedHostCount`;当「非回环绑定 + 无鉴权门 + 配了可信主机名」三者同时成立时,
30
+ 面板显示专门的橙色警告(只放宽主机名校验,不认证任何人)。
31
+ - **`install.mjs` 的优先级写错了(P3)**:注释曾写「profile 由 `$DSH_HOME` 决定」,实际是
32
+ `$DSH_PROFILE_DIR` > `--profile` > `$DSH_PROFILE` > `web`(`$DSH_HOME` 只决定 profiles 根)。
33
+ DSH 会把前两个变量导出给子进程,因此"只设了 `DSH_HOME`"的安装会落进**正在运行的 profile**;
34
+ 现在两者不一致时脚本会先打印警告再动手。
35
+
36
+ ### 验收工具链(P1)
37
+
38
+ - **两条 L3 活体验证脚本在带鉴权门的宿主上已经失效**:`compat-1016-live-check.mjs` 把启动 token
39
+ 只拼在 SSE 的 URL 上、`live-host-smoke.mjs` 完全没有鉴权代码,于是在任何真实宿主上得到
40
+ **2/20 PASS、18 FAIL** 与「HTTP 401 无法连接」。现在两者都先 `GET /?token=` 换会话 cookie
41
+ (与 GUI 完全相同的换法),`live-host-smoke.mjs` 新增 `--token <启动 token>`,
42
+ `compat-1016-live-check.mjs` 的第四个参数即 token。
43
+ - 新增 10 条回归断言:写入根(3 条,含负向对照——把判断退回旧行为时外部目录确实多出
44
+ `history/` 与 `memory.json.bak`)、版本类型(3 条)、鉴权门形状(4 条:对象/字符串/Error 一律
45
+ 401,`undefined` 仍放行)。全套 7 个套件、lint 13 个文件全绿。
46
+
47
+ ## [1.5.0] — 2026-11-04
48
+
49
+ 针对 2026-11-04 全面复核(数据安全 / 并发 / 注入 / SMTP / 设置面板)逐项修复。主线是
50
+ **“合法 JSON 但不是本插件的存储”这一整类输入**:1.4.8 会把 `[]`、`123`、`{"version":"3"}`
51
+ 当作未声明格式的空 store 载入,然后在用户下一次保存时用空库覆盖磁盘。
52
+
53
+ ### 数据安全
54
+
55
+ - **存储形状门**:新增 `storeShapeVerdict()`,区分「不是对象」「对象但不是本插件的存储」
56
+ 「空对象」「合规存储」。前两种不再被采纳,写入被暂停并给出可操作的提示;用户可经历史回滚 /
57
+ 备份整体替换 / 修正文件后重新载入恢复,首次成功写盘后自动解除。
58
+ - **`.bak` 轮转守卫兑现注释承诺**:旧实现只验 `JSON.parse`,畸形主文件会覆盖唯一的好备份;
59
+ 现在只有通过形状门的文件才配称为“上一版备份”。
60
+ - **版本声明收紧**:`{"version":"3"}`、`{"version":"2.0"}` 这类字符串声明不再被当作“未声明版本”
61
+ 而静默降级覆盖,一律拒绝写入。
62
+ - **`adoptParsedStore` 改用同一形状判定**(导入 / 整体替换路径)。
63
+ - **旧位置迁移**:cwd 里的历史文件不是存储时忽略并保留原文件,不再迁移。
64
+
65
+ ### 并发与持久性
66
+
67
+ - **锁回收不再误删**:陈旧锁在 `unlink` 前二次 `stat` 比对 mtime/size,`LOCK_STALE_MS` 10s → 30s。
68
+ - **临时文件唯一化**:`memory.json.tmp-<pid>-<uuid>`,两个进程不会再写同一个临时文件。
69
+ - **落盘栅栏**:tmp 写入 `fsync`,备份复制后 `fsync`,`rename` 后对目录 `fsync`。
70
+ - **重载后强制真写盘**:`readFromDisk` 重置 `lastPersistedKey`,消除“内容巧合相同 → 跳写却报
71
+ saved:true”导致的面板与磁盘分叉;跳写分支同步更新 `savedAt`。
72
+ - 历史清理的设备预算与全局兜底在注释中改为如实描述(跨设备删除是有意保留的例外)。
73
+
74
+ ### 注入与快照
75
+
76
+ - `buildProfilesBlock` 删除死变量;`snapshot()` 的 `injection.profiles` 恢复 `truncated` 标记。
77
+
78
+ ### 反馈邮件(零依赖 SMTP)
79
+
80
+ - EHLO 失败(500/502/504)回落 HELO。
81
+ - 多行响应以最后一行的状态码为准;跨 TCP 块的 UTF-8 用 `StringDecoder` 解码。
82
+ - 服务器未声明 LOGIN/PLAIN 时直接给出机制清单,不再盲目发 `AUTH LOGIN`。
83
+ - `Message-ID` 只接受形如 `local@domain` 的值,其余回退生成;加解密与投递统一走 `mailboxVerdict`。
84
+
85
+ ### 设置面板
86
+
87
+ - **乱序响应不再回退视图**:`absorb()` 拒绝 rev 更旧的快照。
88
+ - **初始加载失败可自愈**:lite 帧在 `view === null` 时也触发重新拉取,并新增「重新载入」按钮。
89
+ - **冲突后不再无限重试**:源码保存的 `baseRev` 在冲突响应后同步为最新 rev;单条记忆在冲突时
90
+ 会先请求确认再覆盖对方修改。
91
+ - **`busy` 改为计数**:首个返回的请求不再提前解锁按钮。
92
+ - **卸载后不再 setState**:组件级 `disposed` 标志。
93
+ - **历史回滚先过体积门**,确认文案带上快照文件名;**切换记忆集前检查用户形象草稿**是否未保存。
94
+ - `normalizeResult` 校验 `data` 是对象且 `entries` 是数组;`resolveSlots()` 的异常不再逃出挂载守卫。
95
+
10
96
  ## [1.4.8] — 2026-11-03
11
97
 
12
98
  针对 2026-11-03 三方复核(架构 / 安全 / 验收)下达的 R1–R11 逐项修复,以及同日第二轮复核
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` | 双端冒烟:对**正在运行**的 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 回合」判为失败。**刻意不进 `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
 
@@ -362,6 +362,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
362
362
  把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开:
363
363
  `DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`(重启宿主生效)。导出目录受同一条限制;
364
364
  手改存储文件把 `backupDir` 指到外面也同样被拒——这条路不能变成创建任意目录的通道。
365
+ 这条规则在**写入时**也执行,不只在校验时:设置页、导出、以及每次保存附带的 `.bak` 与历史
366
+ 快照都走同一个判断(1.5.1 起)。检测到越界的外来 `backupDir` 会被忽略并**在面板上披露**,
367
+ 备份与快照写回默认位置,而不是在外目录里悄悄留下文件。
365
368
  - **导出有 2 秒节流**:两次「导出到备份目录」之间要隔 2 秒,否则提示"还需等几秒"。导出写的是
366
369
  整份存储(满配约 1.2 M 字符),连点不该变成写放大。
367
370
  - 报错文本会折叠路径:自定义备份目录 → `<备份目录>`(云盘路径常带账号名)、记忆目录 →
@@ -638,6 +641,11 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
638
641
 
639
642
  ### 可信主机名(`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS`)
640
643
 
644
+ > ⚠️ 这一项**只放宽主机名校验,不提供任何身份认证**。在没有宿主 `connection` 服务的 profile
645
+ > 上,如果一个反代把请求转成"合法主机名",这条路由就是无登录可读写的。面板在该组合
646
+ > (`security.hostGuarded=false`、`security.authFence=false`、`trustedHostCount>0`)下会显示
647
+ > 专门的橙色警告;对外发布请改用隧道,或在代理层自己做认证。
648
+
641
649
  回环绑定下 `Host` 必须是回环名。若你的访问方式让浏览器发出的 `Host` 不是 `127.0.0.1` /
642
650
  `localhost` / `[::1]`——`hosts` 里把 `dsh.lan` 指向 127.0.0.1、Tailscale 的 MagicDNS 名、
643
651
  或隧道把域名透传进来——可以在启动宿主的环境里显式放行(**仅在没有 `connection` 服务的
@@ -843,7 +851,8 @@ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deep
843
851
  `HOME` 对引擎无效**,别按字面读 `DshProcess.kt`。
844
852
 
845
853
  - **兼容性**:`0.2.1-rc.4` 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的
846
- `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`),注入实测
854
+ `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`;1.5.1 起该脚本第四个参数给启动
855
+ token 时会先 `GET /?token=` 换会话 cookie,不再在带鉴权门的宿主上假失败),注入实测
847
856
  `chars=292 / budget=4000`、`state` p95 1.09 ms、SSE 首帧 0.71 ms;证据
848
857
  `dsh-memory-verification/out/compat-1016-live-rc4-audit.json`。
849
858
  - **数据位置**:`/root/.dsh/instruction-memory/memory.json`,在 App 私有目录的 rootfs 内,
package/install.mjs CHANGED
@@ -5,10 +5,16 @@
5
5
  * node install.mjs --profile superagent # install into a named profile
6
6
  * node install.mjs --remove # roll back completely
7
7
  *
8
- * The profile is resolved in the same order the boot uses:
9
- * $DSH_PROFILE_DIR (a path), then --profile <name>, then $DSH_PROFILE, then
10
- * 'web'. A profile that holds no package.json is refused before anything is
11
- * written, and the profiles that do exist are listed.
8
+ * The profile is resolved in this order: $DSH_PROFILE_DIR (a path), then
9
+ * --profile <name>, then $DSH_PROFILE, then 'web' UNDER $DSH_HOME. Note which
10
+ * variable decides what: $DSH_HOME moves the profiles ROOT, while the name
11
+ * comes from $DSH_PROFILE_DIR / --profile / $DSH_PROFILE. A DSH that spawned
12
+ * this shell exports both, so $DSH_PROFILE_DIR wins over the home you just
13
+ * set — the script warns when the two disagree instead of silently writing
14
+ * into the live profile.
15
+ *
16
+ * A profile that holds no package.json is refused before anything is written,
17
+ * and the profiles that do exist are listed.
12
18
  *
13
19
  * Three idempotent steps:
14
20
  * 1. make the package resolvable from the profile's node_modules (junction)
@@ -38,6 +44,11 @@ const DSH_HOME = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOM
38
44
 
39
45
  // Which profile to install into.
40
46
  //
47
+ // $DSH_HOME only moves the profiles ROOT. The NAME comes from the variables
48
+ // below, and DSH exports both to every child process — so a shell that merely
49
+ // sets DSH_HOME to a scratch directory still installs into $DSH_PROFILE_DIR
50
+ // (the live profile). That mismatch is announced rather than silently obeyed.
51
+ //
41
52
  // This used to be hardcoded to 'web'. On a machine whose DSH runs the
42
53
  // `superagent` profile the script created a junction and a bundle entry inside
43
54
  // a profile nothing loads, then printed INSTALLED — the plugin looked installed
@@ -79,6 +90,16 @@ const profileName = profileNameFromArgv()
79
90
  ?? 'web'
80
91
  const PROF = explicitProfileDir ?? join(PROFILES_ROOT, profileName)
81
92
 
93
+ // A DSH that spawned this shell exports BOTH DSH_HOME and DSH_PROFILE_DIR.
94
+ // Setting only DSH_HOME therefore does not redirect the install: the explicit
95
+ // profile directory still wins, and the write lands in the live profile. Say
96
+ // so BEFORE it happens rather than after.
97
+ if (explicitProfileDir !== null && !explicitProfileDir.startsWith(DSH_HOME)) {
98
+ console.error('warning: DSH_PROFILE_DIR (' + explicitProfileDir + ') is not inside DSH_HOME (' + DSH_HOME + ')')
99
+ console.error(' the explicit profile directory wins, so this install goes to the profile above,')
100
+ console.error(' not to the home you may have just set. Unset DSH_PROFILE_DIR to use DSH_HOME.')
101
+ }
102
+
82
103
  /** Profile directories that actually hold a manifest, for the failure message. */
83
104
  function visibleProfiles() {
84
105
  try {
package/lib/client.js CHANGED
@@ -260,6 +260,7 @@ window.__ModuleLoader__.load({
260
260
  'panel.title': '指令记忆',
261
261
  'panel.subtitle': '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。',
262
262
  'panel.loading': '正在读取指令记忆…',
263
+ 'panel.retry': '重新载入',
263
264
  'panel.masterSwitch': '总开关',
264
265
  'status.mounted': '已挂载到系统提示词——后续每轮对话自动生效({chars} 字符)',
265
266
  'status.notMountedEmpty': '未挂载:当前没有启用中的指令',
@@ -291,6 +292,7 @@ window.__ModuleLoader__.load({
291
292
  'editor.cancel': '取消',
292
293
  'editor.hint': '保存后对下一轮对话立即生效',
293
294
  'editor.needTitleOrContent': '请至少填写标题或指令内容',
295
+ 'editor.conflictOverwrite': '这条记忆刚被其他窗口或进程修改过。继续保存会用你当前的编辑内容覆盖对方的修改,确定要继续吗?',
294
296
  'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
295
297
  'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
296
298
  'body.expand': '展开全部',
@@ -417,6 +419,7 @@ window.__ModuleLoader__.load({
417
419
  'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
418
420
  'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
419
421
  'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
422
+ 'diag.hostWarnTrusted': '安全提示:设置路由绑定在非回环地址({host}),且宿主没有发布鉴权服务;当前唯一放行条件是你配置的 {count} 个可信主机名——那只放宽主机名校验,不提供任何身份认证。请仅在可信网络暴露,或改用隧道方案。',
420
423
  'diag.groupStorage': '存储',
421
424
  'diag.groupInjection': '注入',
422
425
  'diag.groupBackup': '备份与历史',
@@ -493,7 +496,7 @@ window.__ModuleLoader__.load({
493
496
  'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
494
497
  'history.foreign': '来自其他设备({device})',
495
498
  'history.restore': '回滚到此版本',
496
- 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
499
+ 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev},文件 {name})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
497
500
  'history.delete': '删除',
498
501
  'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
499
502
  'history.dir': '快照目录:{dir}',
@@ -545,6 +548,7 @@ window.__ModuleLoader__.load({
545
548
  'panel.title': 'Instruction Memory',
546
549
  'panel.subtitle': 'Saved instructions are written into the system prompt and apply to every conversation, answer, and task that follows.',
547
550
  'panel.loading': 'Loading instruction memory…',
551
+ 'panel.retry': 'Reload',
548
552
  'panel.masterSwitch': 'Master switch',
549
553
  'status.mounted': 'Mounted into the system prompt — effective for every turn that follows ({chars} chars)',
550
554
  'status.notMountedEmpty': 'Not mounted: no enabled instructions',
@@ -576,6 +580,7 @@ window.__ModuleLoader__.load({
576
580
  'editor.cancel': 'Cancel',
577
581
  'editor.hint': 'Takes effect for the next conversation turn once saved',
578
582
  'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
583
+ 'editor.conflictOverwrite': 'Another window or process just changed this entry. Saving now replaces their edit with yours. Continue?',
579
584
  'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
580
585
  'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
581
586
  'body.expand': 'Show all',
@@ -702,6 +707,7 @@ window.__ModuleLoader__.load({
702
707
  'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
703
708
  'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
704
709
  'diag.hostWarnFence': 'Security notice: the settings route is bound to a non-loopback address ({host}), but the host authentication fence is attached — access still requires a signed-in DSH browser session, and unauthenticated requests are refused with 401/403. Keep it on a trusted network anyway.',
710
+ 'diag.hostWarnTrusted': 'Security notice: the settings route is bound to a non-loopback address ({host}) and the host publishes no authentication service; the only thing letting requests in is your {count} trusted hostname(s) — that relaxes a hostname check, it does not authenticate anyone. Keep it on a trusted network or use a tunnel.',
705
711
  'diag.groupStorage': 'Storage',
706
712
  'diag.groupInjection': 'Injection',
707
713
  'diag.groupBackup': 'Backup & history',
@@ -778,7 +784,7 @@ window.__ModuleLoader__.load({
778
784
  'history.itemMetaLite': '{size} KB (file too large to count items)',
779
785
  'history.foreign': 'From another device ({device})',
780
786
  'history.restore': 'Roll back to this',
781
- 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
787
+ 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev}, file {name})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
782
788
  'history.delete': 'Delete',
783
789
  'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
784
790
  'history.dir': 'Snapshot folder: {dir}',
@@ -1165,10 +1171,16 @@ window.__ModuleLoader__.load({
1165
1171
  */
1166
1172
  function normalizeResult(result) {
1167
1173
  if (result === null || typeof result !== 'object') return null
1168
- const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
1174
+ const hasEnvelope = result.snapshot !== undefined && result.snapshot !== null
1175
+ const snapshot = hasEnvelope ? result.snapshot : result
1169
1176
  // data === null passes a typeof check but poisons every consumer that
1170
1177
  // reads data.budgetChars; it is exactly as unusable as a missing data.
1171
- if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined || snapshot.data === null) return null
1178
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
1179
+ const data = snapshot.data
1180
+ if (data === undefined || data === null || typeof data !== 'object' || Array.isArray(data)) return null
1181
+ // Every consumer reads data.entries and sortEntries() calls .slice() on
1182
+ // it; a non-array threw mid-render instead of surfacing a readable error.
1183
+ if (!Array.isArray(data.entries)) return null
1172
1184
  return {
1173
1185
  snapshot,
1174
1186
  ok: result.ok !== false,
@@ -1986,6 +1998,14 @@ window.__ModuleLoader__.load({
1986
1998
  setError(t('err.unrecognized'))
1987
1999
  return
1988
2000
  }
2001
+ // Out-of-order responses: an older request can resolve after a newer one
2002
+ // (an SSE-triggered refresh racing a POST reply) and would restore a
2003
+ // stale snapshot over fresher data. rev only ever increases, so a
2004
+ // snapshot older than the one already rendered is dropped. Equal revs
2005
+ // are still absorbed — they are the same state.
2006
+ const nextRev = revOfFrom(normalized.snapshot)
2007
+ const currentRev = revOf()
2008
+ if (typeof nextRev === 'number' && typeof currentRev === 'number' && nextRev < currentRev) return
1989
2009
  setView(normalized.snapshot)
1990
2010
  viewRef.current = normalized.snapshot
1991
2011
  // Keep the user's in-progress budget edit when a push arrives while
@@ -2007,17 +2027,29 @@ window.__ModuleLoader__.load({
2007
2027
  }
2008
2028
  }
2009
2029
 
2030
+ // A single boolean was cleared by whichever request finished first, which
2031
+ // re-enabled every button while other requests were still in flight.
2032
+ const pendingRef = React.useRef(0)
2033
+ // The panel can be unmounted while a request is in flight; setState after
2034
+ // unmount is a no-op in React 18 but it also loses the error the user
2035
+ // needed to see. Track disposal so late responses are dropped.
2036
+ const disposedRef = React.useRef(false)
2037
+ React.useEffect(() => () => { disposedRef.current = true }, [])
2038
+
2010
2039
  async function send(method, args) {
2040
+ pendingRef.current += 1
2011
2041
  setBusy(true)
2012
2042
  try {
2013
2043
  const result = await callHost(method, args)
2044
+ if (disposedRef.current) return null
2014
2045
  absorb(result)
2015
2046
  return result
2016
2047
  } catch (err) {
2017
- setError(String(err && err.message ? err.message : err))
2048
+ if (!disposedRef.current) setError(String(err && err.message ? err.message : err))
2018
2049
  return null
2019
2050
  } finally {
2020
- setBusy(false)
2051
+ pendingRef.current = Math.max(0, pendingRef.current - 1)
2052
+ if (pendingRef.current === 0 && !disposedRef.current) setBusy(false)
2021
2053
  }
2022
2054
  }
2023
2055
 
@@ -2140,6 +2172,13 @@ window.__ModuleLoader__.load({
2140
2172
  /** Switch the active set. The one click that changes what is injected. */
2141
2173
  function switchSet(id) {
2142
2174
  if (busy || id === '') return
2175
+ // A switch closes every open form. The entry editor has always asked
2176
+ // before discarding; the persona editor did not, so a typed persona was
2177
+ // silently discarded while the entry form next to it would have asked.
2178
+ const openProfile = profileDraftRef.current
2179
+ if (openProfile !== null
2180
+ && profileIsDirty(openProfile, view !== null && Array.isArray(view.data.profiles) ? view.data.profiles : [])
2181
+ && !confirmDiscard()) return
2143
2182
  void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
2144
2183
  // A switch invalidates any list selection: the ids belong to the set
2145
2184
  // that is no longer displayed.
@@ -2226,8 +2265,17 @@ window.__ModuleLoader__.load({
2226
2265
 
2227
2266
  function rollbackTo(item) {
2228
2267
  if (busy) return
2268
+ // Restoring makes the Host read a whole file; a snapshot far larger
2269
+ // than any real store is refused BEFORE the user confirms an operation
2270
+ // that cannot succeed.
2271
+ const sizeVerdict = importSizeVerdict(Number(item.bytes),
2272
+ view !== null && view.limits !== null && typeof view.limits === 'object' ? view.limits.maxBodyChars : undefined)
2273
+ if (!sizeVerdict.ok) {
2274
+ setError(sizeVerdict.message)
2275
+ return
2276
+ }
2229
2277
  const when = new Date(item.epoch).toLocaleString()
2230
- const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
2278
+ const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev, name: item.name }))
2231
2279
  if (ask !== CONFIRM_YES) {
2232
2280
  if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2233
2281
  return
@@ -2377,11 +2425,16 @@ window.__ModuleLoader__.load({
2377
2425
  function saveSource() {
2378
2426
  if (busy) return
2379
2427
  void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
2380
- if (result === null || result.ok !== true) return
2428
+ if (result === null) return
2429
+ // A conflict response carries the FRESH snapshot. Keeping the old
2430
+ // baseRev made every retry conflict again against a rev the Host had
2431
+ // already told us about — an unwinnable loop with no way forward.
2432
+ const freshRev = revOfFrom(result.snapshot)
2433
+ if (typeof freshRev === 'number') setSourceBaseRev(freshRev)
2434
+ if (result.ok !== true) return
2381
2435
  // The draft now matches the committed store, so the rev it is based
2382
2436
  // on must move with it — otherwise the very next save would be
2383
2437
  // refused as a conflict against our own write.
2384
- setSourceBaseRev(revOfFrom(result.snapshot))
2385
2438
  sourceBaselineRef.current = sourceText
2386
2439
  void loadHistory()
2387
2440
  })
@@ -2584,9 +2637,13 @@ window.__ModuleLoader__.load({
2584
2637
  const pushed = JSON.parse(ev.data)
2585
2638
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2586
2639
  const current = viewRef.current
2587
- // A null view means the initial POST `state` is still in flight;
2588
- // its response already carries the current rev.
2589
- if (current !== null && current.data !== null && current.data.rev !== pushed.rev) refresh()
2640
+ // A null view used to be ignored here on the assumption that the
2641
+ // initial POST `state` is always in flight. When that request
2642
+ // failed, nothing ever refetched and the panel stayed on its
2643
+ // loading text for the rest of the session. A lite frame proves
2644
+ // the Host is alive, so it is a safe trigger to retry.
2645
+ if (current === null) refresh()
2646
+ else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
2590
2647
  return
2591
2648
  }
2592
2649
  if (sameSnapshot(pushed, viewRef.current)) return
@@ -2600,7 +2657,11 @@ window.__ModuleLoader__.load({
2600
2657
 
2601
2658
  if (view === null) {
2602
2659
  return h('div', { className: 'im-root' },
2603
- h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error))
2660
+ h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error),
2661
+ error === ''
2662
+ ? null
2663
+ : h('div', { className: 'im-row im-row-end' },
2664
+ h('button', { type: 'button', className: 'im-btn', onClick: () => { setError(''); send('state', null) } }, t('panel.retry'))))
2604
2665
  }
2605
2666
 
2606
2667
  if (draft !== null) {
@@ -2618,13 +2679,26 @@ window.__ModuleLoader__.load({
2618
2679
  setDraft(null); setError('')
2619
2680
  },
2620
2681
  onSave: () => {
2682
+ if (busy) return
2621
2683
  if (draft.title.trim() === '' && draft.content.trim() === '') {
2622
2684
  setError(t('editor.needTitleOrContent'))
2623
2685
  return
2624
2686
  }
2625
- send('save-entry', { entry: draft, baseRev: revOf() }).then((result) => {
2626
- if (result && result.ok === true) { setDraft(null); setError('') }
2627
- })
2687
+ const commit = (baseRev, allowOverwrite) => {
2688
+ void send('save-entry', { entry: draft, baseRev }).then((result) => {
2689
+ if (result === null) return
2690
+ if (result.ok === true) { setDraft(null); setError(''); return }
2691
+ // The Host refuses a write built on a stale rev and answers
2692
+ // with the fresh snapshot (already absorbed into the view).
2693
+ // Retrying on our own would replace the other window's edit
2694
+ // with this draft — a silent overwrite. Ask first, and only
2695
+ // then retry against the rev we just learned.
2696
+ if (result.conflict === true && !allowOverwrite) {
2697
+ if (askConfirm(t('editor.conflictOverwrite'), 'allow') === CONFIRM_YES) commit(revOf(), true)
2698
+ }
2699
+ })
2700
+ }
2701
+ commit(revOf(), false)
2628
2702
  },
2629
2703
  }),
2630
2704
  // The editor is an early return, so it must render its OWN error
@@ -2918,7 +2992,9 @@ window.__ModuleLoader__.load({
2918
2992
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2919
2993
  security.authFence === true
2920
2994
  ? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
2921
- : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2995
+ : security.trustedHostCount > 0
2996
+ ? t('diag.hostWarnTrusted', { host: security.boundHost !== '' ? security.boundHost : '?', count: security.trustedHostCount })
2997
+ : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2922
2998
  : null,
2923
2999
  h('div', { className: 'im-row' },
2924
3000
  h('div', null,
@@ -3707,7 +3783,18 @@ window.__ModuleLoader__.load({
3707
3783
  // published before or after this plugin activates.
3708
3784
  runtimeCtx = ctx
3709
3785
 
3710
- const resolveSlots = () => (ctx.slots !== undefined ? ctx.slots : ctx.get('slots'))
3786
+ const resolveSlots = () => {
3787
+ // ctx.get() can throw on a disposed context, and this call sits outside
3788
+ // safeMount's try — an escaping throw here killed the retry timer with
3789
+ // no message anywhere.
3790
+ try {
3791
+ return ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
3792
+ } catch (err) {
3793
+ console.error('[instruction-memory] 读取 slots 服务失败:'
3794
+ + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err)))
3795
+ return undefined
3796
+ }
3797
+ }
3711
3798
 
3712
3799
  // A retry can fire after the fiber was disposed (plugin disabled, host
3713
3800
  // shutting down); registering into a dead context throws, and an uncaught