@morlay/session-rdb 0.0.16-alpha.0 → 0.0.16-alpha.2

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/src/legacy.ts CHANGED
@@ -1,43 +1,62 @@
1
- // 旧格式(v0/v1)历史数据读取转换:RDB 行 → v0/v1 物理记录 → 上游迁移链 →
2
- // v2 逻辑事件。RDB 写路径从不持久化 sourceEventSeqs(读取时重计算),且
1
+ // 非当前格式(v0/v1/v2)历史数据读取转换:RDB 行 → 物理记录 → 上游迁移链 →
2
+ // 当前逻辑事件。RDB 写路径从不持久化 sourceEventSeqs(读取时重计算),且
3
3
  // delta(assistant/chunk)落盘被过滤——v1→v2 迁移对无 chunk 引用的
4
4
  // assistant/message 走「无 pending 直接 emit」路径,生成 stream: [] 的 v2 事件。
5
5
 
6
- import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
6
+ import {
7
+ SESSION_FORMAT_VERSION,
8
+ type SessionEvent,
9
+ type SessionHeader,
10
+ type SessionId,
11
+ } from "@deepseek-ai/dsh-session";
7
12
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
8
13
  import type { EventRow, SessionRow } from "./backend.ts";
14
+ import { repairRequestHeaders, rowToMeta, scanRows } from "./log.ts";
9
15
 
