dsh-instruction-memory 1.5.1 → 1.5.3

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,82 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.3] — 2026-11-09
11
+
12
+ 1.5.2 的发布后修正,两处都是当天发现:一处**判断依据用错**,一处**依赖了初始化顺序**。没有新功能,
13
+ 存储格式、注入文本与面板行为不变。
14
+
15
+ ### 修复
16
+
17
+ - **背压判断把健康但读得慢的订阅者当成死连接(1.5.2 引入)**:1.5.2 让 `send()` 返回 `res.write()`
18
+ 的结果,并在返回 `false` 时立刻 `cleanup()`。但 `write()` 的 `false` 只表示**这一次写入超过了
19
+ socket 缓冲的高水位**——一个健康、但此刻读得慢的客户端同样会拿到 `false`。于是一个大帧(极限存储
20
+ 约 3.4 MB)就可能把正常订阅者踢下线。现在返回值不再用作存活判据:心跳只负责让"写失败"这条路走通
21
+ ——对端真的消失时,写入会报错并触发该流早已注册的 `close`/`error` 清理;`pushSnapshot` 恢复
22
+ 1.5.1 的语义。
23
+ - **`diskRevFrom()` 的声明顺序依赖**:1.5.2 把它定义在 `writeToDisk()` 之后。`apply()` 是同步函数,
24
+ 通常情况下没有问题;但"缺主文件时从 `.bak` 恢复"这条路径会在 boot 序列里调用 `writeToDisk()`,
25
+ 那是一次**可能先于声明执行**的调用,而 `const` 会落在暂时性死区并抛 `ReferenceError`(保存因此
26
+ 失败、`storageError` 报"保存失败")。现在两个 helper 提到 `writeToDisk()` 之前,用顺序消除这个
27
+ 假设,而不是依赖"今天恰好先 await"。
28
+
29
+ ### 验收
30
+
31
+ - 全套 7 个套件与 `node check-metadata.mjs` 全绿;`contract-test.mjs` 连续独立运行 5 次均 ALL PASS。
32
+ - 1.5.2 的 CI 在 `windows-latest / Node 24` 上失败了 2 条 SSE 断言:
33
+ `sse: a writer that throws on broadcast is fully disposed (response ended)` 与
34
+ `sse: disposing the dead writer does not interrupt a healthy stream`。本版按上面的修正重新验证这两个
35
+ 断言所在的路径(死连接清理 + 健康流不受牵连)。
36
+
37
+ ## [1.5.2] — 2026-11-09
38
+
39
+ 针对 2026-11-09 的代码审查(Host 侧与 Client 侧并行只读审查,逐条复核后落地)。本版**没有新增功能、
40
+ 没有格式变更**:存储文件、注入文本与面板行为在正常路径上与 1.5.1 逐字节一致,改的是一条静默失效的
41
+ 降级路径、一条失效的乱序保护,以及几处可以量化的重复 I/O 与空转渲染。
42
+
43
+ ### 修复
44
+
45
+ - **watcher 降级把轮询器一起杀掉了(P0)**:`storeWatcher.on('error')` 的日志写的是「falling back to
46
+ the 5s poller」,紧接着却调用 `stopWatching()` —— 而该函数同时 `clearInterval(storePoll)`。于是
47
+ `fs.watch` 一旦报错(被监视目录被删或 rename、Windows 目录句柄失效、inotify 句柄耗尽),**watcher
48
+ 与轮询器双双消失**,外部改动检测彻底静默,直到本进程下一次保存成功才重新装回。期间手改
49
+ `memory.json`、或另一个 DSH 进程保存,本窗口会一直显示旧数据,而面板没有任何提示。现在错误分支
50
+ 只关闭 watcher 并显式 `startPolling()`(幂等),注释所承诺的降级才真的发生。
51
+ - **SSE 乱序快照保护在推送路径上失效(P1)**:`absorb()` 用渲染作用域的 `view` 判定「到来的快照是否
52
+ 比已在屏上的更旧」,但 SSE effect 的依赖是空数组,它捕获的是**首渲染**那份 `absorb`,其闭包里的
53
+ `view` 恒为 `null`,`currentRev` 永远是 `undefined` —— 于是注释点名的「SSE 触发刷新与 POST 回包
54
+ 竞争」恰恰是唯一没被保护的那条路径。现在改读 `viewRef.current`(面板一直在维护它)。
55
+
56
+ ### 性能与加固(不改变外部行为)
57
+
58
+ - **一次保存不再整读同一个文件三次(P1)**:`writeToDisk()` 在跨进程锁内依次 `readDiskRev()`、
59
+ 再一次 `readDiskRev()`、再 `readStoreFile()`,只为拿磁盘 rev(两次结果必然相同,锁在自己手上)
60
+ 与 `.bak` 的形状判定。现在一次读取同时供两处使用,`diskReadError` 保留原有 `ENOENT` / 空文件 /
61
+ 非本插件存储三条分支的语义。极限存储(200 条 × 6000 字 ≈ 3.4 MB)下每次保存少约 10 MB 读取与
62
+ 2 次整文件 `JSON.parse`,并缩短了锁的持有时间。
63
+ - **事件流加上心跳与背压(P2)**:原来的 `send` 丢弃 `res.write()` 的返回值,清理只依赖
64
+ `close`/`error` 事件。客户端崩溃或休眠(没有 FIN)时条目不会被移除,积满 `MAX_SSE_STREAMS=32`
65
+ 后所有新订阅者都被 503 拒绝,面板实时同步失效直到重启宿主;慢客户端还会让极限 3.4 MB 的全量帧
66
+ 在 socket 缓冲里无界堆积。现在 25 秒共享心跳(`: ping` 注释帧,SSE 客户端按规范忽略)逐个探测,
67
+ `write()` 返回 `false` 即清理该流;`pushSnapshot` 同样按返回值清理。
68
+ - **暂停倒计时不再空转重渲染整个面板(P2)**:`nowTick` 每秒写一次 state,但全文没有任何地方读取它
69
+ (剩余时间在渲染时用 `Date.now()` 现算)——每秒重跑并 diff 约 3000 个 vnode,只为让一行文字变化。
70
+ 现在倒计时抽成 `MuteCountdown` 子组件,自带 1s tick,且只在归零时回调一次(SSE 已断、宿主不再推
71
+ 快照时,暂停条也能自行退场)。
72
+ - **列表派生计算记忆化(P2)**:排序、标签索引、筛选、关键词扫描与「按需预演」打分原先全部裸写在
73
+ 渲染体里,每次按键或勾选一条都要重排 200 条并对全部正文重复小写化。现在收进 `deriveListState()`
74
+ 并由 `useStableMemo()` 按(快照, UI 状态)缓存;预演只在展开时才打分(收起后不再扫全库)。
75
+ `useStableMemo` 建在 `useRef` 上而不是 `useMemo` 上,因为 ui-test 里那套迷你 React 只实现了
76
+ `useState`/`useEffect`/`useRef` —— 这样测试与生产走同一条代码路径,而不是为了测试加分支。
77
+
78
+ ### 验收
79
+
80
+ - 全套 7 个套件(lint / host-range / smoke / contract / verify / feedback / ui)与
81
+ `node check-metadata.mjs` 全绿;`lib/` 13 个文件全部 PARSE 且无宿主包名导入。
82
+ - **未做的验证(明确记录)**:watcher 故障注入没有做成真机测试——`storeWatcher` 是闭包内变量,
83
+ 无法从外部触发 `error`,Windows 上删除被监视目录只会产生 `rename` 事件。第一条的保证来自
84
+ 「`stopWatching()` 现在只剩插件卸载一处调用点」这一结构性证明,不是行为测试。
85
+
10
86
  ## [1.5.1] — 2026-11-05
