dsh-retrace 0.4.26 → 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,13 +305,74 @@ 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
 
308
+ ### Host-side breaking changes that `0.4.28` adapts to — *not caused by this plugin*
309
+
310
+ 1. **`@deepseek-ai/dsh-session` removed the `Session.events` member** (in `0.1.5-rc.1`).
311
+ The class has no `events` field and no `events` getter at all any more; the supported
312
+ readers are `snapshotEvents(fromSeq, toSeqExclusive)` (frozen, sequence-indexed),
313
+ `eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.28` reaches the log through a
314
+ compatibility accessor that prefers the new API and falls back to the old array, so it
315
+ runs on both host generations.
316
+ **Symptom before the fix:** recall and edit did nothing and surfaced the raw error
317
+ `TypeError: Cannot read properties of undefined (reading 'length')` — both operations
318
+ start by locating the target message id, and that lookup read the removed member.
319
+ 2. **The client-side session store has no `keys()`** (`ctx.sessions`). A plugin that
320
+ enumerates sessions with `keys()` silently sees **zero** of them: no crash, no error,
321
+ just safety warnings that never fire. `0.4.28` prefers the official `list()` and falls
322
+ back to `keys()`; it deliberately does **not** fall back to enumerating service fields,
323
+ because guessing produces a silent empty result as well.
324
+ 3. **The client session controller has no title accessor** — `getTitle` does not exist
325
+ anywhere in `@deepseek-ai/dsh-api-session-controller`, and its `getSnapshot()` carries
326
+ no `title`. See the plugin-side item below: this one used to *overwrite your titles*.
327
+
328
+ ### Plugin-side fixes in `0.4.28` (these are ours)
329
+
330
+ - **The edit / recall affordances never appeared at all.** The client half read chat
331
+ nodes from `snapshot.chat.nodes`, a path this host build does not have — nodes live in
332
+ the `useChat` store (`snapshot.nodes`). Every message-level component threw while
333
+ rendering and was swallowed by the error boundary, so the buttons were missing, while
334
+ the settings entry (which reads no nodes) rendered fine. Fixed: the client half now
335
+ takes `useChat` from the slot contract.
336
+ - **"Jump to message" in the version and fork views did nothing.** It resolved the target
337
+ anchor through `store.getSnapshot()?.chat?.nodes`, which is permanently `undefined`
338
+ here. It now resolves through the `useChat` snapshot injected by the view and pages
339
+ with the official `store.loadThrough(seq)`; when the jump cannot complete it reports a
340
+ **diagnosable reason** (renderer warning + host-log line) instead of failing silently.
341
+ - **Assigning a short code could overwrite your session title.** The client composed
342
+ `[CODE] <current title>` locally but had no way to read the current title, so the base
343
+ degraded to the session-id prefix (`[XXXXXX] <session-id-prefix>`). Title tagging now goes
344
+ through the host route only (`setBadgeTitle`), which reads the current title from the
345
+ session log. Manual renames are unaffected.
346
+ - **Host-side operation failures are logged again** (code + message + stack). They used to
347
+ return the message to the UI without a log line, which is why this whole class of bug
348
+ was hard to diagnose from outside.
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.
301
360
  ### Upgrading
302
361
 
303
362
  ```bash
304
- dsh plugin --profile desktop add dsh-retrace@0.4.26
363
+ dsh plugin --profile desktop add dsh-retrace@0.4.28
305
364
  # then restart DSH — plugins are not hot-reloaded
306
365
  ```
307
366
 
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
372
+ re-written, and nothing has to be re-indexed: upgrade, restart, and the two symptoms above
373
+ are gone. If you are on a host that still provides the old members, the compatibility
374
+ accessors keep those paths working — this build does not drop older hosts.
375
+
308
376
  If the app **fails to boot after an upgrade**, a single failing plugin can take the
309
377
  whole tree down, so recover first and diagnose second:
310
378
 
@@ -325,6 +393,17 @@ the **host surface**, not by semver alone.
325
393
 
326
394
  ## ⚠️ Requirements & limitations
327
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**.
328
407
  - Only **user messages** can be edited; recall works on user and assistant
329
408
  messages. Tool results are shadowed along with the recalled range but are not
330
409
  themselves recall targets.
@@ -345,7 +424,8 @@ the **host surface**, not by semver alone.
345
424
 
346
425
  - Recall / edit-and-resend / regenerate, each written through a three-layer