10
- /** 重建 v0/v1 物理 header 记录(RDB 行 → 物理 JSON 对象)。 */
16
+ /** 重建 v0/v1/v2 物理 header 记录(RDB 行 → 物理 JSON 对象)。 */
11
17
  function physicalHeader(row: SessionRow): Record<string, unknown> {
12
- return {
18
+ const common = {
13
19
  type: "session",
14
20
  version: row.fVersion,
15
21
  id: row.fSessionId,
16
22
  createdAt: row.fCreatedAt,
17
23
  ...(row.fCwd !== null ? { cwd: row.fCwd } : {}),
18
24
  ...(row.fParentSession !== null ? { parentSession: row.fParentSession } : {}),
19
- ...(row.fSeedLength !== null ? { seedLength: row.fSeedLength } : {}),
20
25
  ...(row.fOrigin !== null ? { origin: row.fOrigin } : {}),
21
26
  delegationDepth: row.fDelegationDepth ?? 0,
22
27
  };
28
+ // v0/v1 物理 header 用 seedLength 表达继承前缀;v2 起改用 isSeeded,
29
+ // 前缀长度由 log 里的 session/end-seed 标记推导。
30
+ return row.fVersion < 2
31
+ ? { ...common, ...(row.fSeedLength !== null ? { seedLength: row.fSeedLength } : {}) }
32
+ : { ...common, isSeeded: row.fSeedLength !== null };
23
33
  }
24
34
 
25
- /** 重建一条 v0/v1 物理事件记录(桥接行 + 事件行 → 物理 JSON 对象)。 */
35
+ /** 重建一条 v0/v1/v2 物理事件记录(桥接行 + 事件行 → 物理 JSON 对象)。 */
26
36
  function physicalEvent(row: EventRow): Record<string, unknown> {
37
+ const stored = JSON.parse(row.fData) as unknown;
38
+ // 两种存储形状:当前写入器落完整事件信封(与 JSONL 每行同构),schema v2
39
+ // 时代只落 data 部分。迁移链要的是 payload,故信封形状取 `data`。
40
+ const full =
41
+ typeof stored === "object" &&
42
+ stored !== null &&
43
+ !Array.isArray(stored) &&
44
+ typeof (stored as Record<string, unknown>)["type"] === "string" &&
45
+ "data" in (stored as Record<string, unknown>);
27
46
  return {
28
47
  type: row.fType,
29
48
  seq: row.fSequence,
30
49
  time: row.fCreatedAt,
31
- data: JSON.parse(row.fData) as unknown,
50
+ data: full ? (stored as Record<string, unknown>)["data"] : stored,
32
51
  ...(row.fSurfaceOp !== null ? { surfaceOp: JSON.parse(row.fSurfaceOp) as unknown } : {}),
33
52
  };
34
53
  }
35
54
 
36
55
  /**
37
- * 把旧格式(v0/v1)行转换为 v2 逻辑事件与 header。
38
- * @param row - 会话行(f_version < 2)。
56
+ * 把非当前格式(v0/v1/v2)行转换为当前逻辑事件与 header。
57
+ * @param row - 会话行(f_version < SESSION_FORMAT_VERSION)。
39
58
  * @param eventRows - 已扫描保留的桥接行(稠密 seq,torn tail 已剔除)。
40
- * @returns v2 逻辑 header、继承前缀长度与事件。
59
+ * @returns 当前逻辑 header、继承前缀长度与事件。
41
60
  * @throws 迁移链拒绝(格式损坏 / 无法迁移)时原样抛出。
42
61
  */
43
62
  export function convertLegacyRows(
@@ -59,9 +78,51 @@ export function convertLegacyRows(
59
78
  };
60
79
  }
61
80
 
62
- /** 会话行是否携带旧格式(v0/v1)数据。 */
81
+ /** 会话行是否携带非当前格式(f_version < SESSION_FORMAT_VERSION)数据。 */
63
82
  export function isLegacyVersion(version: number): boolean {
64
- return version < 2;
83
+ return version < SESSION_FORMAT_VERSION;
84
+ }
85
+
86
+ /**
87
+ * 迁移链拒绝后,把旧格式行直接当**当前格式**事件视图采用。
88
+ *
89
+ * 旧写入器只在建会话时落一次 header version,之后跟随上游演进继续追加
90
+ * 事件——同一个 log 因此可能是混合世代(v0 时代的行 + 新版本字段),不是
91
+ * 任何单一已发布格式,严格迁移链必然拒绝。这些行的数据形状(消息包装、
92
+ * 类型名)已经是当前格式,只需 header 版本归一到当前、按稠密 seq 读取;
93
+ * 事件数据的补全与 surface 修复由读取视图修复(repairReadView)负责。
94
+ *
95
+ * 调用方须在此之后执行 `validateStoredEvents`:真正属于旧格式的类型
96
+ * (`assistant/chunk` / `compact/*` 等)仍会被 fail-loud 拒绝。
97
+ *
98
+ * @param row - 会话行(f_version < SESSION_FORMAT_VERSION)。
99
+ * @param eventRows - 该会话全部桥接行(按 f_sequence 升序)。
100
+ * @returns 当前格式 header(version 归一到 SESSION_FORMAT_VERSION)、收缩后的
101
+ * 继承前缀长度、稠密事件与可选 torn tail 起点。
102
+ * @throws scanRows 的已提交区损坏错误(seq gap / 不可解析行)。
103
+ */
104
+ export function adoptLegacyRows(
105
+ row: SessionRow,
106
+ eventRows: readonly EventRow[],
107
+ ): {
108
+ meta: SessionHeader;
109
+ inheritedEventCount: number;
110
+ events: SessionEvent[];
111
+ tornFrom?: number;
112
+ } {
113
+ const { preserved, tornFrom } = scanRows(eventRows, 0);
114
+ // 旧格式 request/header 归一为当前格式:v3 起 system prompt 由 surface 上的
115
+ // system/message 承载,header 必须省略 system / 空 tools / 空 adapterDefaults
116
+ // (上游事件校验 fail loud)。归一必须发生在调用方 `validateStoredEvents`
117
+ // 之前,否则整个会话加载失败。
118
+ repairRequestHeaders(preserved);
119
+ return {
120
+ meta: { ...rowToMeta(row), version: SESSION_FORMAT_VERSION },
121
+ // 继承前缀不得超过事件数(历史 rewind 未收缩的损坏样式),上游 load 判损坏。
122
+ inheritedEventCount: Math.min(row.fSeedLength ?? 0, preserved.length),
123
+ events: preserved,
124
+ ...(tornFrom !== undefined ? { tornFrom } : {}),
125
+ };
65
126
  }
66
127
 
67
128
  export type { SessionId };
package/src/log.ts CHANGED
@@ -1,9 +1,30 @@
1
+ import { SessionSeq } from "@deepseek-ai/dsh-session";
1
2
  import type { SessionEvent, SessionHeader, SessionId, SurfaceOp } from "@deepseek-ai/dsh-session";
2
3
  import type { SessionFormatEvent, SessionFormatHeader } from "@deepseek-ai/dsh-session-format";
3
4
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
4
5
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
5
6
  import type { EventRow, SessionRow } from "./backend.ts";
6
7
 
8
+ /**
9
+ * 把桥接行列里的 replace surfaceOp 归一到当前字段名。
10
+ *
11
+ * v2 时代落库的 JSON 用 `start`/`end`,当前格式用 `startSeq`/`endSeq`;
12
+ * 混合世代回退路径直接采用存储行,必须在此归一,否则上游 v3 surface 校验
13
+ * 以「invalid replace surfaceOp」拒绝整个会话。非 replace 形状原样返回,
14
+ * 由读取视图修复决定降级。
15
+ */
16
+ function normalizeSurfaceOp(value: unknown): SurfaceOp {
17
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
18
+ return value as SurfaceOp;
19
+ }
20
+ const record = value as Record<string, unknown>;
21
+ if (record["op"] !== "replace") return value as SurfaceOp;
22
+ const startSeq = record["startSeq"] ?? record["start"];
23
+ const endSeq = record["endSeq"] ?? record["end"];
24
+ if (typeof startSeq !== "number" || typeof endSeq !== "number") return value as SurfaceOp;
25
+ return { op: "replace", startSeq: SessionSeq(startSeq), endSeq: SessionSeq(endSeq) };
26
+ }
27
+
7
28
  export function rowToMeta(row: SessionRow): SessionHeader {
8
29
  if (!Number.isSafeInteger(row.fCreatedAt) || row.fCreatedAt < 0) {
9
30
  throw new Error("stored session createdAt must be a non-negative safe integer");
@@ -76,7 +97,8 @@ export function sessionConflictRow(storage: SessionStorageMetadata): {
76
97
  }
77
98
 
78
99
  export function rowToEvent(row: EventRow): SessionEvent {
79
- const surfaceOp = row.fSurfaceOp !== null ? (JSON.parse(row.fSurfaceOp) as SurfaceOp) : undefined;
100
+ const surfaceOp =
101
+ row.fSurfaceOp !== null ? normalizeSurfaceOp(JSON.parse(row.fSurfaceOp) as unknown) : undefined;
80
102
  const record = JSON.parse(row.fData) as unknown;
81
103
  // fData 形状判别:新写入的完整事件(含 ignorable 信封,与 JSONL 每行
82
104
  // 同构)vs 旧 v2 库的纯 data 部分。完整事件必有 type/seq/time/data 四键。
@@ -107,7 +129,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
107
129
  } as SessionEvent;
108
130
  }
109
131
 
110
- const SURFACE_EVENT_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
132
+ const SURFACE_EVENT_TYPES = new Set([
133
+ "system/message",
134
+ "user/message",
135
+ "assistant/message",
136
+ "tool/result",
137
+ ]);
111
138
 
112
139
  const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"]);
113
140
 
@@ -126,12 +153,12 @@ const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"])
126
153
  export function recomputeReplaceProvenance(events: SessionEvent[]): void {
127
154
  for (let i = 0; i < events.length; i++) {
128
155
  const event = events[i]!;
129
- const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: number[] };
156
+ const raw = event as unknown as { surfaceOp?: unknown; sourceEventSeqs?: number[] };
130
157
  const op = raw.surfaceOp;
131
158
  if (typeof op !== "object" || op === null || (op as { op?: string }).op !== "replace") {
132
159
  continue;
133
160
  }
134
- const { start, end } = op as { start: number; end: number };
161
+ const { startSeq, endSeq } = op as { startSeq: number; endSeq: number };
135
162
  const metering = i > 0 ? events[i - 1] : undefined;
136
163
  const meteringData =
137
164
  metering !== undefined && METERING_EVENT_TYPES.has(metering.type)
@@ -144,8 +171,8 @@ export function recomputeReplaceProvenance(events: SessionEvent[]): void {
144
171
  const refs: number[] = [];
145
172
  for (const candidate of events) {
146
173
  if (
147
- candidate.seq >= start &&
148
- candidate.seq <= end &&
174
+ candidate.seq >= startSeq &&
175
+ candidate.seq <= endSeq &&
149
176
  SURFACE_EVENT_TYPES.has(candidate.type)
150
177
  ) {
151
178
  refs.push(candidate.seq);
@@ -211,12 +238,15 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
211
238
  degradeToAppend: Set<number>;
212
239
  addAppendMarker: Set<number>;
213
240
  clearSurfaceOp: Set<number>;
241
+ clampEnd: Map<number, number>;
214
242
  } {
215
243
  const nodes: number[] = [];
216
244
  const degradeToAppend = new Set<number>();
217
245
  const addAppendMarker = new Set<number>();
218
246
  const clearSurfaceOp = new Set<number>();
219
- for (const event of events) {
247
+ const clampEnd = new Map<number, number>();
248
+ for (let index = 0; index < events.length; index++) {
249
+ const event = events[index]!;
220
250
  const raw = event as SessionEvent & { surfaceOp?: unknown };
221
251
  const op = raw.surfaceOp;
222
252
  if (op === undefined) {
@@ -239,16 +269,29 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
239
269
  typeof op === "object" && op !== null && !Array.isArray(op)
240
270
  ? (op as Record<string, unknown>)
241
271
  : undefined;
242
- const start = replace?.["start"];
243
- const end = replace?.["end"];
272
+ const start = replace?.["startSeq"];
273
+ const end = replace?.["endSeq"];
244
274
  const shapeOk =
245
275
  replace !== undefined &&
246
276
  replace["op"] === "replace" &&
247
277
  isEventSeqLike(start) &&
248
278
  isEventSeqLike(end);
249
279
  const startIdx = shapeOk ? nodes.indexOf(start as number) : -1;
250
- const endIdx = shapeOk ? nodes.indexOf(end as number) : -1;
280
+ let endIdx = shapeOk ? nodes.indexOf(end as number) : -1;
281
+ // 旧写入器重编号事件后,存储的 range 端点可能落在旧坐标空间:end 不在
282
+ // 当前 surface 中,但紧邻 metering 事件的 shadowedSeqs 仍给出被遮蔽
283
+ // 节点数量。按数量从 start 起夹取到当前 surface 的同长区间,保住压缩
284
+ // 语义(否则只能降级 append,被压缩历史全部回到派生历史)。
285
+ let clamped: number | undefined;
286
+ if (shapeOk && startIdx !== -1 && endIdx === -1) {
287
+ clamped = clampReplaceEnd(events, index, nodes, startIdx);
288
+ if (clamped !== undefined) endIdx = nodes.indexOf(clamped);
289
+ }
251
290
  const rangeOk = shapeOk && startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx;
291
+ // assistant/message 的来源内嵌在 stream,上游禁止其携带 sourceEventSeqs,
292
+ // replace 的 provenance 因此永远无法满足 fold 校验——降级 append 是唯一
293
+ // 可加载形态。
294
+ const provenanceOk = event.type !== "assistant/message";
252
295
  let rewriteOk = true;
253
296
  if (rangeOk && event.type === "tool/result") {
254
297
  const shadowed = nodes.slice(startIdx, endIdx + 1);
@@ -260,14 +303,36 @@ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
260
303
  original?.type === "tool/result" && toolResultRewriteContentOnly(original, event);
261
304
  }
262
305
  }
263
- if (!rangeOk || !rewriteOk) {
306
+ if (!rangeOk || !rewriteOk || !provenanceOk) {
264
307
  degradeToAppend.add(event.seq);
265
308
  nodes.push(event.seq);
266
309
  continue;
267
310
  }
311
+ if (clamped !== undefined) clampEnd.set(event.seq, clamped);
268
312
  nodes.splice(startIdx, endIdx - startIdx + 1, event.seq);
269
313
  }
270
- return { degradeToAppend, addAppendMarker, clearSurfaceOp };
314
+ return { degradeToAppend, addAppendMarker, clearSurfaceOp, clampEnd };
315
+ }
316
+
317
+ /**
318
+ * 夹取一个 end 落在旧坐标空间的 replace 的结尾。
319
+ *
320
+ * 仅当 replace 紧邻一个 metering 事件且其 `shadowedSeqs` 是权威数量时成立;
321
+ * 夹取点是从 `start` 起的第 `shadowedSeqs.length` 个当前 surface 节点(不足
322
+ * 则取当前 surface 末尾)。无法夹取(start 也不在当前 surface)时返回
323
+ * `undefined`,由调用方降级为 append。
324
+ */
325
+ function clampReplaceEnd(
326
+ events: readonly SessionEvent[],
327
+ index: number,
328
+ nodes: readonly number[],
329
+ startIdx: number,
330
+ ): number | undefined {
331
+ const metering = index > 0 ? events[index - 1] : undefined;
332
+ if (metering === undefined || !METERING_EVENT_TYPES.has(metering.type)) return undefined;
333
+ const shadowedSeqs = (metering.data as unknown as { shadowedSeqs?: unknown }).shadowedSeqs;
334
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return undefined;
335
+ return nodes[Math.min(startIdx + shadowedSeqs.length - 1, nodes.length - 1)];
271
336
  }
272
337
 
273
338
  export function repairSurfaceOps(events: SessionEvent[]): void {
@@ -275,14 +340,23 @@ export function repairSurfaceOps(events: SessionEvent[]): void {
275
340
  if (
276
341
  repairs.degradeToAppend.size === 0 &&
277
342
  repairs.addAppendMarker.size === 0 &&
278
- repairs.clearSurfaceOp.size === 0
343
+ repairs.clearSurfaceOp.size === 0 &&
344
+ repairs.clampEnd.size === 0
279
345
  ) {
280
346
  return;
281
347
  }
282
348
  for (const event of events) {
283
349
  const raw = event as SessionEvent & { surfaceOp?: unknown };
350
+ const clamped = repairs.clampEnd.get(event.seq);
284
351
  if (repairs.degradeToAppend.has(event.seq)) {
285
352
  raw.surfaceOp = "append";
353
+ } else if (clamped !== undefined) {
354
+ const op = raw.surfaceOp as { startSeq: number };
355
+ raw.surfaceOp = {
356
+ op: "replace",
357
+ startSeq: SessionSeq(op.startSeq),
358
+ endSeq: SessionSeq(clamped),
359
+ };
286
360
  } else if (repairs.addAppendMarker.has(event.seq)) {
287
361
  raw.surfaceOp = "append";
288
362
  } else if (repairs.clearSurfaceOp.has(event.seq)) {
@@ -291,6 +365,173 @@ export function repairSurfaceOps(events: SessionEvent[]): void {
291
365
  }
292
366
  }
293
367
 
368
+ /**
369
+ * 读取时补全 assistant 结算字段:旧写入器不落库 `stream`(v2 才把流式记录
370
+ * 嵌入消息),读回时缺失即补空数组——上游 seed 校验要求 turn/step/stream
371
+ * 三者齐备,缺失会让整个会话加载失败。
372
+ */
373
+ export function repairAssistantSettlement(events: SessionEvent[]): void {
374
+ for (const event of events) {
375
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") continue;
376
+ const data = event.data as unknown as Record<string, unknown>;
377
+ if (!Array.isArray(data["stream"])) data["stream"] = [];
378
+ }
379
+ }
380
+
381
+ /**
382
+ * 读取时把旧格式 `request/header` 归一为当前格式:v3 起 system prompt 由
383
+ * surface 上的 `system/message` 承载,header 必须省略 `system`,空 `tools` /
384
+ * `adapterDefaults` 也必须省略(上游 v3 事件校验 fail loud)。混合世代回退
385
+ * 视图直接采用存储行,不归一会让整个会话加载失败。system prompt 因此不再
386
+ * 进入模型请求;会话下次运行由 v3 的 system/message 机制重建。
387
+ */
388
+ export function repairRequestHeaders(events: SessionEvent[]): void {
389
+ for (const event of events) {
390
+ if (event.type !== "request/header") continue;
391
+ const data = event.data as unknown as Record<string, unknown>;
392
+ const header = data["header"];
393
+ if (typeof header !== "object" || header === null || Array.isArray(header)) continue;
394
+ const record = header as Record<string, unknown>;
395
+ delete record["system"];
396
+ if (Array.isArray(record["tools"]) && record["tools"].length === 0) delete record["tools"];
397
+ const defaults = record["adapterDefaults"];
398
+ if (
399
+ typeof defaults === "object" &&
400
+ defaults !== null &&
401
+ !Array.isArray(defaults) &&
402
+ Object.keys(defaults).length === 0
403
+ ) {
404
+ delete record["adapterDefaults"];
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * 把紧邻 replace 的 metering 事件的 `shadowedRange` / `shadowedSeqs` 对齐到
411
+ * replace 最终的稠密 range。
412
+ *
413
+ * 旧写入器重编号事件后,metering 与 replace 一起落在旧坐标空间:二者数值
414
+ * 相等但与当前 surface 无关。夹取(或 range 本身已落在稠密空间而 metering
415
+ * 仍是旧值)后二者不再相等,而上游 token-meter 契约要求紧邻的 metering
416
+ * range 与 replace range 完全一致(否则报 no adjacent shadow price)。range
417
+ * 已一致时不改写——当前写入器保证一致,shadowedSeqs 的额外并发节点因此
418
+ * 原样保留。
419
+ */
420
+ export function syncMeteringRanges(events: SessionEvent[]): void {
421
+ for (let i = 1; i < events.length; i++) {
422
+ const metering = events[i - 1]!;
423
+ if (!METERING_EVENT_TYPES.has(metering.type)) continue;
424
+ const event = events[i]!;
425
+ const op = (event as SessionEvent & { surfaceOp?: unknown }).surfaceOp;
426
+ if (typeof op !== "object" || op === null || (op as { op?: string }).op !== "replace") continue;
427
+ const { startSeq, endSeq } = op as { startSeq: number; endSeq: number };
428
+ const data = metering.data as unknown as {
429
+ shadowedRange?: { start: number; end: number };
430
+ shadowedSeqs?: number[];
431
+ };
432
+ if (data.shadowedRange?.start === startSeq && data.shadowedRange.end === endSeq) continue;
433
+ data.shadowedRange = { start: startSeq, end: endSeq };
434
+ data.shadowedSeqs = events
435
+ .filter(
436
+ (candidate) =>
437
+ candidate.seq >= startSeq &&
438
+ candidate.seq <= endSeq &&
439
+ SURFACE_EVENT_TYPES.has(candidate.type),
440
+ )
441
+ .map((candidate) => candidate.seq);
442
+ }
443
+ }
444
+
445
+ const PTC_EVENT_RENAMES: Record<string, string> = {
446
+ "tool/code-dispatch-start": "tool/ptc-dispatch-start",
447
+ "tool/code-dispatch": "tool/ptc-dispatch",
448
+ };
449
+
450
+ /** 把消息的 `tools-code-mode` 插件来源改写为 `tools-ptc`(非该来源原样返回)。 */
451
+ function renamePtcMessageSource(message: unknown): unknown {
452
+ if (typeof message !== "object" || message === null || Array.isArray(message)) return message;
453
+ const record = message as Record<string, unknown>;
454
+ const source = record["source"];
455
+ if (typeof source !== "object" || source === null || Array.isArray(source)) return message;
456
+ const sourceRecord = source as Record<string, unknown>;
457
+ if (sourceRecord["kind"] !== "plugin" || sourceRecord["plugin"] !== "tools-code-mode") {
458
+ return message;
459
+ }
460
+ return { ...record, source: { ...sourceRecord, plugin: "tools-ptc" } };
461
+ }
462
+
463
+ /** 宽类型事件视图:PTC 词汇归一涉及本包 SessionEventMap 之外的插件类型。 */
464
+ interface LegacyPtcEvent {
465
+ type: string;
466
+ data: unknown;
467
+ [key: string]: unknown;
468
+ }
469
+
470
+ /**
471
+ * 读取时把 v2 时代的 PTC 词汇归一为当前词汇。
472
+ *
473
+ * 上游 v2→v3 迁移把 `tool/code-dispatch(-start)` 改名为
474
+ * `tool/ptc-dispatch(-start)`、把 `tools-code-mode` 插件来源改名为 `tools-ptc`、
475
+ * 把 agent preset 值 `code` 改名为 `ptc`。混合世代回退视图直接采用存储行,
476
+ * 不重命名会被上游 v3 校验以「unknown event type」拒绝整个会话。词汇改写
477
+ * 与上游迁移链的 renamePtcEvent 对齐。
478
+ */
479
+ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
480
+ for (let index = 0; index < events.length; index++) {
481
+ const event = events[index] as unknown as LegacyPtcEvent;
482
+ const renamedType = PTC_EVENT_RENAMES[event.type];
483
+ if (renamedType !== undefined) {
484
+ events[index] = { ...event, type: renamedType } as unknown as SessionEvent;
485
+ continue;
486
+ }
487
+ if (event.type === "agent-preset/selected") {
488
+ const data = event.data as Record<string, unknown>;
489
+ if (data["agentPreset"] === "code") {
490
+ events[index] = {
491
+ ...event,
492
+ data: { ...data, agentPreset: "ptc" },
493
+ } as unknown as SessionEvent;
494
+ }
495
+ continue;
496
+ }
497
+ if (event.type === "user/message") {
498
+ const renamed = renamePtcMessageSource(event.data);
499
+ if (renamed !== event.data) {
500
+ events[index] = { ...event, data: renamed } as unknown as SessionEvent;
501
+ }
502
+ continue;
503
+ }
504
+ if (event.type === "agent/inbox/spliced" || event.type === "session/title-llm-request") {
505
+ const data = event.data as Record<string, unknown>;
506
+ const key = event.type === "agent/inbox/spliced" ? "inserted" : "messages";
507
+ const list = data[key];
508
+ if (!Array.isArray(list)) continue;
509
+ const renamed = list.map(renamePtcMessageSource);
510
+ if (renamed.some((message, position) => message !== list[position])) {
511
+ events[index] = {
512
+ ...event,
513
+ data: { ...data, [key]: renamed },
514
+ } as unknown as SessionEvent;
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 读取视图修复总入口:形状补全(结算字段 / request header)→ PTC 词汇归一
522
+ * → surface 语义修复 → metering 对齐 → provenance 重算 → 孤儿 inbox splice
523
+ * 改写。全部只作用于内存视图,不落库。
524
+ */
525
+ export function repairReadView(events: SessionEvent[]): void {
526
+ repairAssistantSettlement(events);
527
+ repairRequestHeaders(events);
528
+ renameLegacyPtcEvents(events);
529
+ repairSurfaceOps(events);
530
+ syncMeteringRanges(events);
531
+ recomputeReplaceProvenance(events);
532
+ repairOrphanInboxSplices(events);
533
+ }
534
+
294
535
  interface InboxSpliceLike {
295
536
  target?: unknown;
296
537
  start?: unknown;
@@ -535,7 +535,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
535
535
  const m = meta("foreign-vocabulary");
536
536
  const handle = await persistence.create(m);
537
537
  await handle.append(oneTurnLog());
538
- // 写路径 v2 校验:未知类型(非 ignorable)拒绝入库,批次不落库。
538
+ // 写路径当前格式校验:未知类型(非 ignorable)拒绝入库,批次不落库。
539
539
  await expect(
540
540
  handle.append([
541
541
  { type: "mystery/event", seq: SessionSeq(6), time: 7, data: { payload: true } },