dsh-instruction-memory 1.5.1 → 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,55 @@
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
+
10
59
  ## [1.5.1] — 2026-11-05
11
60
 
12
61
  针对 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,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()
@@ -2872,7 +2929,19 @@ export function apply(ctx) {
2872
2929
  // monotonic while the CONTENT of their save was dropped without an error
2873
2930
  // — "rev 3" looked healthy and the entry was simply gone. Refuse instead,
2874
2931
  // so the caller retries against the newer store.
2875
- 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)
2876
2945
  if (diskRevNow > (state.data.rev || 0)) {
2877
2946
  state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRevNow
2878
2947
  + ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
@@ -2901,8 +2970,9 @@ export function apply(ctx) {
2901
2970
  // ACROSS processes: two windows that loaded rev 5 would otherwise both
2902
2971
  // write rev 6, and anything that watches the rev for change (the lite
2903
2972
  // 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)
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)
2906
2976
  state.data.rev = previousRev + 1
2907
2977
  const text = serialize()
2908
2978
  // mode 0600: the store holds the user's private instructions and (with a
@@ -2932,7 +3002,11 @@ export function apply(ctx) {
2932
3002
  // discloses it (the .bak may be stale, and the panel says so).
2933
3003
  let hadPrevious = true
2934
3004
  try {
2935
- 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
2936
3010
  // "Reads as a store", not merely "reads as JSON" — this is the test the
2937
3011
  // comment above promises. A foreign-but-parseable primary (an array, a
2938
3012
  // scalar, another tool's object) must NOT be copied over the last good
@@ -3058,11 +3132,20 @@ export function apply(ctx) {
3058
3132
  * then silently misses the second save; and (b) deciding whether a save made
3059
3133
  * while another process holds the lock would clobber a newer store.
3060
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
+
3061
3146
  const readDiskRev = async () => {
3062
3147
  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
3148
+ return diskRevFrom(await readStoreFile(storePath))
3066
3149
  } catch {
3067
3150
  return 0
3068
3151
  }
@@ -5281,7 +5364,9 @@ export function apply(ctx) {
5281
5364
  // restarted) instead of hammering the route.
5282
5365
  res.write('retry: 5000\n\n')
5283
5366
  const lite = isLiteStreamRequest(req)
5284
- 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)
5285
5370
  let closed = false
5286
5371
  const cleanup = () => {
5287
5372
  closed = true
@@ -5293,6 +5378,9 @@ export function apply(ctx) {
5293
5378
  }
5294
5379
  }
5295
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()
5296
5384
  req.on('close', cleanup)
5297
5385
  req.on('error', cleanup)
5298
5386
  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.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",