dsh-instruction-memory 1.3.1 → 1.3.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 +40 -1
- package/lib/client.js +27 -11
- package/lib/index.js +22 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.3.2] — 2026-10-16
|
|
11
|
+
|
|
12
|
+
1.3.1 发布后的两轮全量体检产出:CI 测试基建根治、client/demo 边界修复与宿主生命周期加固。
|
|
13
|
+
|
|
14
|
+
### 修复
|
|
15
|
+
|
|
16
|
+
- **CI 测试基建:路由等待按墙钟计时**:contract/ui 的 rawRoute 原以固定迭代数轮询
|
|
17
|
+
(约 2s 上限),高负载 runner 上真实磁盘写路径(锁、双备份、rename、历史快照)可能偶发
|
|
18
|
+
超限,导致整套测试崩溃(`route never responded`),且崩溃无断言痕迹。CI 偶发红
|
|
19
|
+
(1.2.0 时代即有先例)的根因即此。现改为墙钟 15s 上限,并给每个套件装崩溃钩子
|
|
20
|
+
(失败时输出 GitHub `::error::` 注解,含断言与堆栈),verify 的固定 60ms boot 等待改为
|
|
21
|
+
轮询到条件成立。仅测试基建,不影响已发布包内容。
|
|
22
|
+
- **CI 假绿窗口收口**:套件尾部 `process.exit(failures===0?0:1)` 会覆盖崩溃钩子设置的
|
|
23
|
+
exitCode——最后一次校验之后的浮游 rejection 可能被吞成「ALL PASS + exit 0」。现在退出码
|
|
24
|
+
尊重钩子状态;ui-test 的 scratch 清理移到退出码捕获之前;fetch 包装器在路由超时抛出时
|
|
25
|
+
递减 inFlight(否则后续每次 settle 都烧满等待窗口)。
|
|
26
|
+
- **测试 scratch 目录自清理**:contract/verify/ui 现在都注册按 PID 的退出钩子,任何退出路径
|
|
27
|
+
(崩溃、抛错、失败)都删掉本进程的家目录;此前每次跑测试都会在仓库里留下
|
|
28
|
+
`.test-dsh-home-*` 目录(实测累积到 39 个)。仅测试基建。
|
|
29
|
+
- **verify 三条恒真断言修正**:F17 相关性分词的三个 check 把比较器写在了 detail 位、
|
|
30
|
+
ok 位是恒真值,实际从未断言(c74bfcc 引入的既有问题,本轮审阅发现);改为真布尔断言。
|
|
31
|
+
- **demo 桩 `sets.total` 计数错误**:scoped 数组覆盖克隆后才读计数,「全部配置集合计」显示的是
|
|
32
|
+
当前集合数而非全库数;改为覆盖前从原库统计。
|
|
33
|
+
- **client:`data:null` 快照穿透 normalizer**:absorb 读取 `data.budgetChars` 抛错,初始加载
|
|
34
|
+
会永久卡在加载态且无错误提示(当前宿主不产生该形状,防御性收口,verify 钉住)。
|
|
35
|
+
- **client:重进源码模式不再静默丢弃未保存草稿**:源码草稿与最近一次读/存的基线比对,有改动
|
|
36
|
+
走标准弃用确认(基线随读取与保存推进)。
|
|
37
|
+
- **client:F17 本地预演不再给已停用条目标注命中**(它们永远不会被注入,此前会误导)。
|
|
38
|
+
- **host 生命周期三处加固**:初始 `state.data` 字面量补齐 `disabledUntil` 键(watcher 自写
|
|
39
|
+
检测在新进程首次保存时因键形状不一致必然失配,产生一次多余重载与冗余 SSE 帧,探针实证);
|
|
40
|
+
卸载栅栏(in-flight 操作完成后不再重注册注入段/重启 watcher/推送快照);`state` 路由的
|
|
41
|
+
首次物化纳入磁盘队列(与 readSource 一致)。
|
|
42
|
+
|
|
43
|
+
### 变更
|
|
44
|
+
|
|
45
|
+
- **暂停倒计时改用渲染时时钟**:剩余时间在渲染时直接取 `Date.now()` 计算,1 秒 interval
|
|
46
|
+
仅作重渲染触发器。此前 nowTick 在两次暂停之间可能陈旧任意久(如隔天再暂停),首帧
|
|
47
|
+
剩余时间会虚大;原描述「最多偏大 1 秒」低估了陈旧量。仅展示层。
|
|
48
|
+
|
|
10
49
|
## [1.3.1] — 2026-10-15
|
|
11
50
|
|
|
12
51
|
1.3.0 发布当天的评审跟进:一个披露计数回归与三个展示层小修正。
|
|
@@ -17,7 +56,7 @@
|
|
|
17
56
|
(高负载 runner 上定时器可能已过期),改为确定性地断言到期时刻已写入存储文件,并把到期等待
|
|
18
57
|
从 300ms 放宽到 1s。仅测试基建,不影响已发布的包内容。
|
|
19
58
|
- **导入回退去重后的计数披露**:「新增指令 N 条 / 新增用户形象 N 个」按实际落库数(staged)
|
|
20
|
-
统计,不再包含被回退去重跳过的项;截断披露同样只统计实际落库项。
|
|
59
|
+
统计,不再包含被回退去重跳过的项;截断披露同样只统计实际落库项。a2b885b 引入的口径回归,
|
|
21
60
|
contract-test 新增断言钉住「2 通过 − 1 回退重复 = 新增指令 1 条」。
|
|
22
61
|
- **暂停倒计时与复制路径的三个小修正**:新起暂停时先取当前时刻再起秒表(否则长开面板的
|
|
23
62
|
首帧剩余时间可能偏大最多 1 秒,下一秒自愈);他窗发起暂停时复位暂停菜单展开态;
|
package/lib/client.js
CHANGED
|
@@ -924,7 +924,9 @@ window.__ModuleLoader__.load({
|
|
|
924
924
|
function normalizeResult(result) {
|
|
925
925
|
if (result === null || typeof result !== 'object') return null
|
|
926
926
|
const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
|
|
927
|
-
|
|
927
|
+
// data === null passes a typeof check but poisons every consumer that
|
|
928
|
+
// reads data.budgetChars; it is exactly as unusable as a missing data.
|
|
929
|
+
if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined || snapshot.data === null) return null
|
|
928
930
|
return {
|
|
929
931
|
snapshot,
|
|
930
932
|
ok: result.ok !== false,
|
|
@@ -1530,6 +1532,10 @@ window.__ModuleLoader__.load({
|
|
|
1530
1532
|
// The body ceiling for the live check, mirrored into a ref so the
|
|
1531
1533
|
// debounced effect never has to depend on the snapshot object.
|
|
1532
1534
|
const sourceCeilingRef = React.useRef(0)
|
|
1535
|
+
// The source text as of the last read/save: openSource compares the
|
|
1536
|
+
// live draft against this to decide whether re-entering source mode
|
|
1537
|
+
// needs a discard confirmation.
|
|
1538
|
+
const sourceBaselineRef = React.useRef('')
|
|
1533
1539
|
// F17: the local relevance preview.
|
|
1534
1540
|
const [simOpen, setSimOpen] = React.useState(false)
|
|
1535
1541
|
const [simQuery, setSimQuery] = React.useState('')
|
|
@@ -1595,7 +1601,9 @@ window.__ModuleLoader__.load({
|
|
|
1595
1601
|
// clears it the moment the snapshot says otherwise. This effect sits
|
|
1596
1602
|
// BEFORE the early returns (a hook skipped by a conditional return would
|
|
1597
1603
|
// desync the hook order); it reads the snapshot through `view` because
|
|
1598
|
-
// the derived `injection` does not exist yet up here.
|
|
1604
|
+
// the derived `injection` does not exist yet up here. The tick value
|
|
1605
|
+
// itself is only a re-render trigger; the rendered remaining time is
|
|
1606
|
+
// computed from Date.now() at render time (see below).
|
|
1599
1607
|
const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
|
|
1600
1608
|
&& view.injection.muted === true
|
|
1601
1609
|
React.useEffect(() => {
|
|
@@ -1603,10 +1611,6 @@ window.__ModuleLoader__.load({
|
|
|
1603
1611
|
// A pause started from ANOTHER window replaces the menu row; the open
|
|
1604
1612
|
// flag must not resurface when the row returns after expiry.
|
|
1605
1613
|
setPauseMenuOpen(false)
|
|
1606
|
-
// Snapshot the clock the moment the pause lands: nowTick may be up to
|
|
1607
|
-
// a second stale from a PREVIOUS pause, which would render the first
|
|
1608
|
-
// frame with an inflated remaining time.
|
|
1609
|
-
setNowTick(Date.now())
|
|
1610
1614
|
const tick = setInterval(() => setNowTick(Date.now()), 1000)
|
|
1611
1615
|
return () => clearInterval(tick)
|
|
1612
1616
|
}, [mutedNow])
|
|
@@ -1881,6 +1885,12 @@ window.__ModuleLoader__.load({
|
|
|
1881
1885
|
setError(t('source.editorOpen'))
|
|
1882
1886
|
return
|
|
1883
1887
|
}
|
|
1888
|
+
// Re-entering source mode must not silently discard an unsaved source
|
|
1889
|
+
// draft: sourceText survives 退出源码模式 and reloadSource, and the
|
|
1890
|
+
// button stays reachable while source mode is open. The baseline is
|
|
1891
|
+
// the text as of the last read/save, so an untouched draft reopens
|
|
1892
|
+
// without friction.
|
|
1893
|
+
if (sourceText !== sourceBaselineRef.current && !confirmDiscard()) return
|
|
1884
1894
|
try {
|
|
1885
1895
|
const result = await callHost('read-source', null)
|
|
1886
1896
|
if (result === null || typeof result !== 'object' || typeof result.source !== 'string') {
|
|
@@ -1893,6 +1903,7 @@ window.__ModuleLoader__.load({
|
|
|
1893
1903
|
return
|
|
1894
1904
|
}
|
|
1895
1905
|
setSourceText(result.source)
|
|
1906
|
+
sourceBaselineRef.current = result.source
|
|
1896
1907
|
setSourceBaseRev(revOfFrom(result.snapshot))
|
|
1897
1908
|
setSourceVerdict(null)
|
|
1898
1909
|
setSourceMode(true)
|
|
@@ -1918,6 +1929,7 @@ window.__ModuleLoader__.load({
|
|
|
1918
1929
|
// on must move with it — otherwise the very next save would be
|
|
1919
1930
|
// refused as a conflict against our own write.
|
|
1920
1931
|
setSourceBaseRev(revOfFrom(result.snapshot))
|
|
1932
|
+
sourceBaselineRef.current = sourceText
|
|
1921
1933
|
void loadHistory()
|
|
1922
1934
|
})
|
|
1923
1935
|
}
|
|
@@ -2214,7 +2226,9 @@ window.__ModuleLoader__.load({
|
|
|
2214
2226
|
: data.entries.length
|
|
2215
2227
|
const activeSetName = (setsList.find((set) => set.id === setsActiveId) || {}).name || ''
|
|
2216
2228
|
// F17: the local preview scores the ACTIVE set's on-demand entries.
|
|
2217
|
-
|
|
2229
|
+
// Disabled entries can never be injected, so scoring them as 预演 hits
|
|
2230
|
+
// would mislead — they are excluded from the preview entirely.
|
|
2231
|
+
const autoEntries = data.entries.filter((entry) => entry.mode === 'auto' && entry.enabled !== false)
|
|
2218
2232
|
const sim = relevancePreview(autoEntries, simQuery)
|
|
2219
2233
|
const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
|
|
2220
2234
|
const omittedById = new Map()
|
|
@@ -2271,14 +2285,16 @@ window.__ModuleLoader__.load({
|
|
|
2271
2285
|
? view.diagnostics.rev
|
|
2272
2286
|
: (data.rev || 0)
|
|
2273
2287
|
// F7: the pause decision itself belongs to the host (injection.muted);
|
|
2274
|
-
// the countdown below is display-only.
|
|
2275
|
-
// the
|
|
2276
|
-
//
|
|
2288
|
+
// the countdown below is display-only. The remaining time is computed
|
|
2289
|
+
// from the clock AT RENDER TIME — a stale tick value could be days old
|
|
2290
|
+
// (the interval only runs while a pause is live), which would inflate
|
|
2291
|
+
// the first frame of a later pause. The 1s interval exists purely to
|
|
2292
|
+
// trigger re-renders; at zero the bar yields back to the normal control.
|
|
2277
2293
|
const muted = injection.muted === true
|
|
2278
2294
|
const disabledUntil = typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
|
|
2279
2295
|
? data.disabledUntil
|
|
2280
2296
|
: 0
|
|
2281
|
-
const muteRemainingMs = muted ? Math.max(0, disabledUntil -
|
|
2297
|
+
const muteRemainingMs = muted ? Math.max(0, disabledUntil - Date.now()) : 0
|
|
2282
2298
|
const muteRemainingText = (ms) => {
|
|
2283
2299
|
const totalSec = Math.max(0, Math.round(ms / 1000))
|
|
2284
2300
|
const hh = Math.floor(totalSec / 3600)
|
package/lib/index.js
CHANGED
|
@@ -1255,6 +1255,11 @@ export function apply(ctx) {
|
|
|
1255
1255
|
version: FORMAT_VERSION,
|
|
1256
1256
|
rev: 0,
|
|
1257
1257
|
enabled: true,
|
|
1258
|
+
// Must match normalizeData's output shape KEY FOR KEY: the watcher's
|
|
1259
|
+
// self-write detection compares JSON strings, and a missing key here
|
|
1260
|
+
// made every fresh process's first save look like an external change
|
|
1261
|
+
// (one redundant reload + frame).
|
|
1262
|
+
disabledUntil: 0,
|
|
1258
1263
|
budgetChars: DEFAULT_BUDGET,
|
|
1259
1264
|
backupDir: '',
|
|
1260
1265
|
activeSetId: DEFAULT_SET_ID,
|
|
@@ -1313,6 +1318,13 @@ export function apply(ctx) {
|
|
|
1313
1318
|
* (from syncSection) and cleared in the effect cleanup.
|
|
1314
1319
|
*/
|
|
1315
1320
|
let muteTimer = null
|
|
1321
|
+
/**
|
|
1322
|
+
* Set by the effect cleanup: after unmount, an in-flight queued op may
|
|
1323
|
+
* still complete its disk write (the data must land), but it must not
|
|
1324
|
+
* re-register the prompt section, restart the watcher, or push snapshots
|
|
1325
|
+
* into a torn-down fiber.
|
|
1326
|
+
*/
|
|
1327
|
+
let disposed = false
|
|
1316
1328
|
|
|
1317
1329
|
/**
|
|
1318
1330
|
* A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
|
|
@@ -1471,6 +1483,7 @@ export function apply(ctx) {
|
|
|
1471
1483
|
* leave the prompt byte-identical to a deployment without this plugin.
|
|
1472
1484
|
*/
|
|
1473
1485
|
const syncSection = () => {
|
|
1486
|
+
if (disposed) return
|
|
1474
1487
|
const wanted = sectionText() !== ''
|
|
1475
1488
|
if (wanted && sectionDispose === null) {
|
|
1476
1489
|
try {
|
|
@@ -1504,6 +1517,7 @@ export function apply(ctx) {
|
|
|
1504
1517
|
}
|
|
1505
1518
|
|
|
1506
1519
|
ctx.effect(() => () => {
|
|
1520
|
+
disposed = true
|
|
1507
1521
|
if (sectionDispose !== null) {
|
|
1508
1522
|
const dispose = sectionDispose
|
|
1509
1523
|
sectionDispose = null
|
|
@@ -1534,7 +1548,7 @@ export function apply(ctx) {
|
|
|
1534
1548
|
let watchDebounce = null
|
|
1535
1549
|
|
|
1536
1550
|
const pushSnapshot = () => {
|
|
1537
|
-
if (sseStreams.size === 0) return
|
|
1551
|
+
if (disposed || sseStreams.size === 0) return
|
|
1538
1552
|
const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
1539
1553
|
for (const [send, cleanup] of Array.from(sseStreams)) {
|
|
1540
1554
|
try {
|
|
@@ -1606,7 +1620,7 @@ export function apply(ctx) {
|
|
|
1606
1620
|
* yet) throws and is retried by the first successful write.
|
|
1607
1621
|
*/
|
|
1608
1622
|
const startWatching = () => {
|
|
1609
|
-
if (storeWatcher !== null) return
|
|
1623
|
+
if (disposed || storeWatcher !== null) return
|
|
1610
1624
|
try {
|
|
1611
1625
|
storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
|
|
1612
1626
|
// Only events naming memory.json matter; the .tmp/.bak/.lock churn of
|
|
@@ -3585,7 +3599,12 @@ export function apply(ctx) {
|
|
|
3585
3599
|
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
3586
3600
|
// materialisation happens here, on a deliberate user action, instead of
|
|
3587
3601
|
// as a side effect of the host merely starting up.
|
|
3588
|
-
|
|
3602
|
+
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
3603
|
+
// materialisation happens here, on a deliberate user action, instead of
|
|
3604
|
+
// as a side effect of the host merely starting up. It goes through the
|
|
3605
|
+
// same disk queue as every other disk access, so it can never
|
|
3606
|
+
// interleave with an in-flight write (readSource already did this).
|
|
3607
|
+
const materialized = await enqueue(() => ensureStoreMaterialized())
|
|
3589
3608
|
// Same envelope as every other method. One response shape means the
|
|
3590
3609
|
// Client can never again be handed a payload it silently ignores.
|
|
3591
3610
|
// `saved` is true only on the first run, when materialisation actually
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.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",
|