dsh-retrace 0.4.27 → 0.4.29

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/README.md CHANGED
@@ -20,8 +20,15 @@ DeepSeek Harness.
20
20
  deserves. But rewinding is not just "delete a message": DeepSeek Harness stores
21
21
  conversations in an append-only event log, so a recall only rewinds the context
22
22
  while changed **artifact files stay changed**. dsh-retrace versions the
23
- conversation **and its artifacts** together, and guarantees **every rewind is
24
- legal — never dirtying the log, never breaking /compact**.
23
+ conversation **and its artifacts** together, and keeps **every new rewind legal** —
24
+ it cannot dirty the log, and new markers create **no token-meter pairing debt**
25
+ (two-segment atomic pairs land by construction).
26
+
27
+ > ⚠️ **Honest scope (matches the companion contract's own note)**: sessions that
28
+ > already contain **legacy single-segment markers** — written by older versions —
29
+ > are **known design debt**. Before `/compact`, run the companion `check` and
30
+ > clean them up (`fix --remove-markers`); otherwise the host's own T1 self-check
31
+ > blocks compaction. New rewinds do not add to that debt.
25
32
 
26
33
  > 🛡️ **Write safety** · 🔍 **Deep offline checks** · 🔄 **Detect → repair → guard** — see below.
27
34
 
@@ -54,7 +61,7 @@ Full steps in [📦 Installation](#-installation).
54
61
 
55
62
  | | Capability | What it means |
56
63
  |---|---|---|
57
- | 🛡️ | **Write safety** | Every rewind passes a three-layer pre-write contract guard; running agents are auto-stopped (official `cancel`/`whenIdle`); turn-interval markers are wrapped in a temporary step — **rewinds never dirty the log, /compact never breaks** |
64
+ | 🛡️ | **Write safety** | Every rewind passes a three-layer pre-write contract guard; running agents are auto-stopped (official `cancel`/`whenIdle`); turn-interval markers are wrapped in a temporary step — **new rewinds cannot dirty the log and add no token-meter pairing debt**; **legacy single-segment markers are known debt** (run the companion `check` + `fix --remove-markers` before `/compact`) |
58
65
  | 🔍 | **Deep offline checks** | Companion `dsh-log-contract` ships 30+ contract rules (token-meter pairing / cross-step references / physical order / inbox replay), validated against real corrupted-session fixtures — it finds the class of problem that makes /compact permanently fail |
59
66
  | 🔄 | **Detect → repair → guard** | A watchdog snapshots the log at the first sign of concurrent writes; offline `fix` neutralizes problem markers and clips cross-step references in place; pre-write validation stops bad events before they land |
60
67
 
@@ -298,12 +305,12 @@ Read it before filing an issue.
298
305
  - Stale peer declarations with no remaining import site removed
299
306
  (`@deepseek-ai/dsh-home-paths`, `@deepseek-ai/dsh-client-runtime`).
300
307
 
301
- ### Host-side breaking changes that `0.4.27` adapts to — *not caused by this plugin*
308
+ ### Host-side breaking changes that `0.4.28` adapts to — *not caused by this plugin*
302
309
 
303
310
  1. **`@deepseek-ai/dsh-session` removed the `Session.events` member** (in `0.1.5-rc.1`).
304
311
  The class has no `events` field and no `events` getter at all any more; the supported
305
312
  readers are `snapshotEvents(fromSeq, toSeqExclusive)` (frozen, sequence-indexed),
306
- `eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.27` reaches the log through a
313
+ `eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.28` reaches the log through a
307
314
  compatibility accessor that prefers the new API and falls back to the old array, so it
308
315
  runs on both host generations.
309
316
  **Symptom before the fix:** recall and edit did nothing and surfaced the raw error
@@ -311,14 +318,14 @@ Read it before filing an issue.
311
318
  start by locating the target message id, and that lookup read the removed member.
312
319
  2. **The client-side session store has no `keys()`** (`ctx.sessions`). A plugin that
313
320
  enumerates sessions with `keys()` silently sees **zero** of them: no crash, no error,
314
- just safety warnings that never fire. `0.4.27` prefers the official `list()` and falls
321
+ just safety warnings that never fire. `0.4.28` prefers the official `list()` and falls
315
322
  back to `keys()`; it deliberately does **not** fall back to enumerating service fields,
316
323
  because guessing produces a silent empty result as well.
317
324
  3. **The client session controller has no title accessor** — `getTitle` does not exist
318
325
  anywhere in `@deepseek-ai/dsh-api-session-controller`, and its `getSnapshot()` carries
319
326
  no `title`. See the plugin-side item below: this one used to *overwrite your titles*.
320
327
 
321
- ### Plugin-side fixes in `0.4.27` (these are ours)
328
+ ### Plugin-side fixes in `0.4.28` (these are ours)
322
329
 
323
330
  - **The edit / recall affordances never appeared at all.** The client half read chat
324
331
  nodes from `snapshot.chat.nodes`, a path this host build does not have — nodes live in
@@ -333,21 +340,35 @@ Read it before filing an issue.
333
340
  **diagnosable reason** (renderer warning + host-log line) instead of failing silently.
334
341
  - **Assigning a short code could overwrite your session title.** The client composed
335
342
  `[CODE] <current title>` locally but had no way to read the current title, so the base
336
- degraded to the session-id prefix (`[XXXXXX] 668f9166-648c-4c`). Title tagging now goes
343
+ degraded to the session-id prefix (`[XXXXXX] <session-id-prefix>`). Title tagging now goes
337
344
  through the host route only (`setBadgeTitle`), which reads the current title from the
338
345
  session log. Manual renames are unaffected.
339
346
  - **Host-side operation failures are logged again** (code + message + stack). They used to
340
347
  return the message to the UI without a log line, which is why this whole class of bug
341
348
  was hard to diagnose from outside.
342
349
 
350
+ - **The version and fork views now explain themselves.** They used to show a title plus a row of
351
+ actions with **no sentence anywhere saying what a "version" or a "fork" is** (the only near-miss
352
+ was an empty-state line that disappears as soon as data arrives), and fork rows printed raw node
353
+ types. They now carry an always-visible concept sentence, a type legend, and a plain-language
354
+ "why" line on every row; impact text reads `旧路径的 N 条消息被替换`, not `被遮蔽 N 个节点`.
355
+ - **Client hide-lookup no longer rescans per row.** `useSeqHidden` re-scanned the node map for every
356
+ row (measured **346 ms** at 2000 rows / 20 markers, **1568 ms** at 3000/30 — and that path was
357
+ *dead* on this host until this release made the rows render at all, so it is this fix own cost).
358
+ It now reuses one per-snapshot hide plan: **8.3 ms** and **18.3 ms** respectively, with the
359
+ predicate verified equivalent against the old one.
343
360
  ### Upgrading
344
361
 
345
362
  ```bash
346
- dsh plugin --profile desktop add dsh-retrace@0.4.27
363
+ dsh plugin --profile desktop add dsh-retrace@0.4.29
347
364
  # then restart DSH — plugins are not hot-reloaded
348
365
  ```
349
366
 
350
- **`0.4.27` needs no data migration.** The session format is unchanged (v3), no session is
367
+ **`0.4.27` was withdrawn.** It was briefly published and then recalled — `latest` points at `0.4.26`
368
+ again and `0.4.27` is marked deprecated. **`0.4.28` is its replacement**: it carries every fix
369
+ `0.4.27` had, plus the two items below.
370
+
371
+ **`0.4.28` needs no data migration.** The session format is unchanged (v3), no session is
351
372
  re-written, and nothing has to be re-indexed: upgrade, restart, and the two symptoms above
352
373
  are gone. If you are on a host that still provides the old members, the compatibility
353
374
  accessors keep those paths working — this build does not drop older hosts.
@@ -372,6 +393,17 @@ the **host surface**, not by semver alone.
372
393
 
373
394
  ## ⚠️ Requirements & limitations
374
395
 
396
+ - **Optional dependency (deliberately NOT in `package.json`)**: AI summaries need
397
+ an `llm` service from the host (the official `@deepseek-ai/dsh-llm`, bundled
398
+ with DSH Desktop). The plugin takes it **dynamically** via `ctx.get('llm')`:
399
+ present ⇒ summaries available, absent ⇒ it degrades to **verbatim excerpts
400
+ only**. Install and startup are unaffected either way. Model and credentials
401
+ follow the session's own default selection
402
+ (`agentDefaultModel.currentSelection()`); the plugin adds **no configuration
403
+ surface of its own**. Summaries sit behind a **default-off** switch (at most one
404
+ small call per operation: ≤6×400 chars in, ≤200 tokens out, 5 s timeout); when
405
+ it is off there are **zero LLM calls**, while the verbatim excerpt (zero token
406
+ cost) is **always recorded**.
375
407
  - Only **user messages** can be edited; recall works on user and assistant
376
408
  messages. Tool results are shadowed along with the recalled range but are not
377
409
  themselves recall targets.
@@ -392,7 +424,8 @@ the **host surface**, not by semver alone.
392
424
 
393
425
  - Recall / edit-and-resend / regenerate, each written through a three-layer
394
426
  **pre-write contract guard** and a safe-edit path (auto-stop the agent, temp-step
395
- markers) — rewinds never corrupt the log or break `/compact`.
427
+ markers) — new rewinds do not corrupt the log and add no `/compact` debt;
428
+ **legacy single-segment markers** remain known debt (see the honest note above).
396
429
  - In-session **version timeline** + **artifact rollback** (git-first, snapshot
397
430
  fallback, dry-run preview, jump-to-conversation).
398
431
  - **Fork map + session lineage** in the conversation view.
@@ -406,19 +439,26 @@ the **host surface**, not by semver alone.
406
439
  | 🛡️ | **Running-work detection** | every session is scanned for live work: agent running, queued inbox items, background jobs, unclosed turns |
407
440
  | 📋 | **Running banner** | sessions with live work show a persistent in-page banner (short session code + reasons), so you can see it before quitting |
408
441
  | ⚠️ | **Exit prompt** | on plugin dispose (app exit / reload) a Chinese notice lists each running session and why it is considered busy — it only warns, it never cancels your running agent |
409
- | 🔒 | **Page-close interception (Web)** | `beforeunload` interception: a strong confirm when work is running (details modal, `[仍关闭]` = confirm-and-go), a light confirm otherwise |
410
- | 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports |
411
-
412
- > Desktop note: the Electron shell destroys the window on quit, so the page-level
413
- > `beforeunload` hook cannot fire there and the host exposes no plugin quit-veto seam —
414
- > Desktop is covered by the running banner plus the dispose notice; Web gets the full
415
- > interception.
442
+ | 🔒 | **Page-close interception (web browsers)** | `beforeunload` interception, armed only where the host reports a native confirm dialog: a strong confirm when work is running (details modal, `[仍关闭]` = confirm-and-go), a light confirm otherwise |
443
+ | 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports; the all-sessions shape also carries the host-reported page surface (`surface` / `quitVeto`) |
444
+
445
+ > Desktop note: quit entry points differ by version/platform, and the DSH Desktop Electron
446
+ > shell we inspected has no `will-prevent-unload` handler (0 hits across the packaged 2.0.9
447
+ > `app.asar`). Where the entry does reach the page — the external report's DSH Desktop 0.9.0 /
448
+ > Windows — the page `beforeunload` veto is **swallowed silently**: no dialog, no feedback, and
449
+ > the exit looks stuck (only a force-quit works). Where it does not — the 2.0.9 shell we
450
+ > inspected routes the tray item through `requestQuit(0) → window.destroy() → app.exit(0)` — the
451
+ > quit is unaffected either way. The page cannot tell which case it is in, so desktop **never**
452
+ > arms the native gate; it relies on the running banner plus the dispose notice. The gate is
453
+ > armed only where the host reports that the page really surfaces a native dialog
454
+ > (`quitVeto: true`, i.e. browser pages).
416
455
 
417
456
  > Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
418
457
 
419
- **What's next** — see the [public roadmap](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md) for the agent
420
- business-layer plan (runtime guard, interruption governance, ecosystem-facing
421
- interfaces). This README only describes what is already shipped.
458
+ **What's next** — the agent business-layer plan (runtime guard, interruption
459
+ governance, ecosystem-facing interfaces) is **not published yet**: it is a plan,
460
+ not a shipped capability. This README describes the **development line (main)**,
461
+ which may run ahead of the latest npm release.
422
462
 
