dsh-log-contract 0.3.11 → 0.3.13
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 +32 -0
- package/README.zh.md +28 -0
- package/bin/dsh-log-contract.mjs +194 -64
- package/lib/checks.js +151 -23
- package/lib/compat.js +282 -0
- package/lib/contracts.js +36 -4
- package/lib/index.js +2 -1
- package/lib/legacy-fold.js +177 -0
- package/lib/log-reader.js +8 -4
- package/lib/prewrite.js +33 -15
- package/lib/repair.js +70 -31
- package/lib/validate.js +243 -13
- package/lib/vocab.js +193 -0
- package/package.json +10 -8
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/legacy-fold.js —— **v0/v1/v2 旧格式的 surface 终验器**
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个文件(一手证据,2026-09-14):
|
|
5
|
+
*
|
|
6
|
+
* 官方 `@deepseek-ai/dsh-session` 的 `foldSurface` 在 0.1.5-rc.1 里换成了 **v3 语义**,
|
|
7
|
+
* 它**不是** rc.7(v0 语义)的超集,而是一个**不同格式**的校验器:
|
|
8
|
+
* - replace 操作数字段改名:rc.7 `{op,start,end}` → 0.1.5 `{op,startSeq,endSeq}`
|
|
9
|
+
* (0.1.5 `lib/index.js:279`:`isReplaceOp` 要求 startSeq/endSeq ⇒ 旧 marker 直接
|
|
10
|
+
* “carries an invalid replace surfaceOp”);
|
|
11
|
+
* - provenance 收紧:0.1.5 `lib/index.js:285` 起对 `assistant/message` **一律**禁止
|
|
12
|
+
* `sourceEventSeqs`(“embeds its source stream and cannot carry sourceEventSeqs”);
|
|
13
|
+
* rc.7 允许(空数组仅限 assistant/message)。
|
|
14
|
+
*
|
|
15
|
+
* App 2.0.9 的真实文件版本分布证明旧格式仍在线上:App 内置
|
|
16
|
+
* `dsh-session-format-v0-to-v1` / `v1-to-v2` / `v2-to-v3` 三个迁移包,
|
|
17
|
+
* `SESSION_FORMAT_VERSION = 3`;且 `v2-to-v3/lib/index.js:361-371` 明示 **v2 仍是
|
|
18
|
+
* `{start,end}`**、由迁移改名为 `startSeq/endSeq`。
|
|
19
|
+
*
|
|
20
|
+
* ⇒ 结论:**用运行时的 v3 `foldSurface` 去终验 v0/v1/v2 文件必然误报**;旧格式必须用
|
|
21
|
+
* 本文件这份等价实现(rc.7 `foldSurface` 的逐条移植,语义等价、同样 fail-loud)。
|
|
22
|
+
*
|
|
23
|
+
* 移植源:`@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:229-455`
|
|
24
|
+
* (`isSurfaceEligibleType` / `isEventSeq` / `isReplaceOp` / `surfaceOpOf` /
|
|
25
|
+
* `assertProvenance` / `replacementRange` / `isDeepEqualJson` / `assertToolResultRewrite` /
|
|
26
|
+
* `planSurfaceEvent` / `applySurfacePlan` / `foldSurface`)。
|
|
27
|
+
*
|
|
28
|
+
* 与官方相同的点:**抛错即拒绝**(官方加载期 `SessionPersistenceCorruptionError` 同源
|
|
29
|
+
* 判据);事件按 `index` 作为期望 seq(`baseSeq = 0`),与官方 `foldSurface(events)` 一致。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** v0/v1/v2 的 surface 候选类型(官方 rc.7 `SURFACE_EVENT_TYPES`,3 条)。 */
|
|
33
|
+
const SURFACE_EVENT_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']);
|
|
34
|
+
|
|
35
|
+
/** Whether an event type can join the model-visible surface(rc.7 `isSurfaceEligibleType`)。 */
|
|
36
|
+
export function isLegacySurfaceEligibleType(type) {
|
|
37
|
+
return SURFACE_EVENT_TYPES.has(type);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a runtime value is a non-negative safe event sequence(rc.7 `isEventSeq`)。 */
|
|
41
|
+
function isEventSeq(value) {
|
|
42
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether a runtime value is the exact positional-replacement shape(rc.7 `isReplaceOp`:start/end)。 */
|
|
46
|
+
function isReplaceOp(value) {
|
|
47
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
48
|
+
const op = value;
|
|
49
|
+
return (
|
|
50
|
+
Object.keys(op).length === 3 &&
|
|
51
|
+
Object.hasOwn(op, 'op') && Object.hasOwn(op, 'start') && Object.hasOwn(op, 'end') &&
|
|
52
|
+
op.op === 'replace' && isEventSeq(op.start) && isEventSeq(op.end)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validate event-local surface eligibility and return its operation(rc.7 `surfaceOpOf`)。 */
|
|
57
|
+
function surfaceOpOf(event) {
|
|
58
|
+
if (!SURFACE_EVENT_TYPES.has(event.type)) {
|
|
59
|
+
if (event.surfaceOp !== undefined) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`);
|
|
60
|
+
if (event.sourceEventSeqs !== undefined) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const op = event.surfaceOp;
|
|
64
|
+
if (op === undefined) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`);
|
|
65
|
+
if (op === 'append') return op;
|
|
66
|
+
if (op === null || typeof op !== 'object' || Array.isArray(op)) throw new Error(`session event "${event.type}" carries an invalid surfaceOp`);
|
|
67
|
+
if (!isReplaceOp(op)) throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`);
|
|
68
|
+
return op;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate cited source-event seqs against prior log entries and the replacement range(rc.7 `assertProvenance`)。 */
|
|
72
|
+
function assertProvenance(event, shadowedSeqs) {
|
|
73
|
+
const raw = event.sourceEventSeqs;
|
|
74
|
+
const sources = new Set();
|
|
75
|
+
if (raw !== undefined) {
|
|
76
|
+
if (!Array.isArray(raw)) throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`);
|
|
77
|
+
if (raw.length === 0 && event.type !== 'assistant/message') throw new Error('sourceEventSeqs must not be empty except on assistant/message');
|
|
78
|
+
let nonEarlierSource;
|
|
79
|
+
for (const source of raw) {
|
|
80
|
+
if (!isEventSeq(source)) throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`);
|
|
81
|
+
sources.add(source);
|
|
82
|
+
if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source;
|
|
83
|
+
}
|
|
84
|
+
if (sources.size !== raw.length) throw new Error('sourceEventSeqs must not contain duplicates');
|
|
85
|
+
if (nonEarlierSource !== undefined) throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`);
|
|
86
|
+
}
|
|
87
|
+
const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
|
|
88
|
+
if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Locate one replacement range without mutating the current fold state(rc.7 `replacementRange`)。 */
|
|
92
|
+
function replacementRange(state, op) {
|
|
93
|
+
const startIdx = state.nodes.indexOf(op.start);
|
|
94
|
+
if (startIdx === -1) throw new Error(`surface replace: start seq ${op.start} not found in surface`);
|
|
95
|
+
const endIdx = state.nodes.indexOf(op.end);
|
|
96
|
+
if (endIdx === -1) throw new Error(`surface replace: end seq ${op.end} not found in surface`);
|
|
97
|
+
if (startIdx > endIdx) throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`);
|
|
98
|
+
return { startIdx, endIdx, shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Deep structural equality over the session-event JSON value domain(rc.7 `isDeepEqualJson`)。 */
|
|
102
|
+
function isDeepEqualJson(a, b) {
|
|
103
|
+
if (a === b) return true;
|
|
104
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
105
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
106
|
+
return a.every((item, i) => isDeepEqualJson(item, b[i]));
|
|
107
|
+
}
|
|
108
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
109
|
+
const aKeys = Object.keys(a);
|
|
110
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
111
|
+
return aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqualJson(a[key], b[key]));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Restrict a tool-result replacement to one current result's content(rc.7 `assertToolResultRewrite`)。 */
|
|
115
|
+
function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
|
|
116
|
+
if (event.type !== 'tool/result') return;
|
|
117
|
+
if (shadowedSeqs.length !== 1) throw new Error('tool/result surface replacement must rewrite exactly one current node');
|
|
118
|
+
for (const originalSeq of shadowedSeqs) {
|
|
119
|
+
const original = events[originalSeq - baseSeq];
|
|
120
|
+
if (original?.type !== 'tool/result') throw new Error('tool/result surface replacement must target a current tool/result');
|
|
121
|
+
const originalRest = { ...original.data };
|
|
122
|
+
const replacementRest = { ...event.data };
|
|
123
|
+
const originalResult = original.data.message.content[0];
|
|
124
|
+
const replacementResult = event.data.message.content[0];
|
|
125
|
+
originalRest.message = { ...original.data.message, content: [{ ...originalResult, content: null }] };
|
|
126
|
+
replacementRest.message = { ...event.data.message, content: [{ ...replacementResult, content: null }] };
|
|
127
|
+
if (!isDeepEqualJson(originalRest, replacementRest)) throw new Error('tool/result surface replacement may change only content');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Validate one event at its replay boundary and prepare its atomic fold transition(rc.7 `planSurfaceEvent`)。 */
|
|
132
|
+
function planSurfaceEvent(state, event, expectedSeq, events, baseSeq) {
|
|
133
|
+
if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
|
|
134
|
+
const surfaceOp = surfaceOpOf(event);
|
|
135
|
+
if (surfaceOp === undefined) return undefined;
|
|
136
|
+
if (surfaceOp === 'append') {
|
|
137
|
+
assertProvenance(event, []);
|
|
138
|
+
return { kind: 'append', seq: event.seq };
|
|
139
|
+
}
|
|
140
|
+
const range = replacementRange(state, surfaceOp);
|
|
141
|
+
assertProvenance(event, range.shadowedSeqs);
|
|
142
|
+
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq);
|
|
143
|
+
return { kind: 'replace', seq: event.seq, start: surfaceOp.start, end: surfaceOp.end, ...range };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Commit one previously validated surface transition(rc.7 `applySurfacePlan`)。 */
|
|
147
|
+
function applySurfacePlan(state, plan) {
|
|
148
|
+
if (plan?.kind === 'append') state.nodes.push(plan.seq);
|
|
149
|
+
else if (plan?.kind === 'replace') {
|
|
150
|
+
state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq);
|
|
151
|
+
state.replaceGeneration += 1;
|
|
152
|
+
}
|
|
153
|
+
if (plan?.kind !== 'replace') return undefined;
|
|
154
|
+
return { seq: plan.seq, start: plan.start, end: plan.end, shadowedSeqs: plan.shadowedSeqs };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Apply one event and return replacement metadata only when one occurred(rc.7 `applySurfaceEvent`)。 */
|
|
158
|
+
function applySurfaceEvent(state, event, expectedSeq, events, baseSeq) {
|
|
159
|
+
return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events, baseSeq));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Replay a complete session log through the **v0/v1/v2** canonical surface fold.
|
|
164
|
+
*
|
|
165
|
+
* @param events - session events in contiguous seq order(seq 从 0 起,与官方 `foldSurface` 同约定)。
|
|
166
|
+
* @returns {{ nodes: number[], replacements: Array<{seq:number,start:number,end:number,shadowedSeqs:number[]}> }}
|
|
167
|
+
* @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules.
|
|
168
|
+
*/
|
|
169
|
+
export function legacyFoldSurface(events) {
|
|
170
|
+
const state = { nodes: [], replaceGeneration: 0 };
|
|
171
|
+
const replacements = [];
|
|
172
|
+
for (const [index, event] of events.entries()) {
|
|
173
|
+
const replacement = applySurfaceEvent(state, event, index, events, 0);
|
|
174
|
+
if (replacement !== undefined) replacements.push(replacement);
|
|
175
|
+
}
|
|
176
|
+
return { nodes: [...state.nodes], replacements };
|
|
177
|
+
}
|
package/lib/log-reader.js
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* 契约来源:
|
|
8
8
|
* - 帧扫描/撕裂尾帧判定:复用本项目审计方法论(scan-seq-gaps.mjs),
|
|
9
9
|
* 帧头布局对齐 zstd 规范(magic 0xFD2FB528、descriptor、block 头)。
|
|
10
|
-
* -
|
|
10
|
+
* - 行解码:本地兼容层 `./compat.js` 的 `decodeStorageRecord`(原官方导出于 0.1.5 移除)
|
|
11
11
|
* (lib/index.js:1029,validateRow :922 / expandRow :973)。
|
|
12
12
|
* - 损坏语义:R2 —— chunk 行损坏 = 整段 run 丢失且加载失败(dsh-session
|
|
13
13
|
* lib/index.js:1022-1024 注释明示 fail-loud,无跳过逃生舱)。
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import { zstdDecompressSync } from 'node:zlib';
|
|
17
|
-
import { decodeStorageRecord } from '
|
|
17
|
+
import { decodeStorageRecord, normalizeEventSeqRanges } from './compat.js';
|
|
18
18
|
|
|
19
19
|
const ZSTD_MAGIC = 0xfd2fb528;
|
|
20
20
|
|
|
@@ -209,10 +209,14 @@ export function loadSessionLog(path) {
|
|
|
209
209
|
rows.push({ lineNo, value, decoded: null, error: err });
|
|
210
210
|
continue;
|
|
211
211
|
}
|
|
212
|
-
|
|
212
|
+
// v3 storage-form `sourceEventSeqs` 区间编码 → 内存稠密序列(C1)。
|
|
213
|
+
// 必须在**读取层唯一入口**做:下游 S5/S6 与官方 foldSurface 都按稠密整数序列判定,
|
|
214
|
+
// 拿到含 `[start,end]` 对的未展开形态会误判真实健康会话(S5/S6/S8 + --resume broken)。
|
|
215
|
+
const normalized = decoded.map(normalizeEventSeqRanges);
|
|
216
|
+
for (const event of normalized) {
|
|
213
217
|
events.push({ seq: event.seq, event, lineNo });
|
|
214
218
|
}
|
|
215
|
-
rows.push({ lineNo, value, decoded, error: null });
|
|
219
|
+
rows.push({ lineNo, value, decoded: normalized, error: null });
|
|
216
220
|
}
|
|
217
221
|
|
|
218
222
|
// 按 seq 排序(文件顺序即日志顺序,此处防御性排序以便下游契约检查)
|
package/lib/prewrite.js
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
|
|
18
18
|
* 保证"体检看到的问题 = 写入前拦下的问题"。
|
|
19
19
|
*/
|
|
20
|
-
import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation } from './checks.js';
|
|
20
|
+
import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation, wireViolations } from './checks.js';
|
|
21
|
+
import { resolveFormatVersion } from './vocab.js';
|
|
22
|
+
import { normalizeEventSeqRanges } from './compat.js';
|
|
21
23
|
|
|
22
24
|
/** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
|
|
23
25
|
function isKnownMarkerCandidate(event) {
|
|
@@ -32,17 +34,28 @@ function normalizeCandidate(candidate, nextSeq) {
|
|
|
32
34
|
/**
|
|
33
35
|
* 基于当前日志事件列表建立写前校验器。
|
|
34
36
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
+
* **格式版本(C2)**:`formatVersion` > `header.version` > 事件形状推断 > 0,在**本次
|
|
38
|
+
* `createPreWriter` 调用内固定**并显式传给每条按版本择路的判定。**不再读写模块级全局**
|
|
39
|
+
* `fileVersion`——旧实现下同一进程"先 validate(v3) 再 prewrite(v0)"会把同一份合法 v0 输入
|
|
40
|
+
* 的结论翻成 S4+S8(独立审核复现 B)。下游 `dsh-retrace` 正是只传 `events` 直接调用
|
|
41
|
+
* (`lib/prewrite-guard.js:166`),所以缺省时必须能自行推断,不能把 v3 输入按 0 处理
|
|
42
|
+
* (否则首次调用即 10 条 E3/S2/S8 误报,复现 C)。
|
|
43
|
+
*
|
|
44
|
+
* @param {{ events: Array<object>, baseSeq?: number, formatVersion?: number, header?: object|null }} input
|
|
45
|
+
* 当前日志的已解码事件(按日志顺序;无 seq 字段的事件按位置补 seq,用于窗口校验)。
|
|
46
|
+
* `formatVersion`/`header` 二者其一优先决定被检文件的格式版本。
|
|
37
47
|
* @returns {{
|
|
38
|
-
* events: Array, nextSeq: number,
|
|
48
|
+
* events: Array, nextSeq: number, formatVersion: number,
|
|
39
49
|
* validateAppend(candidate, opts?): { ok, violations, stateAfter },
|
|
40
50
|
* validateEdit(editedEvents, opts?): { ok, violations, stateAfter },
|
|
41
51
|
* }}
|
|
42
52
|
*/
|
|
43
53
|
export function createPreWriter(input = {}) {
|
|
44
|
-
const { baseSeq = 0 } = input;
|
|
45
|
-
|
|
54
|
+
const { baseSeq = 0, header = null } = input;
|
|
55
|
+
// C1:v3 storage-form 区间编码 → 内存稠密序列(与 log-reader 同一归一,覆盖"直接传 events"入口)
|
|
56
|
+
let events = (input.events ?? []).map(normalizeEventSeqRanges);
|
|
57
|
+
// C2:版本显式解析并固定在本次调用内(不读模块级全局)
|
|
58
|
+
const formatVersion = resolveFormatVersion({ formatVersion: input.formatVersion, header, events });
|
|
46
59
|
// 窗口校验支持"无 seq 的原始事件列表":按位置补齐 seq 与 time。
|
|
47
60
|
let nextSeq = baseSeq;
|
|
48
61
|
events = events.map((e) => {
|
|
@@ -71,18 +84,21 @@ export function createPreWriter(input = {}) {
|
|
|
71
84
|
// E1/E3/E4/E5/E6 + M1 + P1/P2 —— 逐事件
|
|
72
85
|
for (const event of candidateEvents) {
|
|
73
86
|
const loc = { seq: event.seq, lineNo: null, eventType: event.type };
|
|
74
|
-
violations.push(...envelopeViolations(event, loc));
|
|
87
|
+
violations.push(...envelopeViolations(event, loc, formatVersion));
|
|
75
88
|
violations.push(...engineViolations(event, loc));
|
|
76
89
|
violations.push(...pluginViolations(event, loc));
|
|
77
90
|
}
|
|
78
91
|
// S1–S7 —— 与官方同语义的增量重放(含拟写事件)
|
|
79
|
-
const replay = replaySurface(candidateEvents.map((event) => ({ event })));
|
|
92
|
+
const replay = replaySurface(candidateEvents.map((event) => ({ event })), formatVersion);
|
|
80
93
|
violations.push(...replay.violations);
|
|
81
|
-
// S8 —— 官方 foldSurface
|
|
82
|
-
const folded = finalFold(candidateEvents);
|
|
94
|
+
// S8 —— fold 终验(按当前文件版本选:v3 官方 foldSurface / v0–v2 本地 legacyFoldSurface)
|
|
95
|
+
const folded = finalFold(candidateEvents, formatVersion);
|
|
83
96
|
if (folded.error) {
|
|
84
|
-
violations.push(violation('S8', { lineNo: null },
|
|
97
|
+
violations.push(violation('S8', { lineNo: null }, `foldSurface 重放失败(按文件版本选:v3 官方 / v0–v2 本地等价):${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
85
98
|
}
|
|
99
|
+
// W1/W2 —— wire 流配对(2026-09-09 V3 验证补:离线 check 有、写前原漏——悬空 tool
|
|
100
|
+
// 编辑必须写前拦,否则违约写入先落盘、体检才报 = 晚一步)
|
|
101
|
+
violations.push(...wireViolations(candidateEvents.map((event) => ({ event })), formatVersion));
|
|
86
102
|
// T1 —— token-meter 配对(事故根因 3 固化)。写前校验只判定**拟写事件自身**
|
|
87
103
|
// 的 step 配对:retrace 的 turn-null 编辑/撤回 marker 必然命中(空
|
|
88
104
|
// assistant/message replace 无 step 可配对),但编辑功能必须可用——白名单
|
|
@@ -129,6 +145,7 @@ export function createPreWriter(input = {}) {
|
|
|
129
145
|
|
|
130
146
|
return {
|
|
131
147
|
events,
|
|
148
|
+
formatVersion,
|
|
132
149
|
get nextSeq() {
|
|
133
150
|
return nextSeq;
|
|
134
151
|
},
|
|
@@ -147,7 +164,7 @@ export function createPreWriter(input = {}) {
|
|
|
147
164
|
nextSeq,
|
|
148
165
|
};
|
|
149
166
|
}
|
|
150
|
-
const normalized = normalizeCandidate(candidate, nextSeq);
|
|
167
|
+
const normalized = normalizeEventSeqRanges(normalizeCandidate(candidate, nextSeq));
|
|
151
168
|
const after = [...events, normalized];
|
|
152
169
|
const result = runChecks(after, nextSeq); result.stateAfter = {
|
|
153
170
|
events: after,
|
|
@@ -172,9 +189,10 @@ export function createPreWriter(input = {}) {
|
|
|
172
189
|
nextSeq,
|
|
173
190
|
};
|
|
174
191
|
}
|
|
175
|
-
const
|
|
192
|
+
const edited = editedEvents.map(normalizeEventSeqRanges);
|
|
193
|
+
const result = runChecks(edited, undefined);
|
|
176
194
|
result.stateAfter = {
|
|
177
|
-
events:
|
|
195
|
+
events: edited,
|
|
178
196
|
nextSeq: result.nextSeq,
|
|
179
197
|
surfaceNodes: result.surface?.nodes ?? [],
|
|
180
198
|
};
|
|
@@ -189,5 +207,5 @@ export function createPreWriter(input = {}) {
|
|
|
189
207
|
*/
|
|
190
208
|
export function preWriterFromLog(log) {
|
|
191
209
|
const events = (log.events ?? []).map((e) => e.event);
|
|
192
|
-
return createPreWriter({ events });
|
|
210
|
+
return createPreWriter({ events, header: log?.header ?? null });
|
|
193
211
|
}
|
package/lib/repair.js
CHANGED
|
@@ -554,6 +554,48 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
554
554
|
const parts = text.split('\n');
|
|
555
555
|
const out = [];
|
|
556
556
|
let changed = 0;
|
|
557
|
+
// ── seq 引用平移(2026-09-14 第五轮:把 round4 存档的未审补丁评审后纳入,并补两处同族缺口)──
|
|
558
|
+
// 一手依据(官方 v0→v1 校验,@0.1.5-rc.2):
|
|
559
|
+
// - `data.shadowedRange{start,end}` + `data.shadowedSeqs`:`dsh-session-format-v0-to-v1`
|
|
560
|
+
// `lib/index.js:55-70`(compaction/prune、compaction/summary 的 dispositions)、
|
|
561
|
+
// `:1142-1146` `shadowedValue()`:start/end 必须是 `earlierSeq(, eventSeq)` 且与 shadowedSeqs 端点一致。
|
|
562
|
+
// - v3 的 `shadowedRange` 仍是 `{start,end}`(`dsh-session-format-v2-to-v3` `lib/index.js:634-640` `range()`),
|
|
563
|
+
// 而 v3 `surfaceOp` 是 `{startSeq,endSeq}`(同包 `:361-371`)⇒ 两种字段名都要覆盖。
|
|
564
|
+
// - `sourceEventSeqs` 在 v3 可为**区间对数组** `[[a,b],…]`(`decodeSeqRanges`);旧实现把区间对
|
|
565
|
+
// 当数字减 delta ⇒ `NaN` ⇒ `JSON.stringify` 成 `null`(实测破坏)。此处改为:两端点同侧才平移,
|
|
566
|
+
// **跨平移起点**的区间对直接报错(单区间无法忠实表达)。
|
|
567
|
+
// - `data.messageSeqs`(session/title、session/title-llm-request)同样是 seq 引用
|
|
568
|
+
// (`v0-to-v1/lib/index.js:2543-2556` `assertTitleSources` 用 `events[seq]` 取源事件)。
|
|
569
|
+
const shiftNum = (sv, label) => {
|
|
570
|
+
if (typeof sv !== 'number' || !Number.isSafeInteger(sv) || sv < startSeq) return { changed: false, value: sv };
|
|
571
|
+
const ns = sv - delta;
|
|
572
|
+
if (ns < 0) return { error: `${label} ${sv} 平移后为负(delta 过大或起点有误)` };
|
|
573
|
+
return { changed: true, value: ns };
|
|
574
|
+
};
|
|
575
|
+
const shiftSeqList = (list, label) => {
|
|
576
|
+
const mapped = [];
|
|
577
|
+
let local = false;
|
|
578
|
+
for (const item of list) {
|
|
579
|
+
if (Array.isArray(item)) {
|
|
580
|
+
const [a, b] = item;
|
|
581
|
+
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b) || a > b) return { error: `${label} 区间对 [${String(a)},${String(b)}] 形状非法` };
|
|
582
|
+
if (b < startSeq) { mapped.push(item); continue; }
|
|
583
|
+
if (a < startSeq) return { error: `${label} 区间对 [${a},${b}] 跨平移起点 ${startSeq},无法用单区间表达(请先展开区间编码)` };
|
|
584
|
+
const na = a - delta;
|
|
585
|
+
const nb = b - delta;
|
|
586
|
+
if (na < 0) return { error: `${label} 区间对 [${a},${b}] 平移后为负` };
|
|
587
|
+
mapped.push([na, nb]);
|
|
588
|
+
local = true;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const r = shiftNum(item, label);
|
|
592
|
+
if (r.error) return { error: r.error };
|
|
593
|
+
mapped.push(r.value);
|
|
594
|
+
if (r.changed) local = true;
|
|
595
|
+
}
|
|
596
|
+
return { changed: local, value: mapped };
|
|
597
|
+
};
|
|
598
|
+
|
|
557
599
|
for (const raw of parts) {
|
|
558
600
|
if (!raw.trim()) {
|
|
559
601
|
out.push(raw);
|
|
@@ -567,37 +609,34 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
567
609
|
continue;
|
|
568
610
|
}
|
|
569
611
|
let modified = false;
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
modified = true;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
modified = true;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
612
|
+
const applyNum = (obj, key, label) => {
|
|
613
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
614
|
+
const r = shiftNum(obj[key], label);
|
|
615
|
+
if (r.error) return r.error;
|
|
616
|
+
if (r.changed) { obj[key] = r.value; modified = true; }
|
|
617
|
+
return null;
|
|
618
|
+
};
|
|
619
|
+
const applyList = (obj, key, label) => {
|
|
620
|
+
if (!obj || typeof obj !== 'object' || !Array.isArray(obj[key])) return null;
|
|
621
|
+
const r = shiftSeqList(obj[key], label);
|
|
622
|
+
if (r.error) return r.error;
|
|
623
|
+
if (r.changed) { obj[key] = r.value; modified = true; }
|
|
624
|
+
return null;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
let err = applyNum(v, 'seq', 'seq');
|
|
628
|
+
if (!err) err = applyNum(v, 'seq0', 'seq0');
|
|
629
|
+
if (!err) err = applyList(v, 'sourceEventSeqs', 'sourceEventSeqs');
|
|
630
|
+
if (!err) err = applyNum(v.surfaceOp, 'start', 'surfaceOp.start');
|
|
631
|
+
if (!err) err = applyNum(v.surfaceOp, 'end', 'surfaceOp.end');
|
|
632
|
+
if (!err) err = applyNum(v.surfaceOp, 'startSeq', 'surfaceOp.startSeq');
|
|
633
|
+
if (!err) err = applyNum(v.surfaceOp, 'endSeq', 'surfaceOp.endSeq');
|
|
634
|
+
if (!err) err = applyNum(v.data?.shadowedRange, 'start', 'shadowedRange.start');
|
|
635
|
+
if (!err) err = applyNum(v.data?.shadowedRange, 'end', 'shadowedRange.end');
|
|
636
|
+
if (!err) err = applyList(v.data, 'shadowedSeqs', 'shadowedSeqs');
|
|
637
|
+
if (!err) err = applyList(v.data, 'messageSeqs', 'messageSeqs');
|
|
638
|
+
if (err) return { text, changed: 0, startSeq, delta, error: err };
|
|
639
|
+
|
|
601
640
|
if (modified) changed++;
|
|
602
641
|
out.push(JSON.stringify(v));
|
|
603
642
|
}
|