dsh-retrace 0.4.27 → 0.4.28

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.28
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.
@@ -416,9 +449,10 @@ the **host surface**, not by semver alone.
416
449
 
417
450
  > Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
418
451
 
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.
452
+ **What's next** — the agent business-layer plan (runtime guard, interruption
453
+ governance, ecosystem-facing interfaces) is **not published yet**: it is a plan,
454
+ not a shipped capability. This README describes the **development line (main)**,
455
+ which may run ahead of the latest npm release.
422
456
 
423
457
  ---
424
458
 
@@ -466,9 +500,9 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
466
500
 
467
501
  Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
468
502
 
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:
503
+ Part of the **Agent business layer (production-grade guarantees)** — the
504
+ framework-agnostic layer that dsh-retrace implements on DeepSeek Harness.
505
+ Companion components:
472
506
 
473
507
  - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) — the
474
508
  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
  - **实时看门狗**——并发写入第一时间快照日志。
@@ -319,7 +332,7 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
319
332
  > 桌面说明:Electron 宿主退出时销毁窗口,页面 `beforeunload` 不会触发,宿主也未暴露
320
333
  > 插件可用的退出否决点——桌面侧由运行中横幅 + dispose 提示覆盖;Web 端拦截完整生效。
321
334
 
322
- **未来计划**——见 [公开路线图](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md)(agent 业务层规划:运行时守护、中断治理、生态开放接口)。本 README 只描述已上线的能力。
335
+ **未来计划**——agent 业务层规划(运行时守护、中断治理、生态开放接口)**尚未发布**,此节是**计划**而非已上线能力。本 README 描述的是**开发线(main)**,可能领先于 npm 上最新发布版。
323
336
 
324
337
  ---
325
338
 
@@ -366,8 +379,8 @@ npm pack --dry-run # 校验发布文件清单
366
379
 
367
380
  收录于 [dsh-plugin topic](https://github.com/topics/dsh-plugin)。
368
381
 
369
- **Agent 业务层(生产级保证)** 的一部分——见 [公开路线图](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md)
370
- (框架无关的业务层定义,dsh-retrace 是它在 DeepSeek Harness 上的实现)。配套组件:
382
+ **Agent 业务层(生产级保证)** 的一部分——即 dsh-retrace 在 DeepSeek Harness 上实现的
383
+ 那层框架无关的业务层定义。配套组件:
371
384
 
372
385
  - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) —— 业务层的