11
87
 
12
88
  针对 2026-11-05 桌面端 / 网页端复核(架构 / 安全 / 验收三方)的 P1–P3 逐项修复。运行时在
package/lib/client.js CHANGED
@@ -1178,8 +1178,8 @@ window.__ModuleLoader__.load({
1178
1178
  if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
1179
1179
  const data = snapshot.data
1180
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.
1181
+ // Every consumer reads data.entries and deriveListState() slices it; a
1182
+ // non-array threw mid-render instead of surfacing a readable error.
1183
1183
  if (!Array.isArray(data.entries)) return null
1184
1184
  return {
1185
1185
  snapshot,
@@ -1469,6 +1469,127 @@ window.__ModuleLoader__.load({
1469
1469
  return { terms, results, autoTotal }
1470
1470
  }
1471
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
+
1472
1593
  /* ---------------- F16: source-mode validation ---------------- */
1473
1594
 
1474
1595
  /**
@@ -1888,12 +2009,13 @@ window.__ModuleLoader__.load({
1888
2009
  const [feedbackBusy, setFeedbackBusy] = React.useState(false)
1889
2010
  const [feedbackError, setFeedbackError] = React.useState('')
1890
2011
  const [feedbackSent, setFeedbackSent] = React.useState('')
1891
- // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1892
- // ONLY while a pause is live (its effect cleans up after itself). The
1893
- // tick drives nothing but the remaining-time text; authority stays with
1894
- // 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.
1895
2015
  const [pauseMenuOpen, setPauseMenuOpen] = React.useState(false)
1896
- 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)
1897
2019
  // F18: the backup-location draft (focused-input protection like budget).
1898
2020
  const [backupText, setBackupText] = React.useState('')
1899
2021
  const backupTextRef = React.useRef('')
@@ -1947,14 +2069,33 @@ window.__ModuleLoader__.load({
1947
2069
  const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1948
2070
  && view.injection.muted === true
1949
2071
  React.useEffect(() => {
1950
- if (!mutedNow) return undefined
2072
+ if (!mutedNow) return
1951
2073
  // A pause started from ANOTHER window replaces the menu row; the open
1952
2074
  // flag must not resurface when the row returns after expiry.
1953
2075
  setPauseMenuOpen(false)
1954
- const tick = setInterval(() => setNowTick(Date.now()), 1000)
1955
- return () => clearInterval(tick)
1956
2076
  }, [mutedNow])
1957
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
+
1958
2099
  /**
1959
2100
  * F16 live validation, debounced: parsing on every keystroke would stall
1960
2101
  * the panel on a multi-megabyte paste, and 250 ms is below the threshold
@@ -1988,7 +2129,13 @@ window.__ModuleLoader__.load({
1988
2129
  && typeof snapshot.data.rev === 'number'
1989
2130
  ? snapshot.data.rev
1990
2131
  : undefined)
1991
- 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)
1992
2139
 
1993
2140
  function absorb(result) {
1994
2141
  const normalized = normalizeResult(result)
@@ -2733,23 +2880,9 @@ window.__ModuleLoader__.load({
2733
2880
  ? view.security
2734
2881
  : { hostGuarded: true, boundHost: '' }
2735
2882
  // List order is display-only and never changes what the Host injects
2736
- // (the injection order is decided host-side). The default mirrors the
2737
- // previous fixed order; updated-time and title are the other sorts.
2738
- const sortEntries = (list) => {
2739
- const copy = list.slice()
2740
- if (sortKey === 'updated') {
2741
- copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
2742
- } else if (sortKey === 'title') {
2743
- copy.sort((a, b) =>
2744
- a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
2745
- || (b.priority - a.priority)
2746
- || (b.updatedAt - a.updatedAt))
2747
- } else {
2748
- copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
2749
- }
2750
- return copy
2751
- }
2752
- 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
2753
2886
  const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
2754
2887
  // Persona profiles, in stored order (stable injection order). Old hosts
2755
2888
  // simply have no array.
@@ -2762,17 +2895,7 @@ window.__ModuleLoader__.load({
2762
2895
  : profiles.reduce((n, p) => n + (p.enabled === true && p.content ? p.name.length + p.content.length + 5 : 0), 0)
2763
2896
  // Every tag actually in use, most frequent first (then name), for the
2764
2897
  // tag filter row.
2765
- const allTags = (() => {
2766
- const counts = new Map()
2767
- for (const entry of data.entries) {
2768
- for (const tag of Array.isArray(entry.tags) ? entry.tags : []) {
2769
- if (typeof tag === 'string' && tag !== '') counts.set(tag, (counts.get(tag) || 0) + 1)
2770
- }
2771
- }
2772
- return [...counts.entries()]
2773
- .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
2774
- .map(([tag]) => tag)
2775
- })()
2898
+ const allTags = derivedList.allTags
2776
2899
  // Budget usage + per-entry marks straight from the Host's injection plan.
2777
2900
  // Older hosts simply publish none of these fields: the meter falls back
2778
2901
  // to data.budgetChars and the maps stay empty.
@@ -2809,8 +2932,8 @@ window.__ModuleLoader__.load({
2809
2932
  // F17: the local preview scores the ACTIVE set's on-demand entries.
2810
2933
  // Disabled entries can never be injected, so scoring them as 预演 hits
2811
2934
  // would mislead — they are excluded from the preview entirely.
2812
- const autoEntries = data.entries.filter((entry) => entry.mode === 'auto' && entry.enabled !== false)
2813
- const sim = relevancePreview(autoEntries, simQuery)
2935
+ const autoEntries = derivedList.autoEntries
2936
+ const sim = derivedList.sim
2814
2937
  const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
2815
2938
  const omittedById = new Map()
2816
2939
  const truncatedById = new Map()
@@ -2827,22 +2950,8 @@ window.__ModuleLoader__.load({
2827
2950
  }
2828
2951
  const keyword = query.trim().toLowerCase()
2829
2952
  const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
2830
- const matchesFilters = (entry) => {
2831
- if (tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(tagFilter))) return false
2832
- if (modeFilter !== '' && entry.mode !== modeFilter) return false
2833
- if (statusFilter === 'enabled' && entry.enabled !== true) return false
2834
- if (statusFilter === 'disabled' && entry.enabled !== false) return false
2835
- return true
2836
- }
2837
- const visible = entries.filter((entry) => {
2838
- if (!matchesFilters(entry)) return false
2839
- if (keyword === '') return true
2840
- const inTags = (Array.isArray(entry.tags) ? entry.tags.join(' ') : '').toLowerCase()
2841
- return entry.title.toLowerCase().indexOf(keyword) !== -1
2842
- || entry.content.toLowerCase().indexOf(keyword) !== -1
2843
- || entry.when.toLowerCase().indexOf(keyword) !== -1
2844
- || inTags.indexOf(keyword) !== -1
2845
- })
2953
+ // Filters and keyword were applied in one pass inside deriveListState.
2954
+ const visible = derivedList.visible
2846
2955
 
2847
2956
  const dotClass = injection.error
2848
2957
  ? 'im-dot im-dot-err'
@@ -3013,7 +3122,11 @@ window.__ModuleLoader__.load({
3013
3122
  // 自动恢复,无需用户操作、不写盘。倒计时只是展示,权威在快照。
3014
3123
  muted && muteRemainingMs > 0
3015
3124
  ? h('div', { className: 'im-pause-bar' },
3016
- 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
+ }),
3017
3130
  h('button', { className: 'im-btn', disabled: busy, onClick: resumePause }, t('mute.resume')))
3018
3131
  : h('div', { className: 'im-pause-row' },
3019
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,49 @@ 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
+ // The RETURN VALUE is deliberately not read as a liveness signal:
2242
+ // res.write() answers false when THIS write exceeded the socket's
2243
+ // high-water mark, which a healthy but momentarily slow reader
2244
+ // produces too — killing on it tore down live streams. A dead peer
2245
+ // is detected the reliable way instead: the write fails, which fires
2246
+ // the close/error handler this stream has already registered.
2247
+ send(SSE_PING_FRAME)
2248
+ } catch {
2249
+ entry.cleanup()
2250
+ }
2251
+ }
2252
+ }, SSE_HEARTBEAT_MS)
2253
+ if (typeof sseHeartbeat.unref === 'function') sseHeartbeat.unref()
2254
+ }
2208
2255
  let storeWatcher = null
2209
2256
  let watchDebounce = null
2210
2257
  /**
@@ -2366,7 +2413,20 @@ export function apply(ctx) {
2366
2413
  // an external edit) but it must not happen silently: the downgrade is
2367
2414
  // invisible otherwise.
2368
2415
  logInfo('store watcher failed, falling back to the 5s poller: ' + describeError(err))
2369
- stopWatching()
2416
+ // Drop the WATCHER only. stopWatching() also clears the poller, which
2417
+ // is the one mechanism still able to notice an external edit after this
2418
+ // error — calling it here contradicted the log line above and left the
2419
+ // process with NO change detection at all until the next successful
2420
+ // save re-armed both. The poller is idempotent, so re-asserting it is a
2421
+ // no-op when it is already running.
2422
+ const dead = storeWatcher
2423
+ storeWatcher = null
2424
+ try {
2425
+ dead.close()
2426
+ } catch {
2427
+ // The OS side already dropped the handle.
2428
+ }
2429
+ startPolling()
2370
2430
  })
2371
2431
  } catch {
2372
2432
  storeWatcher = null
@@ -2378,6 +2438,7 @@ export function apply(ctx) {
2378
2438
  }
2379
2439
 
2380
2440
  const disposeStreams = () => {
2441
+ stopHeartbeat()
2381
2442
  for (const entry of Array.from(sseStreams.values())) {
2382
2443
  try {
2383
2444
  entry.cleanup()
@@ -2392,6 +2453,28 @@ export function apply(ctx) {
2392
2453
 
2393
2454
  const storePath = resolveStorePath()
2394
2455
 
2456
+ /**
2457
+ * The rev a PARSED store carries, or 0 when it carries none usable.
2458
+ *
2459
+ * Defined BEFORE writeToDisk on purpose: the missing-primary recovery path
2460
+ * calls writeToDisk from the boot sequence, and a `const` declared later in
2461
+ * apply() would still be in its temporal dead zone if that write ever ran
2462
+ * before apply() reached the declaration. Ordering it here removes the
2463
+ * question instead of relying on the await that happens to come first today.
2464
+ */
2465
+ const diskRevFrom = (parsed) => {
2466
+ const rev = parsed !== null && parsed !== undefined && typeof parsed === 'object' ? parsed.rev : null
2467
+ return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
2468
+ }
2469
+
2470
+ const readDiskRev = async () => {
2471
+ try {
2472
+ return diskRevFrom(await readStoreFile(storePath))
2473
+ } catch {
2474
+ return 0
2475
+ }
2476
+ }
2477
+
2395
2478
  /**
2396
2479
  * The exact object written to memory.json — and the one an export carries.
2397
2480
  *
@@ -2872,7 +2955,19 @@ export function apply(ctx) {
2872
2955
  // monotonic while the CONTENT of their save was dropped without an error
2873
2956
  // — "rev 3" looked healthy and the entry was simply gone. Refuse instead,
2874
2957
  // so the caller retries against the newer store.
2875
- const diskRevNow = await readDiskRev()
2958
+ //
2959
+ // ONE read serves both this check and the ".bak" shape guard further
2960
+ // down: the previous shape read the same file three times inside the lock
2961
+ // (twice just to obtain the rev) while another DSH process could already
2962
+ // be waiting on that very lock. `diskReadError` preserves the exact
2963
+ // failure the later guard would have raised itself, so its ENOENT / empty
2964
+ // / not-a-store branches keep their current behaviour.
2965
+ let diskReadError = null
2966
+ const diskSnapshot = await readStoreFile(storePath).catch((err) => {
2967
+ diskReadError = err === null || err === undefined ? new Error('unreadable store') : err
2968
+ return undefined
2969
+ })
2970
+ const diskRevNow = diskRevFrom(diskSnapshot)
2876
2971
  if (diskRevNow > (state.data.rev || 0)) {
2877
2972
  state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRevNow
2878
2973
  + ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
@@ -2901,8 +2996,9 @@ export function apply(ctx) {
2901
2996
  // ACROSS processes: two windows that loaded rev 5 would otherwise both
2902
2997
  // write rev 6, and anything that watches the rev for change (the lite
2903
2998
  // SSE stream) cannot tell the second save from a repeat of the first.
2904
- const diskRev = await readDiskRev()
2905
- previousRev = Math.max(state.data.rev || 0, diskRev)
2999
+ // Same read as the check above: the lock is ours, so the file cannot have
3000
+ // moved in between.
3001
+ previousRev = Math.max(state.data.rev || 0, diskRevNow)
2906
3002
  state.data.rev = previousRev + 1
2907
3003
  const text = serialize()
2908
3004
  // mode 0600: the store holds the user's private instructions and (with a
@@ -2932,7 +3028,11 @@ export function apply(ctx) {
2932
3028
  // discloses it (the .bak may be stale, and the panel says so).
2933
3029
  let hadPrevious = true
2934
3030
  try {
2935
- const previous = await readStoreFile(storePath)
3031
+ // Reuse the read taken above instead of touching the file a third
3032
+ // time. The captured failure is rethrown so every branch of the catch
3033
+ // below keeps exactly its current meaning.
3034
+ if (diskReadError !== null) throw diskReadError
3035
+ const previous = diskSnapshot
2936
3036
  // "Reads as a store", not merely "reads as JSON" — this is the test the
2937
3037
  // comment above promises. A foreign-but-parseable primary (an array, a
2938
3038
  // scalar, another tool's object) must NOT be copied over the last good
@@ -3058,16 +3158,6 @@ export function apply(ctx) {
3058
3158
  * then silently misses the second save; and (b) deciding whether a save made
3059
3159
  * while another process holds the lock would clobber a newer store.
3060
3160
  */
3061
- const readDiskRev = async () => {
3062
- try {
3063
- const parsed = await readStoreFile(storePath)
3064
- const rev = parsed !== null && typeof parsed === 'object' ? parsed.rev : null
3065
- return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
3066
- } catch {
3067
- return 0
3068
- }
3069
- }
3070
-
3071
3161
  /**
3072
3162
  * Load the store from disk. Most branches only read; two of them also write —
3073
3163
  * the missing-primary recovery promotes the backup to memory.json, and the
@@ -5293,6 +5383,9 @@ export function apply(ctx) {
5293
5383
  }
5294
5384
  }
5295
5385
  sseStreams.set(send, { cleanup, lite })
5386
+ // Start the shared keep-alive lazily: a host with no open panel pays
5387
+ // nothing, and the timer stops itself when the last stream closes.
5388
+ ensureHeartbeat()
5296
5389
  req.on('close', cleanup)
5297
5390
  req.on('error', cleanup)
5298
5391
  res.on('error', cleanup)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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",