423
463
  ---
424
464
 
@@ -466,9 +506,9 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
466
506
 
467
507
  Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
468
508
 
469
- Part of the **Agent business layer (production-grade guarantees)** — see the
470
- [public roadmap](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md) for the framework-agnostic layer and how
471
- dsh-retrace is its DeepSeek Harness implementation. Companion components:
509
+ Part of the **Agent business layer (production-grade guarantees)** — the
510
+ framework-agnostic layer that dsh-retrace implements on DeepSeek Harness.
511
+ Companion components:
472
512
 
473
513
  - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) — the
474
514
  business layer's "doctor": 30+ offline contract rules + in-place repair
package/README.zh.md CHANGED
@@ -17,8 +17,13 @@
17
17
 
18
18
  **撤回 / 编辑重发 / 重新生成** —— 每个会话都该有的三个操作。但回退不只是「撤掉一条
19
19
  消息」:DeepSeek Harness 把对话存在 append-only 事件日志里,撤回只回退上下文,改过的
20
- **产物文件不会自动还原**。dsh-retrace 把对话**和它的产物**一起版本化,并且保证
21
- **每次回退都合法、不弄脏日志、不破坏 /compact**。
20
+ **产物文件不会自动还原**。dsh-retrace 把对话**和它的产物**一起版本化,并保证
21
+ **每一次新的回退都合法**——不会弄脏日志,新写入的 marker **不产生 token-meter 配对债**
22
+ (两段原子对按构造即通过)。
23
+
24
+ > ⚠️ **诚实的边界(与配套契约自己的说明一致)**:**旧版本留下的单段 marker** 是
25
+ > **已知设计债**。压缩前请用配套 `check` 体检并 `fix --remove-markers` 清理,否则宿主的
26
+ > T1 自检会挡住 `/compact`。新的回退不会增加这笔债。
22
27
 
