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 +76 -0
- package/lib/client.js +171 -58
- package/lib/index.js +108 -15
- package/package.json +1 -1
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
|
|
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,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
|
-
|
|
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
|
-
|
|
2905
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|