373
386
  「医生」: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,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
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * dsh-retrace — lib/boundary-now.js
3
+ *
4
+ * READ-SIDE "现在这条" (the counterpart that lives in the conversation TODAY).
5
+ *
6
+ * Why this exists (real-machine reading order, 2026-09-15): an entry could say
7
+ * WHAT it replaced ("原来的内容:…") but never WHERE it was replaced FROM. The
8
+ * user's words: 「这个条目,现在是什么我并不知道」. The anchor is the message the
9
+ * action left behind — for an edit/resend it is OUR own `retrace-resend-*` node
10
+ * (measured: boundary #8699 → #8706), for a regenerate it is the assistant reply
11
+ * the host produced afterwards.
12
+ *
13
+ * What this module deliberately does NOT do: reconstruct the round's input. The
14
+ * conversation itself is readable — only the REPLACED copy is ours to keep.
15
+ *
16
+ * Rules (measured on a 27k-event / 34-boundary session):
17
+ * - only `edit` / `regenerate` have a counterpart. A pure `recall` retracts and
18
+ * is followed by later, unrelated input — picking that would be a lie.
19
+ * - the counterpart must be a LIVE node: anything a later boundary already
20
+ * replaced (`shadowedSeqs`) is excluded, otherwise the entry would point at
21
+ * a message that is no longer on the surface.
22
+ * - the boundary's OWN carrier / audit events are never the counterpart: they
23
+ * are the marker, not the new content.
24
+ *
25
+ * Pure (no I/O, no host access): the caller passes the log array and the shadowed
26
+ * set, so the seam, the HTTP route and the tests all drive the same rule.
27
+ */
28
+ import { eventText, excerpt, roleOf } from './boundary-what.js'
29
+ import { isReplacementSurfaceEvent, replacedSeqsOfBoundary } from './version-index.js'
30
+
31
+ /** Our own resend node: written by the edit/resend op. */
32
+ export const RESEND_ID_PREFIX = 'retrace-resend-'
33
+
34
+ /** Any id we write (marker / carrier / resend / fold) — never a counterpart. */
35
+ const OUR_ID_PREFIX = 'retrace-'
36
+
37
+ /** `<id>` of a surface event, whichever slot the host used. */
38
+ function idOf(event) {
39
+ const id = event?.data?.id ?? event?.data?.message?.id
40
+ return typeof id === 'string' ? id : ''
41
+ }
42
+
43
+ /** One counterpart candidate as the reader needs it (no raw seq shown). */
44
+ function nowOf(event) {
45
+ return {
46
+ seq: event.seq,
47
+ role: roleOf(event),
48
+ excerpt: excerpt(eventText(event)),
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Index the log once: the counterpart candidates, in ascending seq order.
54
+ *
55
+ * @param {object[]} events - the session's events (ascending seq)
56
+ * @param {Set<number>} [shadowedSeqs] - seqs replaced by SOME boundary (ours or
57
+ * the host's). Those are off the surface and cannot be "现在这条".
58
+ * @returns {{resends: object[], replies: object[]}}
59
+ */
60
+ export function buildNowIndex(events, shadowedSeqs = new Set()) {
61
+ const list = Array.isArray(events) ? events : []
62
+ const shadowed = shadowedSeqs instanceof Set ? shadowedSeqs : new Set()
63
+ const resends = []
64
+ const replies = []
65
+ for (const event of list) {
66
+ const seq = event?.seq
67
+ if (!Number.isSafeInteger(seq) || shadowed.has(seq)) continue
68
+ const id = idOf(event)
69
+ if (id.startsWith(RESEND_ID_PREFIX)) {
70
+ resends.push(nowOf(event))
71
+ continue
72
+ }
73
+ // A regenerate's counterpart is the host's new assistant reply. Our own
74
+ // `retrace-*` nodes are markers, never the reply itself.
75
+ if (event.type === 'assistant/message' && !id.startsWith(OUR_ID_PREFIX)) replies.push(nowOf(event))
76
+ }
77
+ resends.sort((a, b) => a.seq - b.seq)
78
+ replies.sort((a, b) => a.seq - b.seq)
79
+ return { resends, replies }
80
+ }
81
+
82
+ /**
83
+ * The counterpart of ONE boundary, or null when there is honestly none.
84
+ *
85
+ * @param {string} kind - `classifyBoundaryKind` result (`edit` / `regenerate` /
86
+ * `recall` / `compaction` / `replace`)
87
+ * @param {number} boundarySeq
88
+ * @param {{resends: object[], replies: object[]}} index - from `buildNowIndex`
89
+ * @returns {{seq:number, role:string, excerpt:string}|null}
90
+ */
91
+ export function nowOfBoundary(kind, boundarySeq, index) {
92
+ if (kind !== 'edit' && kind !== 'regenerate') return null
93
+ if (!Number.isSafeInteger(boundarySeq) || index === null || index === undefined) return null
94
+ const candidates = kind === 'edit' ? index.resends : index.replies
95
+ if (!Array.isArray(candidates)) return null
96
+ for (const candidate of candidates) {
97
+ if (Number.isSafeInteger(candidate?.seq) && candidate.seq > boundarySeq) return candidate
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Every seq that some SURFACE REPLACEMENT took off the surface (ours AND the
104
+ * host's). Used to keep a counterpart from pointing at a message that is no
105
+ * longer live.
106
+ *
107
+ * Scope = THIS PLUGIN's own replacements (`retrace-*` markers). Two things stay
108
+ * out, both measured on the real session:
109
+ *
110
+ * - ordinary events that merely CITE their precursors (`tool/result` → its
111
+ * `tool/call`). Counting those marked 14644 seqs "shadowed" instead of 107.
112
+ * - other surface owners' bulk operations (another plugin's fold ranges, the
113
+ * host's compaction checkpoints). They hide ranges from the VIEW/CONTEXT
114
+ * while the messages stay in the log. Counting them made every one of the 23
115
+ * real edits report "the counterpart is no longer in the log", including the
116
+ * case this reading order was specified from: boundary #8699 → the resend
117
+ * node #8706.
118
+ *
119
+ * Our own later replacement is different: the marker stands in that node's place
120
+ * for good, so that node can never be "现在这条" again.
121
+ *
122
+ * @param {object[]} events
123
+ * @param {(event:object)=>boolean} [isOurs] - id predicate for this plugin's
124
+ * markers (defaults to the `retrace-` prefix).
125
+ * @returns {Set<number>}
126
+ */
127
+ export function shadowedSeqsOf(events, isOurs = null) {
128
+ const owns = typeof isOurs === 'function' ? isOurs : (event) => idOf(event).startsWith(OUR_ID_PREFIX)
129
+ const shadowed = new Set()
130
+ if (!Array.isArray(events)) return shadowed
131
+ for (const event of events) {
132
+ if (!isReplacementSurfaceEvent(event)) continue
133
+ if (!owns(event)) continue
134
+ for (const seq of replacedSeqsOfBoundary(event, null)) {
135
+ if (Number.isSafeInteger(seq)) shadowed.add(seq)
136
+ }
137
+ }
138
+ return shadowed
139
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * dsh-retrace — lib/boundary-tree.js
3
+ *
4
+ * The read-point outline forest: "which change landed inside which discarded
5
+ * set". Derived from our own boundary artifact (one record per boundary, each
6
+ * carrying `discardedSeqs` — the exact surface nodes that operation removed),
7
+ * so the client can indent nested branches without touching the projection wire.
8
+ *
9
+ * RULE (frozen 2026-09-14, corrected by measurement; DO NOT change back to set
10
+ * inclusion):
11
+ * parent(C) = the boundary B whose discarded set CONTAINS C's own event seq,
12
+ * `C.seq ∈ S(B)`, choosing the SMALLEST such |S(B)|.
13
+ *
14
+ * Why NOT `S(C) ⊆ S(B)` (the rule first written into the contract): measured on
15
+ * a real 24k-event session, the child's discarded nodes were **already dead**
16
+ * when the parent replaced its range (the child ran FIRST), so
17
+ * `|S(child) ∩ S(parent)| = 0` for every one of the 8 links of the real 9-layer
18
+ * chain. The parent's set only holds the child's MARKER event seq — that node was
19
+ * still on the surface at that moment. Inclusion therefore yields a degenerate
20
+ * forest (1 parent / depth 1) instead of 14 parents / depth 9. Membership is
21
+ * still an EXACT SET operation: no numeric interval is ever enumerated, and no
22
+ * `start ≤ end` ordering is assumed — a reversed range (`[16093..15458]`, real
23
+ * data) never enters this module.
24
+ *
25
+ * The discarded sets may be STORED SLIMMED (`S ∩ allBoundarySeqs`, see
26
+ * lib/versioning.js) because membership only ever asks about another boundary's
27
+ * seq; `discardedCount` then carries the exact `|S|`. Both forms must yield a
28
+ * byte-identical tree (pinned by a test).
29
+ *
30
+ * Zero imports (pure): the host, the route and the tests all share one rule.
31
+ */
32
+
33
+ /** Parse one record's discarded seqs into a Set (invalid entries dropped). */
34
+ function discardedSetOf(record) {
35
+ const raw = record?.discardedSeqs
36
+ if (!Array.isArray(raw)) return null
37
+ const set = new Set()
38
+ for (const seq of raw) if (Number.isSafeInteger(seq) && seq >= 0) set.add(seq)
39
+ return set
40
+ }
41
+
42
+ /** Exact `|S|`: the stored `discardedCount` when present, else the set's size. */
43
+ function discardedCountOf(record, set) {
44
+ return Number.isSafeInteger(record?.discardedCount) && record.discardedCount >= 0 ? record.discardedCount : set.size
45
+ }
46
+
47
+ /** Distance used to break equal-size parent candidates (nearest ancestor wins). */
48
+ function tieBreak(childSeq, a, b) {
49
+ const da = Math.abs(a.seq - childSeq)
50
+ const db = Math.abs(b.seq - childSeq)
51
+ if (da !== db) return da - db
52
+ return a.seq - b.seq
53
+ }
54
+
55
+ /**
56
+ * Build the outline forest from artifact records.
57
+ *
58
+ * @param {object[]} records - artifact lines ({ boundarySeq, discardedSeqs, ... })
59
+ * @returns {{
60
+ * tree: Record<string, {parent:number|null, children:number[], discardedCount:number}>,
61
+ * maxDepth: number,
62
+ * withChildren: number,
63
+ * nodes: number
64
+ * } | null} null when no record carries a usable `discardedSeqs`
65
+ * (old artifacts, or a session that never recorded one) — the caller then
66
+ * OMITS the field and the client falls back to a flat list.
67
+ */
68
+ export function boundaryTreeOf(records) {
69
+ if (!Array.isArray(records)) return null
70
+ // Latest record wins per boundary (the artifact is append-only).
71
+ const latest = new Map()
72
+ for (const record of records) {
73
+ if (!Number.isSafeInteger(record?.boundarySeq)) continue
74
+ latest.set(record.boundarySeq, record)
75
+ }
76
+ const entries = []
77
+ for (const [seq, record] of latest) {
78
+ const set = discardedSetOf(record)
79
+ if (set === null) continue
80
+ entries.push({ seq, set, count: discardedCountOf(record, set) })
81
+ }
82
+ if (entries.length === 0) return null
83
+
84
+ const childrenBySeq = new Map(entries.map((entry) => [entry.seq, []]))
85
+ const parentBySeq = new Map()
86
+ for (const child of entries) {
87
+ let best = null
88
+ for (const candidate of entries) {
89
+ if (candidate.seq === child.seq) continue
90
+ if (!candidate.set.has(child.seq)) continue
91
+ // Smallest containing set = the direct parent; an intermediate D would be
92
+ // smaller and would have won. Equal sizes fall back to the nearest seq.
93
+ if (best === null || candidate.count < best.count || (candidate.count === best.count && tieBreak(child.seq, candidate, best) < 0)) {
94
+ best = candidate
95
+ }
96
+ }
97
+ parentBySeq.set(child.seq, best === null ? null : best.seq)
98
+ if (best !== null) childrenBySeq.get(best.seq).push(child.seq)
99
+ }
100
+
101
+ const tree = {}
102
+ for (const entry of entries) {
103
+ tree[String(entry.seq)] = {
104
+ parent: parentBySeq.get(entry.seq) ?? null,
105
+ children: (childrenBySeq.get(entry.seq) ?? []).slice().sort((a, b) => a - b),
106
+ discardedCount: entry.count,
107
+ }
108
+ }
109
+
110
+ const depthMemo = new Map()
111
+ const depthOf = (seq) => {
112
+ if (depthMemo.has(seq)) return depthMemo.get(seq)
113
+ depthMemo.set(seq, 0) // cycle guard (a parent always has a larger discarded set)
114
+ const kids = childrenBySeq.get(seq) ?? []
115
+ const depth = kids.length === 0 ? 0 : 1 + Math.max(...kids.map(depthOf))
116
+ depthMemo.set(seq, depth)
117
+ return depth
118
+ }
119
+ let maxDepth = 0
120
+ let withChildren = 0
121
+ for (const entry of entries) {
122
+ maxDepth = Math.max(maxDepth, depthOf(entry.seq))
123
+ if ((childrenBySeq.get(entry.seq) ?? []).length > 0) withChildren += 1
124
+ }
125
+ return { tree, maxDepth, withChildren, nodes: entries.length }
126
+ }