23
28
  > 🛡️ **写安全** · 🔍 **深层体检** · 🔄 **检测→修复→守护** —— 详见下方「生产级保证」。
24
29
 
@@ -51,7 +56,7 @@ dsh plugin --profile desktop add dshmarket # 只需一次
51
56
 
52
57
  | | 能力 | 说明 |
53
58
  |---|---|---|
54
- | 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 —— **回退永不弄脏日志,/compact 永不失效** |
59
+ | 🛡️ | **写安全** | 每次回退过三层写前契约校验;运行中的 agent 自动停止(官方 `cancel`/`whenIdle`);轮次间 marker 用临时 step 包裹 —— **新的回退不会弄脏日志**,新 marker **不产生 token-meter 配对债**;**旧单段 marker 是已知设计债**(压缩前先 `check` + `fix --remove-markers`) |
55
60
  | 🔍 | **深层体检** | 配套 `dsh-log-contract` 30+ 条契约规则(token-meter 配对 / 跨 step 引用 / 物理序 / inbox 重放),用真实损坏会话当测试集 —— 能找出让 /compact 永久失效的那类问题 |
56
61
  | 🔄 | **检测→修复→守护** | 看门狗在并发写入第一时间快照日志;离线 `fix` 原地中和问题 marker、裁剪跨 step 引用;写前校验在坏事件落盘前拦住 |
