dsh-instruction-memory 1.5.0 → 1.5.2

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.2] — 2026-11-09
11
+
12
+ 针对 2026-11-09 的代码审查(Host 侧与 Client 侧并行只读审查,逐条复核后落地)。本版**没有新增功能、
13
+ 没有格式变更**:存储文件、注入文本与面板行为在正常路径上与 1.5.1 逐字节一致,改的是一条静默失效的
14
+ 降级路径、一条失效的乱序保护,以及几处可以量化的重复 I/O 与空转渲染。
15
+
16
+ ### 修复
17
+
18
+ - **watcher 降级把轮询器一起杀掉了(P0)**:`storeWatcher.on('error')` 的日志写的是「falling back to
19
+ the 5s poller」,紧接着却调用 `stopWatching()` —— 而该函数同时 `clearInterval(storePoll)`。于是
20
+ `fs.watch` 一旦报错(被监视目录被删或 rename、Windows 目录句柄失效、inotify 句柄耗尽),**watcher
21
+ 与轮询器双双消失**,外部改动检测彻底静默,直到本进程下一次保存成功才重新装回。期间手改
22
+ `memory.json`、或另一个 DSH 进程保存,本窗口会一直显示旧数据,而面板没有任何提示。现在错误分支
23
+ 只关闭 watcher 并显式 `startPolling()`(幂等),注释所承诺的降级才真的发生。
24
+ - **SSE 乱序快照保护在推送路径上失效(P1)**:`absorb()` 用渲染作用域的 `view` 判定「到来的快照是否
25
+ 比已在屏上的更旧」,但 SSE effect 的依赖是空数组,它捕获的是**首渲染**那份 `absorb`,其闭包里的
26
+ `view` 恒为 `null`,`currentRev` 永远是 `undefined` —— 于是注释点名的「SSE 触发刷新与 POST 回包
27
+ 竞争」恰恰是唯一没被保护的那条路径。现在改读 `viewRef.current`(面板一直在维护它)。
28
+
29
+ ### 性能与加固(不改变外部行为)
30
+
31
+ - **一次保存不再整读同一个文件三次(P1)**:`writeToDisk()` 在跨进程锁内依次 `readDiskRev()`、
32
+ 再一次 `readDiskRev()`、再 `readStoreFile()`,只为拿磁盘 rev(两次结果必然相同,锁在自己手上)
33
+ 与 `.bak` 的形状判定。现在一次读取同时供两处使用,`diskReadError` 保留原有 `ENOENT` / 空文件 /
34
+ 非本插件存储三条分支的语义。极限存储(200 条 × 6000 字 ≈ 3.4 MB)下每次保存少约 10 MB 读取与
35
+ 2 次整文件 `JSON.parse`,并缩短了锁的持有时间。
36
+ - **事件流加上心跳与背压(P2)**:原来的 `send` 丢弃 `res.write()` 的返回值,清理只依赖
37
+ `close`/`error` 事件。客户端崩溃或休眠(没有 FIN)时条目不会被移除,积满 `MAX_SSE_STREAMS=32`
38
+ 后所有新订阅者都被 503 拒绝,面板实时同步失效直到重启宿主;慢客户端还会让极限 3.4 MB 的全量帧
39
+ 在 socket 缓冲里无界堆积。现在 25 秒共享心跳(`: ping` 注释帧,SSE 客户端按规范忽略)逐个探测,
40
+ `write()` 返回 `false` 即清理该流;`pushSnapshot` 同样按返回值清理。
41
+ - **暂停倒计时不再空转重渲染整个面板(P2)**:`nowTick` 每秒写一次 state,但全文没有任何地方读取它
42
+ (剩余时间在渲染时用 `Date.now()` 现算)——每秒重跑并 diff 约 3000 个 vnode,只为让一行文字变化。
43
+ 现在倒计时抽成 `MuteCountdown` 子组件,自带 1s tick,且只在归零时回调一次(SSE 已断、宿主不再推
44
+ 快照时,暂停条也能自行退场)。
45
+ - **列表派生计算记忆化(P2)**:排序、标签索引、筛选、关键词扫描与「按需预演」打分原先全部裸写在
46
+ 渲染体里,每次按键或勾选一条都要重排 200 条并对全部正文重复小写化。现在收进 `deriveListState()`
47
+ 并由 `useStableMemo()` 按(快照, UI 状态)缓存;预演只在展开时才打分(收起后不再扫全库)。
48
+ `useStableMemo` 建在 `useRef` 上而不是 `useMemo` 上,因为 ui-test 里那套迷你 React 只实现了
49
+ `useState`/`useEffect`/`useRef` —— 这样测试与生产走同一条代码路径,而不是为了测试加分支。
50
+
51
+ ### 验收
52
+
53
+ - 全套 7 个套件(lint / host-range / smoke / contract / verify / feedback / ui)与
54
+ `node check-metadata.mjs` 全绿;`lib/` 13 个文件全部 PARSE 且无宿主包名导入。
55
+ - **未做的验证(明确记录)**:watcher 故障注入没有做成真机测试——`storeWatcher` 是闭包内变量,
56
+ 无法从外部触发 `error`,Windows 上删除被监视目录只会产生 `rename` 事件。第一条的保证来自
57
+ 「`stopWatching()` 现在只剩插件卸载一处调用点」这一结构性证明,不是行为测试。
58
+
59
+ ## [1.5.1] — 2026-11-05
60
+
61
+ 针对 2026-11-05 桌面端 / 网页端复核(架构 / 安全 / 验收三方)的 P1–P3 逐项修复。运行时在
62
+ `0.2.1-rc.5` 与 `0.2.2-rc.1` 上都没有 blocker;这一版修的是**守卫的边界**和**验证工具链本身**。
63
+
64
+ ### 修复
65
+
66
+ - **写入根(P2)**:`backupDir` 的子树的规则此前只在「设置页」与「导出」两处执行,保存路径
67
+ (`.bak` 与历史快照)没有——一个手改的、指向 `$DSH_HOME` 之外的 `backupDir`,会在下一次保存时
68
+ 在外目录里留下 `memory.json.bak` 与 `history/*.json`,与 README 的承诺直接矛盾。现在两处写盘
69
+ 路径都过同一条判断,越界值被忽略并在面板披露,备份与快照写回默认位置。
70
+ - **鉴权门对未知形状 fail-open(P3)**:`requestRejection()` 过去只在返回**数字 ≥400** 时拒绝,
71
+ 其余一律当作通过。宿主今天只返回 `undefined | 401 | 403`,所以不可利用;但这是没写进契约的
72
+ 假设。现在 `undefined/null/false` 才算通过,其余(对象、字符串、Error)一律按 401 拒绝并记日志。
73
+ - **版本号类型误导(P3)**:`{"version":"2"}` 会报「声明为格式版本 2,本插件支持的格式版本为 1 / 2」
74
+ ——自相矛盾。现在按类型区分:非数字版本报「version 字段类型不正确:必须是数字,当前是 "2"」。
75
+ - **拒绝文案泄露绝对路径(P3)**:备份/导出越界的提示里回的是未折叠的 `$DSH_HOME`,现改用
76
+ 与快照一致的 `displayPath()`。
77
+ - **可信主机名不提供认证这件事写进文档与面板(P2)**:README 该节加了醒目说明,快照新增
78
+ `security.trustedHostCount`;当「非回环绑定 + 无鉴权门 + 配了可信主机名」三者同时成立时,
79
+ 面板显示专门的橙色警告(只放宽主机名校验,不认证任何人)。
80
+ - **`install.mjs` 的优先级写错了(P3)**:注释曾写「profile 由 `$DSH_HOME` 决定」,实际是
81
+ `$DSH_PROFILE_DIR` > `--profile` > `$DSH_PROFILE` > `web`(`$DSH_HOME` 只决定 profiles 根)。
82
+ DSH 会把前两个变量导出给子进程,因此"只设了 `DSH_HOME`"的安装会落进**正在运行的 profile**;
83
+ 现在两者不一致时脚本会先打印警告再动手。
84
+
85
+ ### 验收工具链(P1)
86
+
87
+ - **两条 L3 活体验证脚本在带鉴权门的宿主上已经失效**:`compat-1016-live-check.mjs` 把启动 token
88
+ 只拼在 SSE 的 URL 上、`live-host-smoke.mjs` 完全没有鉴权代码,于是在任何真实宿主上得到
89
+ **2/20 PASS、18 FAIL** 与「HTTP 401 无法连接」。现在两者都先 `GET /?token=` 换会话 cookie
90
+ (与 GUI 完全相同的换法),`live-host-smoke.mjs` 新增 `--token <启动 token>`,
91
+ `compat-1016-live-check.mjs` 的第四个参数即 token。
92
+ - 新增 10 条回归断言:写入根(3 条,含负向对照——把判断退回旧行为时外部目录确实多出
93
+ `history/` 与 `memory.json.bak`)、版本类型(3 条)、鉴权门形状(4 条:对象/字符串/Error 一律
94
+ 401,`undefined` 仍放行)。全套 7 个套件、lint 13 个文件全绿。
95
+
10
96
  ## [1.5.0] — 2026-11-04
