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 +49 -0
- package/lib/client.js +171 -58
- package/lib/index.js +99 -11
- package/package.json +1 -1
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
|
|
1182
|
-
//
|
|
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)
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
2737
|
-
//
|
|
2738
|
-
const
|
|
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 =
|
|
2813
|
-
const sim =
|
|
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
|
-
|
|
2831
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2905
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|