57
62
 
@@ -287,6 +292,14 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
287
292
 
288
293
  ## ⚠️ 要求与限制
289
294
 
295
+ - **可选依赖(不写进 `package.json`)**:AI 摘要需要宿主提供 `llm` 服务
296
+ (官方 `@deepseek-ai/dsh-llm`,随 DSH Desktop 内置)。插件用
297
+ `ctx.get('llm')` **动态取用**:有就用,缺失即降级为**只给逐字原文**,
298
+ 安装/启动不受影响。模型与凭据沿用会话自身的默认模型选择
299
+ (`agentDefaultModel.currentSelection()`),插件**不新增任何配置面**。
300
+ 摘要是**默认关闭**的开关(每次操作至多 1 次小调用,输入 ≤6×400 字、
301
+ 输出 ≤200 token、5 秒超时),关闭时**零 LLM 调用**;而逐字原文
302
+ (`excerpt`)零 token 成本,**始终产出**。
290
303
  - 只有**用户消息**可以编辑;撤回同时适用于用户与助手消息。工具结果会随区间一并
291
304
  被阴影化,但不能单独作为撤回目标。
292
305
  - 智能体必须**空闲**:回复流式输出时需先点击 ⏹ 停止,再撤回或编辑;否则 Host
@@ -301,7 +314,7 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
301
314
 
302
315
  **当前已具备(0.4.x):**
303
316
 
304
- - 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker),**不会损坏日志、不会破坏 /compact**。
317
+ - 撤回 / 编辑重发 / 重新生成——每次回退都过**三层写前校验**与安全编辑路径(自动停 agent、临时 step 包裹 marker),**新的回退不会损坏日志、不新增 `/compact` 债**;**旧单段 marker 需要压缩前清理**(配套 `check` + `fix --remove-markers`)。
305
318
  - 单会话**版本时间线** + **产物回退**(git 优先 + 快照兜底、干跑预览、跳转对话)。
306
319
  - 对话视图内的**分叉图** + **会话谱系**。
307
320
  - **实时看门狗**——并发写入第一时间快照日志。
