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 +51 -17
- package/README.zh.md +20 -7
- package/bin/retrace.mjs +1 -1
- package/lib/adapter/contract.js +1 -1
- package/lib/adapter/dsh.js +1 -1
- package/lib/archaeology-cli.js +1 -1
- package/lib/badge.js +1 -1
- package/lib/boundary-derive.js +107 -0
- package/lib/boundary-now.js +139 -0
- package/lib/boundary-tree.js +126 -0
- package/lib/boundary-what.js +140 -0
- package/lib/client.bundle.js +1237 -415
- package/lib/client.js +1733 -482
- package/lib/dynamic-client.js +1237 -415
- package/lib/dynamic-host.js +24 -4
- package/lib/forkmap.js +15 -9
- package/lib/host-core.js +21 -1
- package/lib/http.js +90 -1
- package/lib/index.js +9 -2
- package/lib/llm-summary.js +289 -0
- package/lib/marker-carrier.js +2 -2
- package/lib/migration-traces.js +1 -1
- package/lib/platform/session-paths.js +1 -1
- package/lib/projection/forkmap.js +9 -2
- package/lib/projection/versions.js +4 -0
- package/lib/summary-gate.js +160 -0
- package/lib/summary-store.js +152 -0
- package/lib/version-index.js +58 -8
- package/lib/versioning.js +196 -2
- package/package.json +1 -1
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
|
|
24
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
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.
|
|
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`
|
|
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
|
|
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** —
|
|
420
|
-
|
|
421
|
-
|
|
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)** —
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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 包裹 ——
|
|
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
|
|
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
|
-
|
|
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 业务层(生产级保证)**
|
|
370
|
-
|
|
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
package/lib/adapter/contract.js
CHANGED
|
@@ -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
|
-
* 误判为契约违规(
|
|
83
|
+
* 误判为契约违规(现场实测:位置序 span 的 seq 数值非单调属正常写入)。
|
|
84
84
|
* 真正可判定的是:两端都是当前面上的节点 → 位置连续段 → 首尾一致(见下)。
|
|
85
85
|
*/
|
|
86
86
|
export function assertSpanShape(span, contract = 'Span.shape') {
|
package/lib/adapter/dsh.js
CHANGED
|
@@ -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,懒加载缓存。
|
package/lib/archaeology-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-retrace · lib/archaeology-cli.js
|
|
3
3
|
*
|
|
4
|
-
* 会话日志考古 CLI
|
|
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
|
@@ -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
|
+
}
|