347
426
  **pre-write contract guard** and a safe-edit path (auto-stop the agent, temp-step
348
- 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).
349
429
  - In-session **version timeline** + **artifact rollback** (git-first, snapshot
350
430
  fallback, dry-run preview, jump-to-conversation).
351
431
  - **Fork map + session lineage** in the conversation view.
@@ -369,9 +449,10 @@ the **host surface**, not by semver alone.
369
449
 
370
450
  > Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
371
451
 
372
- **What's next** — see the [public roadmap](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md) for the agent
373
- business-layer plan (runtime guard, interruption governance, ecosystem-facing
374
- 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.
375
456
 
376
457
  ---
377
458
 
@@ -419,9 +500,9 @@ and the [issue tracker](https://github.com/yamingmou/dsh-retrace/issues).
419
500
 
420
501
  Listed on the [dsh-plugin topic](https://github.com/topics/dsh-plugin).
421
502
 
422
- Part of the **Agent business layer (production-grade guarantees)** — see the
423
- [public roadmap](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md) for the framework-agnostic layer and how
424
- 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:
425
506
 
426
507
  - [**dsh-log-contract**](https://github.com/yamingmou/dsh-log-contract) — the
427
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') {
@@ -39,6 +39,8 @@ import { editorError, editorId, lastModelSource } from '../host-core.js'
39
39
  import { assertContract, assertSpanShape, assertMarkerShape, assertAuditShape, runtimeSurfaceOpShape, contractViolation } from './contract.js'
40
40
  // 载体形状/文案的唯一真相(纯模块零 import;生成动态件时与 contract 一同 inline)。
41
41
  import { AUDIT_EVENT_TYPE, CARRIER_EVENT_TYPE, CARRIER_SOURCE_KIND, TRACE_TEXT } from '../marker-carrier.js'
42
+ // Host event-view compatibility (new host: snapshotEvents/eventAt; old host: events array).
43
+ import { sessionEvents, eventAt, nextAppendSeq } from '../host-compat.js'
42
44
 
43
45
  /**
44
46
  * 第 2 段的 content(留痕 + 可解释载体)。
@@ -99,12 +101,13 @@ function resolveMeter(meter) {
99
101
  */
100
102
  function priceByNode({ service, deriveMessage, session, seqs }) {
101
103
  if (typeof deriveMessage !== 'function' || typeof service.estimateMessage !== 'function') return null
102
- const events = Array.isArray(session?.events) ? session.events : null
103
- if (!events) return null
104
+ // 无事件视图(既无 snapshotEvents 也无 events 数组)→ 该来源不可用(null,不再是静默空)。
105
+ const hasEventView = typeof session?.snapshotEvents === 'function' || Array.isArray(session?.events)
106
+ if (!hasEventView) return null
104
107
  let tokens = 0
105
108
  const missing = []
106
109
  for (const seq of seqs) {
107
- const event = Number.isSafeInteger(seq) ? events[seq] : undefined
110
+ const event = Number.isSafeInteger(seq) ? eventAt(session, seq) : undefined
108
111
  let price = null
109
112
  if (event !== undefined && event !== null) {
110
113
  try {
@@ -251,14 +254,24 @@ export function createDshMarkerWriter({ validateMarker, log = () => {}, meter, d
251
254
  ? { op: 'replace', startSeq: span.start, endSeq: span.end }
252
255
  : { op: 'replace', start: span.start, end: span.end }
253
256
  const shadowed = Array.isArray(span.shadowedSeqs) ? span.shadowedSeqs.slice() : []
254
- // ── 写前断言(在任何 append 之前;seq 是唯一无法提前得知的字段)──
255
- // ② 第 2 段:先按「被遮蔽段」预演一次(审计 seq 未定);第 1 段落盘后再用
256
- // **真实**审计 seq 复核一次(见 assertMarkerShape 的 auditSeq 选项)。
257
- const preview = { seq: 0, type: CARRIER_EVENT_TYPE, data, surfaceOp, sourceEventSeqs: shadowed.slice() }
258
- assertMarkerShape(preview, 'dshAdapter.writeMarker.marker(preview)', { runtimeShape: opShape })
259
- // 写前校验钩子**两阶段**(见 prewrite-guard):
260
- // 阶段 pre = 落盘前只跑业务闸(回档幅度等,不依赖 seq)⇒ 拒绝时**零写入**;
261
- // 阶段 post = 第 1 段已落盘、第 2 段未落盘,跑完整契约校验(审计 seq 真实存在)。
257
+ // ── 写前断言/校验(全部在任何 append 之前)──
258
+ // 两段结构**成对**(判据 = lib/marker-carrier.js:15 形状 / :23 sourceEventSeqs
259
+ // 首元素 = 第 1 段 seq / :32 同一读法)。任何在第 1 段落盘**之后**才失败的校验
260
+ // 都会留下孤儿审计行(2026-09-14 真机:seq 26032/26033 —— 官方 shadow-price
261
+ // claim 无人消费 ⇒ contextPressure.surfaceTokens 漂移)。
262
+ // 故把完整契约校验**前移**:审计段按它将被写入的位置合成进事件表,与载体一起校验。
263
+ // seq 是唯一无法提前得知的字段:交给 validateMarker 的信封**不带 seq**
264
+ // ——`createPreWriter.validateAppend` 只在 `candidate.seq === undefined` 时按
265
+ // nextSeq 赋值;带伪造 seq(历史硬编码 0)会被判 E2/S6/S8(见 marker-append-seq 测试)。
266
+ // `assertMarkerShape` 仍用 `seq: 0` 的**形状副本**(只要求 seq 是非负安全整数)。
267
+ const carrierEnvelope = (seqs) => ({ type: CARRIER_EVENT_TYPE, data, surfaceOp, sourceEventSeqs: seqs })
268
+ const withShapeSeq = (envelope) => ({ seq: 0, ...envelope })
269
+ const preview = carrierEnvelope(shadowed.slice())
270
+ assertMarkerShape(withShapeSeq(preview), 'dshAdapter.writeMarker.marker(preview)', { runtimeShape: opShape })
271
+ // 写前校验钩子三态(见 prewrite-guard):
272
+ // pre = 落盘前只跑业务闸(回档幅度等,不依赖 seq)⇒ 拒绝时**零写入**;
273
+ // pair = **计划中的两段**(审计 + 载体)整体跑完整契约校验 ⇒ 拒绝时**零写入**;
274
+ // post = 兼容旧调用方的"第 1 段已落盘"形态(本 writer 已不使用)。
262
275
  // 业务闸先于「取令牌价」:业务拒绝的错误码不该被定价路径的内部错误盖掉。
263
276
  if (typeof validateMarker === 'function') {
264
277
  await validateMarker(session, preview, { phase: 'pre' })
@@ -283,35 +296,48 @@ export function createDshMarkerWriter({ validateMarker, log = () => {}, meter, d
283
296
  // ① 第 1 段:官方词表把 `compaction/prune` 的 data 定为精确三成员
284
297
  // (无 optional/opaque)⇒ 形状不合规一律在写盘前拦下。
285
298
  assertAuditShape({ seq: 0, type: AUDIT_EVENT_TYPE, data: auditData }, 'dshAdapter.writeMarker.audit(preview)')
286
- // 第 1 段先写:两段都须引用更早 seq,且关联方向只能是「第 2 段 → 第 1 段」。
287
- let auditSeq = appendAudit(session, auditData)
288
- // 第 2 段:首元素 = 审计 seq,其后是**全部**被遮蔽节点(官方 provenance 硬要求
289
- // 「须列全被遮蔽的面节点」)。
290
- let envelope = { seq: 0, type: CARRIER_EVENT_TYPE, data, surfaceOp, sourceEventSeqs: [auditSeq, ...shadowed] }
291
- assertMarkerShape(envelope, 'dshAdapter.writeMarker.marker', { runtimeShape: opShape, auditSeq })
292
- if (typeof validateMarker === 'function') {
293
- await validateMarker(session, envelope, { phase: 'post', auditSeq })
299
+ // ── pair 校验:两段作为整体,在任何 append 之前 ──────────────────────────
300
+ // 计划中的审计 seq = 当前追加位;校验后**同步复核**(到 appendAudit 之间无 await),
301
+ // 并发 append 使预言过期时重跑校验;连续漂移则零写入报错(不留孤儿)。
302
+ let plannedAuditSeq = nextAppendSeq(session)
303
+ let pairValidated = typeof validateMarker !== 'function'
304
+ for (let attempt = 0; attempt < 3 && !pairValidated; attempt++) {
305
+ const pairEnvelope = carrierEnvelope([plannedAuditSeq, ...shadowed])
306
+ assertMarkerShape(withShapeSeq(pairEnvelope), 'dshAdapter.writeMarker.marker(pair)', { runtimeShape: opShape, auditSeq: plannedAuditSeq })
307
+ await validateMarker(session, pairEnvelope, { phase: 'pair', audit: auditData, auditSeq: plannedAuditSeq })
308
+ const live = nextAppendSeq(session)
309
+ if (live === plannedAuditSeq) pairValidated = true
310
+ else plannedAuditSeq = live
311
+ }
312
+ if (!pairValidated) {
313
+ throw editorError(
314
+ 'marker-pair-race',
315
+ 'Concurrent appends kept moving the log tail while validating the two-segment marker; nothing was written. Retry.',
316
+ )
294
317
  }
318
+ // ── 同步段:审计 + 载体,中间没有任何 await ────────────────────────────
295
319
  // 官方 shadow-price 协议要求 claim 与 replace **紧邻**(surface-projection:
296
320
  // "producers append the metering event and the replacement synchronously
297
- // adjacent, so a surviving claim always prices the very next event")。上面两次
298
- // `await` 之间可能有并发 append 落盘 ⇒ claim 被顶掉 ⇒ 官方 fold 以 **0 增量**
299
- // 折叠(不抛、静默)。在同步段复核相邻性,被顶掉就重挂一条审计段
300
- // (旧段留在日志里:log-only,不遮蔽任何节点)。
321
+ // adjacent, so a surviving claim always prices the very next event")。旧流程在
322
+ // 两段之间有一次 post 校验 await ⇒ 并发 append 可能顶掉 claim;现在校验全部前移,
323
+ // 两段在同一同步段内落盘,相邻性由结构成立(rearmClaim 仅作兜底)。
324
+ const auditSeq = appendAudit(session, auditData)
325
+ if (auditSeq !== plannedAuditSeq) {
326
+ throw editorError(
327
+ 'marker-pair-race',
328
+ `Audit segment landed at seq ${auditSeq} but the two-segment marker was validated at ${plannedAuditSeq}; nothing further was written. Retry.`,
329
+ )
330
+ }
301
331
  const reArmed = rearmClaim(session, span, auditSeq, shadowedTokenCount, log)
302
- if (reArmed !== auditSeq) {
303
- auditSeq = reArmed
304
- envelope = { seq: 0, type: CARRIER_EVENT_TYPE, data, surfaceOp, sourceEventSeqs: [auditSeq, ...shadowed] }
305
- assertMarkerShape(envelope, 'dshAdapter.writeMarker.marker', { runtimeShape: opShape, auditSeq })
306
- // 重挂之后**不再 await**:任何 await 都会重新打开刚被顶掉的窗口,相邻性靠
307
- // "同步段内无并发落盘"成立。post 校验已针对重挂前那条同形状、同价、seq 更小的
308
- // 审计段跑过 ⇒ 这里只记一行说明(不再重跑,以免再次被顶掉)。
309
- log(`retrace: 已重挂审计段 seq ${auditSeq}(post 校验针对重挂前的审计段跑过:同形状、同价、seq 更小)`)
332
+ const finalAuditSeq = reArmed === auditSeq ? auditSeq : reArmed
333
+ if (finalAuditSeq !== auditSeq) {
334
+ log(`retrace: 已重挂审计段 seq ${finalAuditSeq}(并发顶掉;旧段留在日志里,log-only,不遮蔽任何节点)`)
310
335
  }
311
- // 第 2 段落盘。契约边界:可抛断言已全部完成(审计段是合法的 log-only 事件,
312
- // 即便此后极端失败也不影响会话可构造性——见负控制实测:只写第 1 段不遮蔽任何
313
- // 节点,官方链也照常接受)。
314
- return session.append(CARRIER_EVENT_TYPE, data, { surfaceOp, sourceEventSeqs: [auditSeq, ...shadowed] })
336
+ // 第 2 段落盘(首元素 = 审计 seq,其后是全部被遮蔽节点)。
337
+ const carrier = session.append(CARRIER_EVENT_TYPE, data, { surfaceOp, sourceEventSeqs: [finalAuditSeq, ...shadowed] })
338
+ // ── 写后对账:两段成对(判据见 lib/marker-carrier.js:23/:32)──
339
+ assertPairing(session, finalAuditSeq, carrier)
340
+ return carrier
315
341
  },
316
342
  }
317
343
  }
@@ -328,7 +354,7 @@ function appendAudit(session, auditData) {
328
354
 
329
355
  /** 日志末尾事件的 seq(会话对象无 events 视图时 null ⇒ 跳过相邻性复核)。 */
330
356
  function tailSeqOf(session) {
331
- const events = session?.events
357
+ const events = sessionEvents(session)
332
358
  if (!Array.isArray(events) || events.length === 0) return null
333
359
  const last = events[events.length - 1]
334
360
  return Number.isSafeInteger(last?.seq) ? last.seq : null
@@ -348,3 +374,30 @@ function rearmClaim(session, span, auditSeq, shadowedTokenCount, log) {
348
374
  shadowedTokenCount,
349
375
  })
350
376
  }
377
+
378
+ /**
379
+ * 写后对账:第 2 段与第 1 段**成对**。
380
+ *
381
+ * 判据取自契约本身(`lib/marker-carrier.js`):
382
+ * - `:15` 形状 = 第 1 段先写、第 2 段后写,两者都引用更早 seq;
383
+ * - `:23` 第 2 段 `sourceEventSeqs = [<第 1 段 seq>, …全部被遮蔽节点]`;
384
+ * - `:32` 关联读法(判据):第 2 段 `sourceEventSeqs` **首元素 = 第 1 段 seq**。
385
+ * 不满足 ⇒ 显式失败(marker-pair-unpaired),不静默返回半写结果。
386
+ * @param {object} session
387
+ * @param {number} auditSeq - 第 1 段(审计)seq
388
+ * @param {object} carrier - 第 2 段(载体)事件
389
+ */
390
+ function assertPairing(session, auditSeq, carrier) {
391
+ const auditEvent = eventAt(session, auditSeq)
392
+ const first = Array.isArray(carrier?.sourceEventSeqs) ? carrier.sourceEventSeqs[0] : undefined
393
+ const paired = first === auditSeq
394
+ && Number.isSafeInteger(carrier?.seq)
395
+ && carrier.seq === auditSeq + 1
396
+ && auditEvent?.type === AUDIT_EVENT_TYPE
397
+ if (!paired) {
398
+ throw editorError(
399
+ 'marker-pair-unpaired',
400
+ `Marker segments are not paired: carrier.seq=${String(carrier?.seq)} sourceEventSeqs[0]=${String(first)} audit seq=${auditSeq} (event type=${String(auditEvent?.type ?? 'missing')}). An orphan audit segment may remain in the log () — do not ignore.`,
401
+ )
402
+ }
403
+ }
@@ -4,7 +4,7 @@
4
4
  * DSH 平台适配器(2026-09-01)——实现 EventReader 接口。
5
5
  *
6
6
  * 职责:从 DSH 会话文件(session.jsonl.zstd)读全量事件——可靠事实,
7
- * 不依赖 host 内存视图(DSH 2.0.3 host 的 session.events 可能稀疏/窗口化)。
7
+ * 不依赖 host 内存视图(DSH 2.0.3 host 的事件视图可能稀疏/窗口化)。
8
8
  *
9
9
  * 换架构时:业务层(message-list.js/守卫)零改动,新平台实现自己的 EventReader
10
10
  * (读自己的日志格式 → 同样的通用事件结构)。
@@ -12,8 +12,7 @@
12
12
  // 会话文件定位($DSH_HOME/sessions → ~/dsh-v3/sessions → ~/.dsh/sessions;两种文件名
13
13
  // 都认、同目录并存取 mtime 新者)收敛到 lib/platform/session-paths.js 单一实现——
14
14
  // 此前本文件与 watchdog/archaeology-cli 各写一份硬编码,基座换代时口径必然分叉。
15
- import { homedir } from 'node:os'
16
- import { sessionFilePath, activeSessionsRoot, listSessionFiles } from '../platform/session-paths.js'
15
+ import { sessionFilePath, activeSessionsRoot, listSessionFiles, workspaceAbbr } from '../platform/session-paths.js'
17
16
  // 官方 foldSurface:重放得与写入端完全一致的 surface nodes(replace 插 marker、
18
17
  // 遮蔽移除节点 → nodes 非 seq 单调;span 计算必须用它,否则 start/end indexOf
19
18
  // 会 not found/倒置 → S4/S8 拒 → 撤回死锁)。peerDep 提供。
@@ -215,7 +214,7 @@ export function computeSpan(events, target, mode = 'round') {
215
214
  * 文件侧「目标同一轮的前置 user 原文」——regenerate
216
215
  * 重发文本的唯一可靠来源。
217
216
  *
218
- * 为什么必须算在文件侧:host 内存 session.events 是窗口化/稀疏视图(带 undefined 洞),
217
+ * 为什么必须算在文件侧:host 内存事件视图是窗口化/稀疏视图(带 undefined 洞),
219
218
  * regenerate 在内存里「向前找最近的前置 user」会越过洞(洞里正是该轮 user)或越过被
220
219
  * 遮蔽区间,选到**更早轮**的 user → 重发错文本 + marker targetSeq 指向错轮。round span
221
220
  * 的起点在文件全量 events + 官方 foldSurface nodes 上就是目标同一轮的轮首 user
@@ -286,7 +285,7 @@ export const dshAdapter = {
286
285
  },
287
286
  /**
288
287
  * 从文件全量事件算某 turn 内的最大 step 号(情形② marker step 分配用)。
289
- * 绕开 host 窗口化 session.events(稀疏内存视图可能看不到 turn 内全部 step,
288
+ * 绕开 host 窗口化事件视图(稀疏内存视图可能看不到 turn 内全部 step,
290
289
  * 算小 → 新 step 号与窗口外既有 step 冲突 = step key 冲突白屏)。
291
290
  * 失败返回 null(调用方 fallback 内存扫描)。
292
291
  * @param {string} [filePath] 可选:直接指定会话文件路径(测试注入)。
@@ -294,38 +293,16 @@ export const dshAdapter = {
294
293
  }
295
294
 
296
295
  // ─────────────────────────────────────────────────────────────────────────────
297
- // 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与修复线
296
+ // 语义短码推导(2026-09-02)——工作区 createdAt 序号 + 父链,与外部工具
298
297
  // 短码表生成器同规则(表的新鲜版,不冲突)。
299
298
  // 短码 = 工作区2 + 序号3 + 父工作区2 + 父序号3;根父 = FF000。
300
299
  // 只读会话文件帧1(header),全量 ~110 会话 ≈ 30ms,懒加载缓存。
301
300
  // ─────────────────────────────────────────────────────────────────────────────
302
301
 
303
302
  /**
304
- * 工作区目录名 → 2 位缩写。
305
- *
306
- * 会话目录名是**工作区绝对路径**把 `/` 换成 `-`、首尾再各加 `--`
307
- * (`/Users/<user>/proj` → `--Users-<user>-proj--`)。缩写口径:先剥掉
308
- * **机器相关前缀**(用户 home),再剥掉平台前缀(Users、home、Volumes 等),
309
- * 最后取剩下前两段的首字母;只有一段时取其前两个字符。
310
- *
311
- * ⚠️ 这里**不得**写死任何具体用户名 —— 旧实现硬编码了某一台机器的用户名,
312
- * 换台机器就会把工作区缩写算错(进而是错误的短码)。home 由 `os.homedir()`
313
- * 推出,因此本函数在任何机器上自洽。
303
+ * 工作区目录名 → 2 位缩写:实现见 `lib/platform/session-paths.js`(单一真相;
304
+ * 短码侧 `lib/identity/shortcode.js` 与本文件的推导器共用同一份,避免换机器分叉)。
314
305
  */
315
- function workspaceAbbr(workspace) {
316
- const raw = String(workspace ?? '')
317
- const encoded = raw.replace(/^--/, '').replace(/--$/, '')
318
- let rest = encoded
319
- const homeEnc = homedir().replace(/\//g, '-').replace(/^-+/, '').replace(/-+$/, '')
320
- if (homeEnc && (rest === homeEnc || rest.startsWith(homeEnc + '-'))) {
321
- rest = rest.slice(homeEnc.length).replace(/^-+/, '')
322
- } else {
323
- rest = rest.replace(/^-*(?:Users|home|Volumes|private|var|tmp)-/i, '')
324
- }
325
- const parts = rest.split('-').filter(Boolean)
326
- if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toLowerCase()
327
- return (parts[0] ?? encoded).slice(0, 2).toLowerCase()
328
- }
329
306
 
330
307
  /** 派生短码表:扫活动基座各工作区会话 header,按 createdAt 排序编号,含父链。 */
331
308
  export async function deriveBadgeTable(opts = {}) {
@@ -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
+ }