dsh-retrace 0.4.26 → 0.4.27

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
@@ -298,13 +298,60 @@ Read it before filing an issue.
298
298
  - Stale peer declarations with no remaining import site removed
299
299
  (`@deepseek-ai/dsh-home-paths`, `@deepseek-ai/dsh-client-runtime`).
300
300
 
301
+ ### Host-side breaking changes that `0.4.27` adapts to — *not caused by this plugin*
302
+
303
+ 1. **`@deepseek-ai/dsh-session` removed the `Session.events` member** (in `0.1.5-rc.1`).
304
+ The class has no `events` field and no `events` getter at all any more; the supported
305
+ readers are `snapshotEvents(fromSeq, toSeqExclusive)` (frozen, sequence-indexed),
306
+ `eventAt(seq)`, `ownEvents()` and `isOwnSeq(seq)`. `0.4.27` reaches the log through a
307
+ compatibility accessor that prefers the new API and falls back to the old array, so it
308
+ runs on both host generations.
309
+ **Symptom before the fix:** recall and edit did nothing and surfaced the raw error
310
+ `TypeError: Cannot read properties of undefined (reading 'length')` — both operations
311
+ start by locating the target message id, and that lookup read the removed member.
312
+ 2. **The client-side session store has no `keys()`** (`ctx.sessions`). A plugin that
313
+ 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
315
+ back to `keys()`; it deliberately does **not** fall back to enumerating service fields,
316
+ because guessing produces a silent empty result as well.
317
+ 3. **The client session controller has no title accessor** — `getTitle` does not exist
318
+ anywhere in `@deepseek-ai/dsh-api-session-controller`, and its `getSnapshot()` carries
319
+ no `title`. See the plugin-side item below: this one used to *overwrite your titles*.
320
+
321
+ ### Plugin-side fixes in `0.4.27` (these are ours)
322
+
323
+ - **The edit / recall affordances never appeared at all.** The client half read chat
324
+ nodes from `snapshot.chat.nodes`, a path this host build does not have — nodes live in
325
+ the `useChat` store (`snapshot.nodes`). Every message-level component threw while
326
+ rendering and was swallowed by the error boundary, so the buttons were missing, while
327
+ the settings entry (which reads no nodes) rendered fine. Fixed: the client half now
328
+ takes `useChat` from the slot contract.
329
+ - **"Jump to message" in the version and fork views did nothing.** It resolved the target
330
+ anchor through `store.getSnapshot()?.chat?.nodes`, which is permanently `undefined`
331
+ here. It now resolves through the `useChat` snapshot injected by the view and pages
332
+ with the official `store.loadThrough(seq)`; when the jump cannot complete it reports a
333
+ **diagnosable reason** (renderer warning + host-log line) instead of failing silently.
334
+ - **Assigning a short code could overwrite your session title.** The client composed
335
+ `[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
337
+ through the host route only (`setBadgeTitle`), which reads the current title from the
338
+ session log. Manual renames are unaffected.
339
+ - **Host-side operation failures are logged again** (code + message + stack). They used to
340
+ return the message to the UI without a log line, which is why this whole class of bug
341
+ was hard to diagnose from outside.
342
+
301
343
  ### Upgrading
302
344
 
303
345
  ```bash
304
- dsh plugin --profile desktop add dsh-retrace@0.4.26
346
+ dsh plugin --profile desktop add dsh-retrace@0.4.27
305
347
  # then restart DSH — plugins are not hot-reloaded
306
348
  ```
307
349
 
350
+ **`0.4.27` needs no data migration.** The session format is unchanged (v3), no session is
351
+ re-written, and nothing has to be re-indexed: upgrade, restart, and the two symptoms above
352
+ are gone. If you are on a host that still provides the old members, the compatibility
353
+ accessors keep those paths working — this build does not drop older hosts.
354
+
308
355
  If the app **fails to boot after an upgrade**, a single failing plugin can take the
309
356
  whole tree down, so recover first and diagnose second:
310
357
 
@@ -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] 可选:直接指定会话文件路径(测试注入)。
@@ -301,31 +300,9 @@ export const dshAdapter = {
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 = {}) {