11
97
 
12
98
  针对 2026-11-04 全面复核(数据安全 / 并发 / 注入 / SMTP / 设置面板)逐项修复。主线是
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
@@ -419,6 +419,7 @@ window.__ModuleLoader__.load({
419
419
  'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
420
420
  'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
421
421
  'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
422
+ 'diag.hostWarnTrusted': '安全提示:设置路由绑定在非回环地址({host}),且宿主没有发布鉴权服务;当前唯一放行条件是你配置的 {count} 个可信主机名——那只放宽主机名校验,不提供任何身份认证。请仅在可信网络暴露,或改用隧道方案。',
422
423
  'diag.groupStorage': '存储',
423
424
  'diag.groupInjection': '注入',
424
425
  'diag.groupBackup': '备份与历史',
@@ -706,6 +707,7 @@ window.__ModuleLoader__.load({
706
707
  'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
707
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.',
708
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.',
709
711
  'diag.groupStorage': 'Storage',
710
712
  'diag.groupInjection': 'Injection',
711
713
  'diag.groupBackup': 'Backup & history',
@@ -1176,8 +1178,8 @@ window.__ModuleLoader__.load({
1176
1178
  if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
1177
1179
  const data = snapshot.data
1178
1180
  if (data === undefined || data === null || typeof data !== 'object' || Array.isArray(data)) return null
1179
- // Every consumer reads data.entries and sortEntries() calls .slice() on
1180
- // it; a non-array threw mid-render instead of surfacing a readable error.
1181
+ // Every consumer reads data.entries and deriveListState() slices it; a
1182
+ // non-array threw mid-render instead of surfacing a readable error.
1181
1183
  if (!Array.isArray(data.entries)) return null
1182
1184
  return {
1183
1185
  snapshot,
@@ -1467,6 +1469,127 @@ window.__ModuleLoader__.load({
1467
1469
  return { terms, results, autoTotal }
1468
1470
  }
1469
1471
 
1472
+ /**
1473
+ * A useMemo equivalent built on useRef.
1474
+ *
1475
+ * This module runs against two React instances: the real one inside the DSH
1476
+ * web shell, and the minimal stand-in ui-test.mjs drives the panel with,
1477
+ * which implements useState / useEffect / useRef only. useMemo is absent
1478
+ * there, so the memo is written against useRef — which both provide — with
1479
+ * the same reference-comparison semantics, rather than a test-only branch
1480
+ * that would leave the shipped path unexercised.
1481
+ *
1482
+ * @param {Array} deps
1483
+ * @param {() => any} compute
1484
+ * @returns {any} the cached value
1485
+ */
1486
+ function useStableMemo(deps, compute) {
1487
+ const box = React.useRef(null)
1488
+ const previous = box.current
1489
+ let fresh = previous === null || previous.deps.length !== deps.length
1490
+ if (!fresh) {
1491
+ for (let i = 0; i < deps.length; i += 1) {
1492
+ if (previous.deps[i] !== deps[i]) {
1493
+ fresh = true
1494
+ break
1495
+ }
1496
+ }
1497
+ }
1498
+ if (fresh) box.current = { deps, value: compute() }
1499
+ return box.current.value
1500
+ }
1501
+
1502
+ /**
1503
+ * Sort, filter and index the entry list ONCE per (snapshot, UI state).
1504
+ *
1505
+ * All of this used to run inline in the panel's render body, so a keystroke
1506
+ * in the search box or a single checkbox toggle re-sorted up to 200 entries
1507
+ * and re-lowered every title/body/scenario to scan for the keyword. None of
1508
+ * it is expensive on its own; the cost was paying it on every unrelated
1509
+ * render. The lower-cased haystack is built per entry, not per keystroke,
1510
+ * and the on-demand preview only scores while its panel is open.
1511
+ *
1512
+ * @param {{entries: Array, sortKey: string, tagFilter: string, modeFilter: string,
1513
+ * statusFilter: string, query: string, simQuery: string, simOpen: boolean}} input
1514
+ * @returns {{entries: Array, allTags: string[], visible: Array,
1515
+ * autoEntries: Array, sim: {terms: string[], results: Array, autoTotal: number}}}
1516
+ */
1517
+ function deriveListState(input) {
1518
+ const source = Array.isArray(input.entries) ? input.entries : []
1519
+ const copy = source.slice()
1520
+ if (input.sortKey === 'updated') {
1521
+ copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
1522
+ } else if (input.sortKey === 'title') {
1523
+ copy.sort((a, b) =>
1524
+ a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
1525
+ || (b.priority - a.priority)
1526
+ || (b.updatedAt - a.updatedAt))
1527
+ } else {
1528
+ copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
1529
+ }
1530
+ const tagCounts = new Map()
1531
+ const haystack = new Map()
1532
+ for (const entry of source) {
1533
+ const tags = Array.isArray(entry.tags) ? entry.tags : []
1534
+ for (const tag of tags) {
1535
+ if (typeof tag === 'string' && tag !== '') tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)
1536
+ }
1537
+ haystack.set(entry, [
1538
+ typeof entry.title === 'string' ? entry.title : '',
1539
+ typeof entry.content === 'string' ? entry.content : '',
1540
+ typeof entry.when === 'string' ? entry.when : '',
1541
+ tags.join(' '),
1542
+ ].join(' ').toLowerCase())
1543
+ }
1544
+ const allTags = [...tagCounts.entries()]
1545
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
1546
+ .map(([tag]) => tag)
1547
+ // Same predicate order as the inline version: filters first, then the
1548
+ // keyword, so a filtered-out entry never pays for the search.
1549
+ const keyword = typeof input.query === 'string' ? input.query.trim().toLowerCase() : ''
1550
+ const visible = copy.filter((entry) => {
1551
+ if (input.tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(input.tagFilter))) return false
1552
+ if (input.modeFilter !== '' && entry.mode !== input.modeFilter) return false
1553
+ if (input.statusFilter === 'enabled' && entry.enabled !== true) return false
1554
+ if (input.statusFilter === 'disabled' && entry.enabled !== false) return false
1555
+ if (keyword === '') return true
1556
+ return (haystack.get(entry) || '').indexOf(keyword) !== -1
1557
+ })
1558
+ // The card visibility check needs the count even while collapsed, so the
1559
+ // candidate list is always built; only scoring is skipped.
1560
+ const autoEntries = source.filter((entry) => entry.mode === 'auto' && entry.enabled !== false)
1561
+ const sim = input.simOpen === true
1562
+ ? relevancePreview(autoEntries, input.simQuery)
1563
+ : { terms: [], results: [], autoTotal: 0 }
1564
+ return { entries: copy, allTags, visible, autoEntries, sim }
1565
+ }
1566
+
1567
+ /**
1568
+ * F7's remaining-time line, with its own 1s clock.
1569
+ *
1570
+ * The tick used to live in the panel's state: it re-rendered (and diffed)
1571
+ * the whole settings tree every second for a value nothing ever read, since
1572
+ * the text is derived from Date.now() at render time. Scoping it here
1573
+ * re-renders one span instead. It also reports expiry, so the pause bar
1574
+ * steps aside even when the SSE stream is already dead and no fresh
1575
+ * snapshot is coming to flip the flag.
1576
+ */
1577
+ function MuteCountdown(props) {
1578
+ const [, force] = React.useState(0)
1579
+ const remaining = Math.max(0, props.until - Date.now())
1580
+ const live = remaining > 0
1581
+ React.useEffect(() => {
1582
+ if (!live) return undefined
1583
+ const tick = setInterval(() => force((n) => n + 1), 1000)
1584
+ return () => clearInterval(tick)
1585
+ }, [live])
1586
+ React.useEffect(() => {
1587
+ if (live) return
1588
+ if (typeof props.onExpired === 'function') props.onExpired()
1589
+ }, [live])
1590
+ return h('span', null, props.format(remaining))
1591
+ }
1592
+
1470
1593
  /* ---------------- F16: source-mode validation ---------------- */
1471
1594
 
1472
1595
  /**
@@ -1886,12 +2009,13 @@ window.__ModuleLoader__.load({
1886
2009
  const [feedbackBusy, setFeedbackBusy] = React.useState(false)
1887
2010
  const [feedbackError, setFeedbackError] = React.useState('')
1888
2011
  const [feedbackSent, setFeedbackSent] = React.useState('')
1889
- // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1890
- // ONLY while a pause is live (its effect cleans up after itself). The
1891
- // tick drives nothing but the remaining-time text; authority stays with
1892
- // the host snapshot (injection.muted / data.disabledUntil).
2012
+ // F7: the pause menu (null-state boolean). The countdown that used to
2013
+ // live here as a panel-wide 1s tick now lives in MuteCountdown, so a live
2014
+ // pause re-renders one line instead of the whole settings tree.
1893
2015
  const [pauseMenuOpen, setPauseMenuOpen] = React.useState(false)
1894
- const [nowTick, setNowTick] = React.useState(Date.now())
2016
+ // Bumped once when the countdown reaches zero: the panel then re-renders
2017
+ // and the pause bar yields to the normal control even without a snapshot.
2018
+ const [, setMuteExpired] = React.useState(0)
1895
2019
  // F18: the backup-location draft (focused-input protection like budget).
1896
2020
  const [backupText, setBackupText] = React.useState('')
1897
2021
  const backupTextRef = React.useRef('')
@@ -1945,14 +2069,33 @@ window.__ModuleLoader__.load({
1945
2069
  const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1946
2070
  && view.injection.muted === true
1947
2071
  React.useEffect(() => {
1948
- if (!mutedNow) return undefined
2072
+ if (!mutedNow) return
1949
2073
  // A pause started from ANOTHER window replaces the menu row; the open
1950
2074
  // flag must not resurface when the row returns after expiry.
1951
2075
  setPauseMenuOpen(false)
1952
- const tick = setInterval(() => setNowTick(Date.now()), 1000)
1953
- return () => clearInterval(tick)
1954
2076
  }, [mutedNow])
1955
2077
 
2078
+ /**
2079
+ * The whole list derivation (sort, tag index, filter, keyword scan,
2080
+ * on-demand preview), memoised on the snapshot and the UI inputs that
2081
+ * feed it. It sits before the early returns because hooks cannot be
2082
+ * called conditionally — a null view simply derives empty lists.
2083
+ */
2084
+ const derivedList = useStableMemo([view, sortKey, tagFilter, modeFilter, statusFilter, query, simQuery, simOpen],
2085
+ () => deriveListState({
2086
+ entries: view !== null && view.data !== null && typeof view.data === 'object'
2087
+ && Array.isArray(view.data.entries)
2088
+ ? view.data.entries
2089
+ : [],
2090
+ sortKey,
2091
+ tagFilter,
2092
+ modeFilter,
2093
+ statusFilter,
2094
+ query,
2095
+ simQuery,
2096
+ simOpen,
2097
+ }))
2098
+
1956
2099
  /**
1957
2100
  * F16 live validation, debounced: parsing on every keystroke would stall
1958
2101
  * the panel on a multi-megabyte paste, and 250 ms is below the threshold
@@ -1986,7 +2129,13 @@ window.__ModuleLoader__.load({
1986
2129
  && typeof snapshot.data.rev === 'number'
1987
2130
  ? snapshot.data.rev
1988
2131
  : undefined)
1989
- const revOf = () => revOfFrom(view)
2132
+ // The LIVE snapshot, through the ref — not the render-scoped `view`.
2133
+ // absorb() is also called from the SSE effect, which is registered once
2134
+ // (deps []), so its closure holds the FIRST render's absorb and with it
2135
+ // the first render's `view` (null). Reading `view` here made the
2136
+ // out-of-order guard below accept every pushed snapshot, which is exactly
2137
+ // the SSE-vs-POST race its comment promises to drop.
2138
+ const revOf = () => revOfFrom(viewRef.current)
1990
2139
 
1991
2140
  function absorb(result) {
1992
2141
  const normalized = normalizeResult(result)
@@ -2731,23 +2880,9 @@ window.__ModuleLoader__.load({
2731
2880
  ? view.security
2732
2881
  : { hostGuarded: true, boundHost: '' }
2733
2882
  // List order is display-only and never changes what the Host injects
2734
- // (the injection order is decided host-side). The default mirrors the
2735
- // previous fixed order; updated-time and title are the other sorts.
2736
- const sortEntries = (list) => {
2737
- const copy = list.slice()
2738
- if (sortKey === 'updated') {
2739
- copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
2740
- } else if (sortKey === 'title') {
2741
- copy.sort((a, b) =>
2742
- a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
2743
- || (b.priority - a.priority)
2744
- || (b.updatedAt - a.updatedAt))
2745
- } else {
2746
- copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
2747
- }
2748
- return copy
2749
- }
2750
- const entries = sortEntries(data.entries)
2883
+ // (the injection order is decided host-side). Sorting, filtering and the
2884
+ // keyword scan all happen once, inside the memoised deriveListState.
2885
+ const entries = derivedList.entries
2751
2886
  const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
2752
2887
  // Persona profiles, in stored order (stable injection order). Old hosts
2753
2888
  // simply have no array.
@@ -2760,17 +2895,7 @@ window.__ModuleLoader__.load({
2760
2895
  : profiles.reduce((n, p) => n + (p.enabled === true && p.content ? p.name.length + p.content.length + 5 : 0), 0)
2761
2896
  // Every tag actually in use, most frequent first (then name), for the
2762
2897
  // tag filter row.
2763
- const allTags = (() => {
2764
- const counts = new Map()
2765
- for (const entry of data.entries) {
2766
- for (const tag of Array.isArray(entry.tags) ? entry.tags : []) {
2767
- if (typeof tag === 'string' && tag !== '') counts.set(tag, (counts.get(tag) || 0) + 1)
2768
- }
2769
- }
2770
- return [...counts.entries()]
2771
- .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
2772
- .map(([tag]) => tag)
2773
- })()
2898
+ const allTags = derivedList.allTags
2774
2899
  // Budget usage + per-entry marks straight from the Host's injection plan.
2775
2900
  // Older hosts simply publish none of these fields: the meter falls back
2776
2901
  // to data.budgetChars and the maps stay empty.
@@ -2807,8 +2932,8 @@ window.__ModuleLoader__.load({
2807
2932
  // F17: the local preview scores the ACTIVE set's on-demand entries.
2808
2933
  // Disabled entries can never be injected, so scoring them as 预演 hits
2809
2934
  // would mislead — they are excluded from the preview entirely.
2810
- const autoEntries = data.entries.filter((entry) => entry.mode === 'auto' && entry.enabled !== false)
2811
- const sim = relevancePreview(autoEntries, simQuery)
2935
+ const autoEntries = derivedList.autoEntries
2936
+ const sim = derivedList.sim
2812
2937
  const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
2813
2938
  const omittedById = new Map()
2814
2939
  const truncatedById = new Map()
@@ -2825,22 +2950,8 @@ window.__ModuleLoader__.load({
2825
2950
  }
2826
2951
  const keyword = query.trim().toLowerCase()
2827
2952
  const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
2828
- const matchesFilters = (entry) => {
2829
- if (tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(tagFilter))) return false
2830
- if (modeFilter !== '' && entry.mode !== modeFilter) return false
2831
- if (statusFilter === 'enabled' && entry.enabled !== true) return false
2832
- if (statusFilter === 'disabled' && entry.enabled !== false) return false
2833
- return true
2834
- }
2835
- const visible = entries.filter((entry) => {
2836
- if (!matchesFilters(entry)) return false
2837
- if (keyword === '') return true
2838
- const inTags = (Array.isArray(entry.tags) ? entry.tags.join(' ') : '').toLowerCase()
2839
- return entry.title.toLowerCase().indexOf(keyword) !== -1
2840
- || entry.content.toLowerCase().indexOf(keyword) !== -1
2841
- || entry.when.toLowerCase().indexOf(keyword) !== -1
2842
- || inTags.indexOf(keyword) !== -1
2843
- })
2953
+ // Filters and keyword were applied in one pass inside deriveListState.
2954
+ const visible = derivedList.visible
2844
2955
 
2845
2956
  const dotClass = injection.error
2846
2957
  ? 'im-dot im-dot-err'
@@ -2990,7 +3101,9 @@ window.__ModuleLoader__.load({
2990
3101
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2991
3102
  security.authFence === true
2992
3103
  ? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
2993
- : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
3104
+ : security.trustedHostCount > 0
3105
+ ? t('diag.hostWarnTrusted', { host: security.boundHost !== '' ? security.boundHost : '?', count: security.trustedHostCount })
3106
+ : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2994
3107
  : null,
2995
3108
  h('div', { className: 'im-row' },
2996
3109
  h('div', null,
@@ -3009,7 +3122,11 @@ window.__ModuleLoader__.load({
3009
3122
  // 自动恢复,无需用户操作、不写盘。倒计时只是展示,权威在快照。
3010
3123
  muted && muteRemainingMs > 0
3011
3124
  ? h('div', { className: 'im-pause-bar' },
3012
- h('span', null, t('mute.active', { remaining: muteRemainingText(muteRemainingMs) })),
3125
+ h(MuteCountdown, {
3126
+ until: disabledUntil,
3127
+ format: (ms) => t('mute.active', { remaining: muteRemainingText(ms) }),
3128
+ onExpired: () => setMuteExpired((n) => n + 1),
3129
+ }),
3013
3130
  h('button', { className: 'im-btn', disabled: busy, onClick: resumePause }, t('mute.resume')))
3014
3131
  : h('div', { className: 'im-pause-row' },
3015
3132
  h('button', {
package/lib/index.js CHANGED
@@ -128,6 +128,10 @@ const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
128
128
  * duplicated tab left open for weeks) cannot grow the set without bound.
129
129
  */
130
130
  const MAX_SSE_STREAMS = 32
131
+ /** Idle keep-alive for event streams; well under the usual 60s proxy idle timeout. */
132
+ const SSE_HEARTBEAT_MS = 25 * 1000
133
+ /** An SSE comment line: every spec-compliant client ignores it. */
134
+ const SSE_PING_FRAME = ': ping\n\n'
131
135
  const HISTORY_DIR_NAME = 'history'
132
136
  /** Name of the JSON Schema shipped with the package and copied next to the store. */
133
137
  const SCHEMA_FILE_NAME = 'memory.schema.json'
@@ -2205,6 +2209,43 @@ export function apply(ctx) {
2205
2209
  * pushSnapshot).
2206
2210
  */
2207
2211
  const sseStreams = new Map()
2212
+ /**
2213
+ * Shared heartbeat over every live event stream, started with the first
2214
+ * subscriber and stopped with the last. Two jobs:
2215
+ *
2216
+ * - intermediaries (and some client stacks) close an idle stream; a comment
2217
+ * frame every 25s keeps it warm, and SSE clients are required to ignore
2218
+ * `:` lines, so an older panel sees no difference;
2219
+ * - it is the only place that can PROVE a subscriber stopped reading.
2220
+ * `res.write()` returning false means the socket buffer is full. Without
2221
+ * checking it, a crashed or sleeping client keeps its Map entry, socket and
2222
+ * file handle until TCP notices — minutes — and enough such zombies hold
2223
+ * MAX_SSE_STREAMS so that every later subscriber is refused with 503 until
2224
+ * the host is restarted.
2225
+ */
2226
+ let sseHeartbeat = null
2227
+ const stopHeartbeat = () => {
2228
+ if (sseHeartbeat === null) return
2229
+ clearInterval(sseHeartbeat)
2230
+ sseHeartbeat = null
2231
+ }
2232
+ const ensureHeartbeat = () => {
2233
+ if (sseHeartbeat !== null || disposed) return
2234
+ sseHeartbeat = setInterval(() => {
2235
+ if (disposed || sseStreams.size === 0) {
2236
+ stopHeartbeat()
2237
+ return
2238
+ }
2239
+ for (const [send, entry] of Array.from(sseStreams)) {
2240
+ try {
2241
+ if (send(SSE_PING_FRAME) === false) entry.cleanup()
2242
+ } catch {
2243
+ entry.cleanup()
2244
+ }
2245
+ }
2246
+ }, SSE_HEARTBEAT_MS)
2247
+ if (typeof sseHeartbeat.unref === 'function') sseHeartbeat.unref()
2248
+ }
2208
2249
  let storeWatcher = null
2209
2250
  let watchDebounce = null
2210
2251
  /**
@@ -2227,10 +2268,12 @@ export function apply(ctx) {
2227
2268
  for (const [send, entry] of Array.from(sseStreams)) {
2228
2269
  try {
2229
2270
  if (entry.lite === true) {
2230
- send(litePayload)
2271
+ // A false return means the socket buffer is full — this subscriber
2272
+ // stopped reading. Drop it now rather than queue frames in memory.
2273
+ if (send(litePayload) === false) entry.cleanup()
2231
2274
  } else {
2232
2275
  if (fullPayload === null) fullPayload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
2233
- send(fullPayload)
2276
+ if (send(fullPayload) === false) entry.cleanup()
2234
2277
  }
2235
2278
  } catch {
2236
2279
  // The response is dead: run its full teardown (end + unsubscribe),
@@ -2366,7 +2409,20 @@ export function apply(ctx) {
2366
2409
  // an external edit) but it must not happen silently: the downgrade is
2367
2410
  // invisible otherwise.
2368
2411
  logInfo('store watcher failed, falling back to the 5s poller: ' + describeError(err))
2369
- stopWatching()
2412
+ // Drop the WATCHER only. stopWatching() also clears the poller, which
2413
+ // is the one mechanism still able to notice an external edit after this
2414
+ // error — calling it here contradicted the log line above and left the
2415
+ // process with NO change detection at all until the next successful
2416
+ // save re-armed both. The poller is idempotent, so re-asserting it is a
2417
+ // no-op when it is already running.
2418
+ const dead = storeWatcher
2419
+ storeWatcher = null
2420
+ try {
2421
+ dead.close()
2422
+ } catch {
2423
+ // The OS side already dropped the handle.
2424
+ }
2425
+ startPolling()
2370
2426
  })
2371
2427
  } catch {
2372
2428
  storeWatcher = null
@@ -2378,6 +2434,7 @@ export function apply(ctx) {
2378
2434
  }
2379
2435
 
2380
2436
  const disposeStreams = () => {
2437
+ stopHeartbeat()
2381
2438
  for (const entry of Array.from(sseStreams.values())) {
2382
2439
  try {
2383
2440
  entry.cleanup()
@@ -2473,8 +2530,36 @@ export function apply(ctx) {
2473
2530
  }
2474
2531
  /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
2475
2532
  const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
2533
+ /**
2534
+ * The backup directory the WRITE path may use, or '' when the configured one
2535
+ * must be ignored.
2536
+ *
2537
+ * `setOptions` and `exportToDir` each check the subtree rule, but they are
2538
+ * not the only writers: every save also drops a `.bak` and a history
2539
+ * snapshot into this directory. A store hand-edited to point outside
2540
+ * `$DSH_HOME` therefore escaped the fence on the very next save, which
2541
+ * contradicted the promise that every write stays inside the harness home.
2542
+ * The same rule now runs where the writes actually happen; the refusal is
2543
+ * disclosed once per process instead of silently relocated.
2544
+ */
2545
+ let refusedBackupDir = null
2546
+ const effectiveBackupDir = () => {
2547
+ const configured = backupDirOf()
2548
+ if (configured === '') return ''
2549
+ if (allowExternalBackup() || isInsideDshRoot(configured)) return configured
2550
+ if (refusedBackupDir !== configured) {
2551
+ refusedBackupDir = configured
2552
+ state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
2553
+ + '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
2554
+ logInfo('ignoring backupDir outside the harness home: ' + configured)
2555
+ }
2556
+ return ''
2557
+ }
2476
2558
  /** The user-facing copy under a custom backup location, or null (F18). */
2477
- const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
2559
+ const customBackupPath = () => {
2560
+ const dir = effectiveBackupDir()
2561
+ return dir !== '' ? join(dir, basename(storePath) + BACKUP_FILE_SUFFIX) : null
2562
+ }
2478
2563
  /**
2479
2564
  * History snapshots live in a `history/` SUBDIRECTORY of the backup location
2480
2565
  * (default: the store directory). Two reasons for the subdirectory:
@@ -2485,7 +2570,10 @@ export function apply(ctx) {
2485
2570
  * 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
2486
2571
  * folder holds the history, nothing else is touched.
2487
2572
  */
2488
- const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
2573
+ const historyDirPath = () => {
2574
+ const dir = effectiveBackupDir()
2575
+ return join(dir !== '' ? dir : dirname(storePath), HISTORY_DIR_NAME)
2576
+ }
2489
2577
 
2490
2578
  /**
2491
2579
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
@@ -2841,7 +2929,19 @@ export function apply(ctx) {
2841
2929
  // monotonic while the CONTENT of their save was dropped without an error
2842
2930
  // — "rev 3" looked healthy and the entry was simply gone. Refuse instead,
2843
2931
  // so the caller retries against the newer store.
2844
- const diskRevNow = await readDiskRev()
2932
+ //
2933
+ // ONE read serves both this check and the ".bak" shape guard further
2934
+ // down: the previous shape read the same file three times inside the lock
2935
+ // (twice just to obtain the rev) while another DSH process could already
2936
+ // be waiting on that very lock. `diskReadError` preserves the exact
2937
+ // failure the later guard would have raised itself, so its ENOENT / empty
2938
+ // / not-a-store branches keep their current behaviour.
2939
+ let diskReadError = null
2940
+ const diskSnapshot = await readStoreFile(storePath).catch((err) => {
2941
+ diskReadError = err === null || err === undefined ? new Error('unreadable store') : err
2942
+ return undefined
2943
+ })
2944
+ const diskRevNow = diskRevFrom(diskSnapshot)
2845
2945
  if (diskRevNow > (state.data.rev || 0)) {
2846
2946
  state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRevNow
2847
2947
  + ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
@@ -2870,8 +2970,9 @@ export function apply(ctx) {
2870
2970
  // ACROSS processes: two windows that loaded rev 5 would otherwise both
2871
2971
  // write rev 6, and anything that watches the rev for change (the lite
2872
2972
  // SSE stream) cannot tell the second save from a repeat of the first.
2873
- const diskRev = await readDiskRev()
2874
- previousRev = Math.max(state.data.rev || 0, diskRev)
2973
+ // Same read as the check above: the lock is ours, so the file cannot have
2974
+ // moved in between.
2975
+ previousRev = Math.max(state.data.rev || 0, diskRevNow)
2875
2976
  state.data.rev = previousRev + 1
2876
2977
  const text = serialize()
2877
2978
  // mode 0600: the store holds the user's private instructions and (with a
@@ -2901,7 +3002,11 @@ export function apply(ctx) {
2901
3002
  // discloses it (the .bak may be stale, and the panel says so).
2902
3003
  let hadPrevious = true
2903
3004
  try {
2904
- const previous = await readStoreFile(storePath)
3005
+ // Reuse the read taken above instead of touching the file a third
3006
+ // time. The captured failure is rethrown so every branch of the catch
3007
+ // below keeps exactly its current meaning.
3008
+ if (diskReadError !== null) throw diskReadError
3009
+ const previous = diskSnapshot
2905
3010
  // "Reads as a store", not merely "reads as JSON" — this is the test the
2906
3011
  // comment above promises. A foreign-but-parseable primary (an array, a
2907
3012
  // scalar, another tool's object) must NOT be copied over the last good
@@ -3027,11 +3132,20 @@ export function apply(ctx) {
3027
3132
  * then silently misses the second save; and (b) deciding whether a save made
3028
3133
  * while another process holds the lock would clobber a newer store.
3029
3134
  */
3135
+ /**
3136
+ * The rev a PARSED store carries, or 0 when it carries none usable. Split out
3137
+ * so a caller that already read the file — writeToDisk does, once, inside the
3138
+ * lock — can derive the same number without a second read. Called at request
3139
+ * time, long after apply() finished defining it.
3140
+ */
3141
+ const diskRevFrom = (parsed) => {
3142
+ const rev = parsed !== null && parsed !== undefined && typeof parsed === 'object' ? parsed.rev : null
3143
+ return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
3144
+ }
3145
+
3030
3146
  const readDiskRev = async () => {
3031
3147
  try {
3032
- const parsed = await readStoreFile(storePath)
3033
- const rev = parsed !== null && typeof parsed === 'object' ? parsed.rev : null
3034
- return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
3148
+ return diskRevFrom(await readStoreFile(storePath))
3035
3149
  } catch {
3036
3150
  return 0
3037
3151
  }
@@ -3080,7 +3194,13 @@ export function apply(ctx) {
3080
3194
  const rejectVersion = (version, source) => {
3081
3195
  state.versionRejected = true
3082
3196
  state.rejectedVersion = version
3083
- state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3197
+ // A string that spells a supported number ("2") produced the absurd
3198
+ // "declared version 2, supported versions 1 / 2". The TYPE is the
3199
+ // problem, so say that instead of comparing it to the list.
3200
+ state.storageError = (typeof version === 'number'
3201
+ ? '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3202
+ : '存储文件的 version 字段类型不正确:必须是数字,当前是 ' + JSON.stringify(version === undefined ? null : version)
3203
+ + ';本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / '))
3084
3204
  + ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
3085
3205
  state.loadedFrom = source + ' (unsupported version ' + version + ')'
3086
3206
  state.storeExists = true
@@ -3478,6 +3598,13 @@ export function apply(ctx) {
3478
3598
  * to say which of the two situations it is looking at.
3479
3599
  */
3480
3600
  authFence: connectionGate !== null,
3601
+ /**
3602
+ * How many hostnames DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS widened the
3603
+ * Host/Origin check to. The list relaxes a HOSTNAME rule; it is not an
3604
+ * identity check, so the panel must be able to say when a widened
3605
+ * allow-list is the only thing standing in front of a fence-less route.
3606
+ */
3607
+ trustedHostCount: trustedHosts.length,
3481
3608
  },
3482
3609
  /**
3483
3610
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3852,7 +3979,7 @@ export function apply(ctx) {
3852
3979
  return {
3853
3980
  ok: false,
3854
3981
  saved: false,
3855
- message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
3982
+ message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
3856
3983
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
3857
3984
  snapshot: snapshot(),
3858
3985
  }
@@ -4486,7 +4613,7 @@ export function apply(ctx) {
4486
4613
  return {
4487
4614
  ok: false,
4488
4615
  saved: false,
4489
- message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
4616
+ message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
4490
4617
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4491
4618
  snapshot: snapshot(),
4492
4619
  }
@@ -5237,7 +5364,9 @@ export function apply(ctx) {
5237
5364
  // restarted) instead of hammering the route.
5238
5365
  res.write('retry: 5000\n\n')
5239
5366
  const lite = isLiteStreamRequest(req)
5240
- const send = (payload) => { res.write(payload) }
5367
+ // The write() result is the backpressure signal that pushSnapshot and the
5368
+ // heartbeat act on: false means the peer stopped reading this stream.
5369
+ const send = (payload) => res.write(payload)
5241
5370
  let closed = false
5242
5371
  const cleanup = () => {
5243
5372
  closed = true
@@ -5249,6 +5378,9 @@ export function apply(ctx) {
5249
5378
  }
5250
5379
  }
5251
5380
  sseStreams.set(send, { cleanup, lite })
5381
+ // Start the shared keep-alive lazily: a host with no open panel pays
5382
+ // nothing, and the timer stops itself when the last stream closes.
5383
+ ensureHeartbeat()
5252
5384
  req.on('close', cleanup)
5253
5385
  req.on('error', cleanup)
5254
5386
  res.on('error', cleanup)
@@ -5306,9 +5438,16 @@ export function apply(ctx) {
5306
5438
  logInfo('connection fence threw, refusing the request: ' + describeError(err))
5307
5439
  rejection = 401
5308
5440
  }
5309
- if (typeof rejection === 'number' && rejection >= 400) {
5310
- logInfo('route refused an unauthenticated request (' + rejection + ')')
5311
- res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5441
+ // Only the documented "admit" values pass. Anything else — a future
5442
+ // `{status:401}`, an Error, a string — is treated as a refusal, because
5443
+ // an authentication gate that silently opens on an unexpected shape is
5444
+ // not a gate. The numeric status is preserved when it is plausible.
5445
+ const admitted = rejection === undefined || rejection === null || rejection === false
5446
+ if (!admitted) {
5447
+ const status = typeof rejection === 'number' && rejection >= 400 && rejection <= 599 ? rejection : 401
5448
+ logInfo('route refused a request (' + status + ', fence returned '
5449
+ + (typeof rejection === 'number' ? rejection : typeof rejection) + ')')
5450
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5312
5451
  res.end(JSON.stringify({
5313
5452
  ok: false,
5314
5453
  message: rejection === 403
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
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",