@@ -313,13 +326,18 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
313
326
  | 🛡️ | **运行中检测** | 逐会话扫描运行中工作:agent 正在跑 / inbox 排队 / 后台 jobs / 未闭合轮 |
314
327
  | 📋 | **运行中横幅** | 有运行中工作的会话显示页面常驻横幅(会话短码 + 原因),退出前可见 |
315
328
  | ⚠️ | **退出提示** | 插件 dispose(应用退出/重载)时中文提示列出每个运行中会话与原因——只提示,绝不代你取消 agent |
316
- | 🔒 | **页面关闭拦截(Web)** | `beforeunload` 拦截:有运行中任务强确认(明细模态,`[仍关闭]` 即确认离开),无任务轻确认 |
317
- | 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP),两入口同形状 |
329
+ | 🔒 | **页面关闭拦截(普通浏览器)** | `beforeunload` 拦截,只在宿主回报会弹原生确认框的页面上武装:有运行中任务强确认(明细模态,`[仍关闭]` 即确认离开),无任务轻确认 |
330
+ | 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP),两入口同形状;全会话形状另带宿主判定的承载面(`surface` / `quitVeto`) |
318
331
 
319
- > 桌面说明:Electron 宿主退出时销毁窗口,页面 `beforeunload` 不会触发,宿主也未暴露
320
- > 插件可用的退出否决点——桌面侧由运行中横幅 + dispose 提示覆盖;Web 端拦截完整生效。
332
+ > 桌面说明:**退出入口随版本/平台而变**,而我们检查的 Electron 壳没有处理 `will-prevent-unload`
333
+ > (装好的 2.0.9 `app.asar` 全文检索 0 命中)。**会走到该入口的那类版本**(外部报告所在的
334
+ > DSH Desktop 0.9.0 / Windows)上,页面 `beforeunload` 否决被**静默吞掉**:不弹界面、不给
335
+ > 反馈,表现为退出卡住(只能强退);**不走该入口的版本**(我们检查的 2.0.9:托盘项走
336
+ > `requestQuit(0) → window.destroy() → app.exit(0)`)上,退出本来就不受影响。页面里分不出
337
+ > 自己属于哪一类,因此**桌面端一律不武装**原生门,保护由运行中横幅 + dispose 提示承担;
338
+ > 只有宿主回报"这个页面会弹原生确认框"(`quitVeto: true`,即普通浏览器页)时才武装。
321
339
 
322
- **未来计划**——见 [公开路线图](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md)(agent 业务层规划:运行时守护、中断治理、生态开放接口)。本 README 只描述已上线的能力。
340
+ **未来计划**——agent 业务层规划(运行时守护、中断治理、生态开放接口)**尚未发布**,此节是**计划**而非已上线能力。本 README 描述的是**开发线(main)**,可能领先于 npm 上最新发布版。
323
341
 
324
342
  ---
325
343
 
@@ -366,8 +384,8 @@ npm pack --dry-run # 校验发布文件清单
366
384
 
367
385
  收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin)。
368
386
 
369
- **Agent 业务层(生产级保证)** 的一部分——见 [公开路线图](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md)
370
- (框架无关的业务层定义,dsh-retrace 是它在 DeepSeek Harness 上的实现)。配套组件:
387
+ **Agent 业务层(生产级保证)** 的一部分——即 dsh-retrace 在 DeepSeek Harness 上实现的
388
+ 那层框架无关的业务层定义。配套组件:
371
389
 
372
390
  - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) —— 业务层的
373
391
  「医生」:30+ 条离线契约规则 + 原地修复(`fix --neutralize` / `--clip-crossstep`)。
package/bin/retrace.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * dsh-retrace · bin/retrace.mjs
4
4
  *
5
- * 会话日志考古 CLI(任务书 A1-A4)——只读,不写任何日志。
5
+ * 会话日志考古 CLI——只读,不写任何日志。
6
6
  *
7
7
  * retrace index <session> [--json]
8
8
  * 工具调用索引:调用数 / 配对率 / 孤儿数 / 命令分布(A1)
@@ -80,7 +80,7 @@ const isPlainObject = (value) => typeof value === 'object' && value !== null &&
80
80
  * 带着更大的 seq 插进被遮蔽区间的位置,于是「位置在前、seq 更大」是合法形态。
81
81
  * 官方 `replacementRange`(dsh-session)只按 `indexOf(start) <= indexOf(end)` 的
82
82
  * **位置**判定,与 seq 数值大小无关;要求 start <= end 会把真实会话上的合法 span
