dsh-instruction-memory 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.3.3] — 2026-10-16
11
+
12
+ 一处注释整理,无代码与行为变更。
13
+
14
+ ### 变更
15
+
16
+ - **合并 `lib/index.js` 中重复的一段注释**:`case 'state'` 首次物化处的说明被整段写了两遍
17
+ (1.3.1 追加「经同一磁盘队列、不会与在途写入交错」这句时保留了原段),现合并为一段并保留该句。
18
+ 纯注释:`git diff` 仅 3 行删除,不改变任何代码路径或行为。
19
+
20
+ ## [1.3.2] — 2026-10-16
21
+
22
+ 1.3.1 发布后的两轮全量体检产出:CI 测试基建根治、client/demo 边界修复与宿主生命周期加固。
23
+
24
+ ### 修复
25
+
26
+ - **CI 测试基建:路由等待按墙钟计时**:contract/ui 的 rawRoute 原以固定迭代数轮询
27
+ (约 2s 上限),高负载 runner 上真实磁盘写路径(锁、双备份、rename、历史快照)可能偶发
28
+ 超限,导致整套测试崩溃(`route never responded`),且崩溃无断言痕迹。CI 偶发红
29
+ (1.2.0 时代即有先例)的根因即此。现改为墙钟 15s 上限,并给每个套件装崩溃钩子
30
+ (失败时输出 GitHub `::error::` 注解,含断言与堆栈),verify 的固定 60ms boot 等待改为
31
+ 轮询到条件成立。仅测试基建,不影响已发布包内容。
32
+ - **CI 假绿窗口收口**:套件尾部 `process.exit(failures===0?0:1)` 会覆盖崩溃钩子设置的
33
+ exitCode——最后一次校验之后的浮游 rejection 可能被吞成「ALL PASS + exit 0」。现在退出码
34
+ 尊重钩子状态;ui-test 的 scratch 清理移到退出码捕获之前;fetch 包装器在路由超时抛出时
35
+ 递减 inFlight(否则后续每次 settle 都烧满等待窗口)。
36
+ - **测试 scratch 目录自清理**:contract/verify/ui 现在都注册按 PID 的退出钩子,任何退出路径
37
+ (崩溃、抛错、失败)都删掉本进程的家目录;此前每次跑测试都会在仓库里留下
38
+ `.test-dsh-home-*` 目录(实测累积到 39 个)。仅测试基建。
39
+ - **verify 三条恒真断言修正**:F17 相关性分词的三个 check 把比较器写在了 detail 位、
40
+ ok 位是恒真值,实际从未断言(c74bfcc 引入的既有问题,本轮审阅发现);改为真布尔断言。
41
+ - **demo 桩 `sets.total` 计数错误**:scoped 数组覆盖克隆后才读计数,「全部配置集合计」显示的是
42
+ 当前集合数而非全库数;改为覆盖前从原库统计。
43
+ - **client:`data:null` 快照穿透 normalizer**:absorb 读取 `data.budgetChars` 抛错,初始加载
44
+ 会永久卡在加载态且无错误提示(当前宿主不产生该形状,防御性收口,verify 钉住)。
45
+ - **client:重进源码模式不再静默丢弃未保存草稿**:源码草稿与最近一次读/存的基线比对,有改动
46
+ 走标准弃用确认(基线随读取与保存推进)。
47
+ - **client:F17 本地预演不再给已停用条目标注命中**(它们永远不会被注入,此前会误导)。
48
+ - **host 生命周期三处加固**:初始 `state.data` 字面量补齐 `disabledUntil` 键(watcher 自写
49
+ 检测在新进程首次保存时因键形状不一致必然失配,产生一次多余重载与冗余 SSE 帧,探针实证);
50
+ 卸载栅栏(in-flight 操作完成后不再重注册注入段/重启 watcher/推送快照);`state` 路由的
51
+ 首次物化纳入磁盘队列(与 readSource 一致)。
52
+
53
+ ### 变更
54
+
55
+ - **暂停倒计时改用渲染时时钟**:剩余时间在渲染时直接取 `Date.now()` 计算,1 秒 interval
56
+ 仅作重渲染触发器。此前 nowTick 在两次暂停之间可能陈旧任意久(如隔天再暂停),首帧
57
+ 剩余时间会虚大;原描述「最多偏大 1 秒」低估了陈旧量。仅展示层。
58
+
10
59
  ## [1.3.1] — 2026-10-15
11
60
 
12
61
  1.3.0 发布当天的评审跟进:一个披露计数回归与三个展示层小修正。
@@ -17,7 +66,7 @@
17
66
  (高负载 runner 上定时器可能已过期),改为确定性地断言到期时刻已写入存储文件,并把到期等待
18
67
  从 300ms 放宽到 1s。仅测试基建,不影响已发布的包内容。
19
68
  - **导入回退去重后的计数披露**:「新增指令 N 条 / 新增用户形象 N 个」按实际落库数(staged)
20
- 统计,不再包含被回退去重跳过的项;截断披露同样只统计实际落库项。80f94f5 引入的口径回归,
69
+ 统计,不再包含被回退去重跳过的项;截断披露同样只统计实际落库项。a2b885b 引入的口径回归,
21
70
  contract-test 新增断言钉住「2 通过 − 1 回退重复 = 新增指令 1 条」。
22
71
  - **暂停倒计时与复制路径的三个小修正**:新起暂停时先取当前时刻再起秒表(否则长开面板的
23
72
  首帧剩余时间可能偏大最多 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
- if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined) return null
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
- const autoEntries = data.entries.filter((entry) => entry.mode === 'auto')
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. At zero the bar yields back to
2275
- // the normal control (the host's own timer is delivering a fresh
2276
- // snapshot at that moment in any case).
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 - nowTick) : 0
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
@@ -3584,8 +3598,10 @@ export function apply(ctx) {
3584
3598
  // it displays the absolute path, and the user may then hand-edit it.
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
- // as a side effect of the host merely starting up.
3588
- const materialized = await ensureStoreMaterialized()
3601
+ // as a side effect of the host merely starting up. It goes through the
3602
+ // same disk queue as every other disk access, so it can never
3603
+ // interleave with an in-flight write (readSource already did this).
3604
+ const materialized = await enqueue(() => ensureStoreMaterialized())
3589
3605
  // Same envelope as every other method. One response shape means the
3590
3606
  // Client can never again be handed a payload it silently ignores.
3591
3607
  // `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.1",
3
+ "version": "1.3.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",