83
- * 误判为契约违规(真实数据实测:1360270 → 1360265 的跨度是正常写入)。
83
+ * 误判为契约违规(现场实测:位置序 span 的 seq 数值非单调属正常写入)。
84
84
  * 真正可判定的是:两端都是当前面上的节点 → 位置连续段 → 首尾一致(见下)。
85
85
  */
86
86
  export function assertSpanShape(span, contract = 'Span.shape') {
@@ -293,7 +293,7 @@ export const dshAdapter = {
293
293
  }
294
294
 
295
295
  // ─────────────────────────────────────────────────────────────────────────────
296
- // 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与修复线
296
+ // 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与外部工具
297
297
  // 短码表生成器同规则(表的新鲜版,不冲突)。
298
298
  // 短码 = 工作区2 + 序号3 + 父工作区2 + 父序号3;根父 = FF000。
299
299
  // 只读会话文件帧1(header),全量 ~110 会话 ≈ 30ms,懒加载缓存。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-retrace · lib/archaeology-cli.js
3
3
  *
4
- * 会话日志考古 CLI 的核心逻辑(任务书 dsh-会话日志考古-插件任务与方法.md A1-A4)。
4
+ * 会话日志考古 CLI 的核心逻辑(只读索引 / 查询 / 抽取 / 文件历史 / 谱系)。
5
5
  * 与 dsh-log-contract 的 archaeology.js 分工:B 侧提供 extract/audit 纯函数,
6
6
  * 本模块提供 A 侧的文件版本考古(write/edit 重放)与谱系(parent 链)。
7
7
  * **只读不写**(纪律 §8.1)。
package/lib/badge.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * 不依赖外部短码表、插件运行时自算即可。同一 session id 永远得到同一短码。
9
9
  *
10
10
  * 与 `会话短码表.json`(工作区 2 位 + 序号 3 位)的关系:
11
- * - 外部表 = 人工维护的语义短码(线名/父子关系可读),由修复线维护;
11
+ * - 外部表 = 人工维护的语义短码(线名/父子关系可读),由外部工具维护;
12
12
  * - 本模块 = 插件自算的确定性短码(人机交互兜底),任何环境可复现;
13
13
  * - 两者并存:铭牌优先显示外部语义短码(若可查),否则用本模块兜底。
14
14
  *
@@ -0,0 +1,94 @@
1
+ /**
2
+ * dsh-retrace — lib/boot-pin.js
3
+ *
4
+ * 启动批量 pin 的**重试调度**(唯一职责:什么时候再跑一次)。
5
+ *
6
+ * 从 lib/index.js 迁出的理由(2026-09-18 外部 issue #1:桌面端托盘退出死锁):
7
+ * 原实现是内联的递归 `setTimeout`,句柄被丢弃、既不 `unref` 也不在 dispose 里清。
8
+ * 宿主是 Node 侧 —— 一条 30s 递归(最多 20 次 ≈ 启动后 10 分钟)会把宿主事件循环
9
+ * 钉住,退出/关机流程得等它跑完(桌面端退不掉的两个拖累之一,另一个见
10
+ * lib/watchdog.js)。
11
+ *
12
+ * 与 lib/watchdog.js 同款:定时器创建/销毁可注入(`schedule` / `unschedule`),
13
+ * 于是"句柄有没有 unref""dispose 有没有撤销"能被用例直接捕获,不靠 review。
14
+ * 职责边界:本模块只决定"何时重试";"重试做什么"由 `run()` 注入(装配点传
15
+ * pinAllResident),"现在有没有可处理的会话"由 `hasResidentSessions()` 注入。
16
+ * 本模块零 import、零宿主服务引用(可单测,也进得了动态件 realm)。
17
+ */
18
+
19
+ /** 默认重试上限(对齐 0.4.19 的启动窗口:20 × 30s ≈ 启动后 10 分钟)。 */
20
+ export const BOOT_PIN_MAX_ATTEMPTS = 20
21
+ /** 默认重试间隔。 */
22
+ export const BOOT_PIN_DELAY_MS = 30_000
23
+
24
+ /**
25
+ * 建启动重试链。
26
+ * @param {object} options
27
+ * @param {() => Promise<any>} options.run 实际执行体(返回值里的 `value.total` 用于日志)。
28
+ * @param {() => boolean} options.hasResidentSessions 现在有没有可处理的会话。
29
+ * @param {(line: string) => void} [options.log] 诊断日志。
30
+ * @param {number} [options.maxAttempts] 重试上限(含首跑)。
31
+ * @param {number} [options.delayMs] 重试间隔。
32
+ * @param {(cb: () => void, ms: number) => unknown} [options.schedule] 定时器创建
33
+ * (默认全局 setTimeout;返回的句柄上若有 `unref` 会被调用)。
34
+ * @param {(handle: unknown) => void} [options.unschedule] 定时器销毁(默认全局 clearTimeout)。
35
+ * @returns {{ dispose(): void }} dispose = 停止重试链 + 撤掉挂着的定时器(幂等)。
36
+ */
37
+ export function createBootPinRetry({
38
+ run,
39
+ hasResidentSessions,
40
+ log = () => {},
41
+ maxAttempts = BOOT_PIN_MAX_ATTEMPTS,
42
+ delayMs = BOOT_PIN_DELAY_MS,
43
+ schedule = (cb, ms) => setTimeout(cb, ms),
44
+ unschedule = (handle) => clearTimeout(handle),
45
+ } = {}) {
46
+ if (typeof run !== 'function') throw new TypeError('createBootPinRetry: run must be a function')
47
+ if (typeof hasResidentSessions !== 'function') throw new TypeError('createBootPinRetry: hasResidentSessions must be a function')
48
+
49
+ let timer = null
50
+ let disposed = false
51
+
52
+ /** 挂下一次(序号 = next)。序号到顶就不挂,链自然收尾。 */
53
+ function arm(next) {
54
+ if (disposed || next >= maxAttempts) return
55
+ timer = schedule(() => {
56
+ timer = null
57
+ step(next + 1)
58
+ }, delayMs)
59
+ // 宿主是 Node 侧:不 unref ⇒ 这条 30s 递归把事件循环钉到启动后 10 分钟,
60
+ // 退出/关机被它拖住。unref 后宿主该退就退(会话仍在时事件循环自有别的句柄撑着)。
61
+ if (timer && typeof timer.unref === 'function') timer.unref()
62
+ }
63
+
64
+ function step(current) {
65
+ if (disposed) return
66
+ const finish = () => arm(current)
67
+ if (!hasResidentSessions()) {
68
+ finish()
69
+ return
70
+ }
71
+ Promise.resolve()
72
+ .then(() => run())
73
+ .then((result) => {
74
+ log(`retrace: bootPin(${current}) → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
75
+ })
76
+ .catch((error) => {
77
+ log(`retrace: bootPin error: ${String(error).slice(0, 160)}`)
78
+ })
79
+ .then(finish)
80
+ }
81
+
82
+ step(0)
83
+
84
+ return {
85
+ /** 停止重试链并撤掉挂着的定时器(重复调用安全)。 */
86
+ dispose() {
87
+ disposed = true
88
+ if (timer !== null) {
89
+ unschedule(timer)
90
+ timer = null
91
+ }
92
+ },
93
+ }
94
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * dsh-retrace — lib/boundary-derive.js
3
+ *
4
+ * READ-SIDE derivation of the boundary digest ("what did this boundary throw
5
+ * away?") for boundaries that have NO stored artifact record.
6
+ *
7
+ * Why this exists (real-machine finding 2026-09-15): the digest artifact
8
+ * (`<pluginDataHome()>/dsh-retrace/summaries/<sessionId>.jsonl`) is written at
9
+ * OPERATION time and only started shipping with the summary feature, so every
10
+ * boundary that predates it has no record — the read point then rendered one
11
+ * bare line (`替换 / 9-1 16:30 / 当时共 2241 条消息`) with no content at all.
12
+ * The discarded ORIGINALS are still in the session log, so the digest can be
13
+ * recomputed on read.
14
+ *
15
+ * Authority order: a STORED record always wins. The stored digest was built at
16
+ * operation time, when the replaced window was still on the surface; once
17
+ * compaction removes those events, the log can no longer reproduce it. This
18
+ * module is the fallback, never an override.
19
+ *
20
+ * Pure (no imports beyond two pure siblings, no I/O): the caller passes an
21
+ * `eventAt(seq)` accessor, so the host seam, the HTTP route and the tests all
22
+ * drive the same rule and a test can hand it a plain Map.
23
+ */
24
+ import { makeWhat } from './boundary-what.js'
25
+ import { classifyBoundaryKind, replacedSeqsOfBoundary } from './version-index.js'
26
+
27
+ /**
28
+ * Derive one digest record per version that has no stored artifact line.
29
+ *
30
+ * @param {object} input
31
+ * @param {Array<{boundarySeq:number, versionId?:string}>} input.versions - the
32
+ * boundaries to derive for (normally "our" boundaries only; a host-side
33
+ * replacement is skipped even if it is passed in).
34
+ * @param {(seq:number)=>object|undefined} input.eventAt - one event by exact seq.
35
+ * @param {Set<number>} [input.boundarySeqs] - the boundary seqs used to SLIM
36
+ * `discardedSeqs` (same convention as the write side: membership only ever
37
+ * asks about another boundary's seq, `discardedCount` keeps the exact |S|).
38
+ * Defaults to the passed `versions`.
39
+ * @returns {object[]} derived artifact-shaped records (`derived: true`)
40
+ */
41
+ /** 这一档属于哪一轮(人读):载体事件 → 被替换段里第一个带 turn 的事件 → null。 */
42
+ export function turnOfBoundary(event, spanEvents = []) {
43
+ // 实测:撤回/编辑/重发的"载体"事件常常不带 `data.turn`,被它替换掉的那一段才
44
+ // 带 ⇒ 先看载体,再在被替换段里取第一个可用 turn。都取不到就 null(调用方整段
45
+ // 省略,不编造"第 ? 轮")。
46
+ const usable = (value) => (Number.isSafeInteger(value) && value > 0 ? value : null)
47
+ const own = usable(event?.data?.turn) ?? usable(event?.turn)
48
+ if (own !== null) return own
49
+ for (const spanEvent of spanEvents) {
50
+ const found = usable(spanEvent?.data?.turn) ?? usable(spanEvent?.turn)
51
+ if (found !== null) return found
52
+ }
53
+ return null
54
+ }
55
+
56
+ export function deriveBoundaryRecords({ versions, eventAt, boundarySeqs = null } = {}) {
57
+ const list = Array.isArray(versions) ? versions : []
58
+ if (typeof eventAt !== 'function') return []
59
+ const boundarySet = boundarySeqs instanceof Set
60
+ ? boundarySeqs
61
+ : new Set(list.map((version) => version?.boundarySeq).filter((seq) => Number.isSafeInteger(seq)))
62
+ const out = []
63
+ for (const version of list) {
64
+ const boundarySeq = version?.boundarySeq
65
+ if (!Number.isSafeInteger(boundarySeq)) continue
66
+ const event = eventAt(boundarySeq)
67
+ if (event === undefined || event === null) continue
68
+ const kind = classifyBoundaryKind(event)
69
+ // Host-origin surface replacement (a tool result re-rendered by the host):
70
+ // never a read point of ours, so never given a digest.
71
+ if (kind === 'replace') continue
72
+ // The EXACT removed set: the carrier's own citation minus its audit guide
73
+ // item (never a numeric window — see lib/boundary-tree.js).
74
+ const removed = replacedSeqsOfBoundary(event, null)
75
+ const spanEvents = removed.map((seq) => eventAt(seq)).filter((spanEvent) => spanEvent !== undefined)
76
+ // 轮次:载体自身常不带 turn(实测),被替换的那一段才带 ⇒ 用它的 turn 告诉
77
+ // 用户"这一轮的输入"是哪一轮;都取不到就省略(不编造)。
78
+ const turn = turnOfBoundary(event, spanEvents)
79
+ const what = makeWhat({
80
+ op: kind,
81
+ at: event.time,
82
+ spanEvents,
83
+ // The EXACT seq list is passed explicitly: when compaction already ate the
84
+ // originals, `makeWhat` still lists them (role `unknown`, empty excerpt) and
85
+ // reports `replacedMore`, so the row keeps saying "丢弃了 N 条消息" instead
86
+ // of collapsing to a content-free line.
87
+ replacedSeqs: removed,
88
+ // The action's own NEW text is NOT reconstructible here: the carrier keeps
89
+ // only the archive notice (measured: every carrier's second segment reads
90
+ // "(此处内容已被撤回…)"), and the live continuation is a different node.
91
+ // Leaving it empty keeps the client from printing a misleading 延续 line.
92
+ newText: '',
93
+ })
94
+ out.push({
95
+ boundarySeq,
96
+ turn,
97
+ versionId: typeof version.versionId === 'string' ? version.versionId : `v${boundarySeq}`,
98
+ kind,
99
+ at: typeof event.time === 'number' ? event.time : undefined,
100
+ what,
101
+ discardedSeqs: removed.filter((seq) => boundarySet.has(seq)),
102
+ discardedCount: removed.length,
103
+ derived: true,
104
+ })
105
+ }
106
+ return out
107
+ }