dsh-log-contract 0.2.0 → 0.2.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/bin/dsh-log-contract.mjs +8 -3
- package/lib/checks.js +47 -0
- package/lib/contracts.js +8 -0
- package/lib/index.js +2 -1
- package/lib/prewrite.js +21 -1
- package/lib/repair.js +360 -16
- package/lib/validate.js +4 -0
- package/package.json +1 -1
package/bin/dsh-log-contract.mjs
CHANGED
|
@@ -161,13 +161,18 @@ function cmdContracts() {
|
|
|
161
161
|
function cmdFix(args) {
|
|
162
162
|
const json = args.includes('--json');
|
|
163
163
|
const removeMarkers = args.includes('--remove-markers');
|
|
164
|
+
const dropFailedTurns = args.includes('--drop-failed-turns');
|
|
165
|
+
const trimIdx = args.indexOf('--trim-last');
|
|
166
|
+
const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
|
|
167
|
+
const compactIdx = args.indexOf('--compact-last');
|
|
168
|
+
const compactLast = compactIdx >= 0 && args[compactIdx + 1] ? Number(args[compactIdx + 1]) : undefined;
|
|
164
169
|
const apply = args.includes('--apply');
|
|
165
170
|
const backupDirIdx = args.indexOf('--backup-dir');
|
|
166
171
|
const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
|
|
167
172
|
const file = args.find((a) => !a.startsWith('-'));
|
|
168
173
|
if (!file) fail(USAGE);
|
|
169
174
|
|
|
170
|
-
const result = repairSession(file, { removeMarkers, apply, backupDir });
|
|
175
|
+
const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, compactLast, apply, backupDir });
|
|
171
176
|
if (json) {
|
|
172
177
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
173
178
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -176,7 +181,7 @@ function cmdFix(args) {
|
|
|
176
181
|
process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
|
|
177
182
|
process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
|
|
178
183
|
if (result.applied) {
|
|
179
|
-
process.stdout.write(` 已应用修复:移除 ${result.removed}
|
|
184
|
+
process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行\n`);
|
|
180
185
|
process.stdout.write(` 备份:${result.backupPath}\n`);
|
|
181
186
|
process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
|
|
182
187
|
} else if (apply && !result.ok) {
|
|
@@ -184,7 +189,7 @@ function cmdFix(args) {
|
|
|
184
189
|
} else if (apply) {
|
|
185
190
|
process.stdout.write(' (--apply 且无问题——无内容可修)\n');
|
|
186
191
|
} else {
|
|
187
|
-
process.stdout.write(` (干跑模式:${result.removed}
|
|
192
|
+
process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号;加 --apply 落盘,--remove-markers / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
|
|
188
193
|
}
|
|
189
194
|
process.stdout.write('\n');
|
|
190
195
|
process.exit(result.ok ? 0 : 1);
|
package/lib/checks.js
CHANGED
|
@@ -265,6 +265,53 @@ export function finalFold(events) {
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* T1 —— token-meter 配对(复刻 @deepseek-ai/dsh-token-meter 的 _foldEvent 状态机,
|
|
270
|
+
* 2026-08-28 事故根因 3 固化):
|
|
271
|
+
* - `step/start` 打开一个 step(记录 turn/step);
|
|
272
|
+
* - `step/end` 必须匹配当前打开的 step/start,否则抛错;
|
|
273
|
+
* - `assistant/message` 必须匹配当前打开的 step/start(turn/step 完全一致),否则抛错;
|
|
274
|
+
* - `user/message` / `tool/result` 不检查(token meter 不配对)。
|
|
275
|
+
*
|
|
276
|
+
* 违反 = token meter 折叠抛错 → 该会话 `/compact` 与压力测量永久失败。
|
|
277
|
+
* 已知命中:retrace 的 turn/step=null 编辑/撤回 marker(空 assistant/message replace)——
|
|
278
|
+
* foldSurface 认可其合法性(M1 只约束 append 形态),但 token meter 崩溃。这是
|
|
279
|
+
* M1 规则的盲区:M1 没约束"replace 也必须过 token meter"。
|
|
280
|
+
*
|
|
281
|
+
* @param events - 行序事件流(`{event, lineNo}`)。
|
|
282
|
+
* @returns T1 违规列表。
|
|
283
|
+
*/
|
|
284
|
+
export function tokenMeterViolations(events) {
|
|
285
|
+
const out = [];
|
|
286
|
+
// 无任何 step/start 的日志:现代 DSH 每个 assistant 回合必有 step/start,
|
|
287
|
+
// 完全没有说明是极早期格式或简化日志——token-meter 的 step 配对兼容性未
|
|
288
|
+
// 定义,不做配对检查(避免对旧结构误报;真实事故会话都是现代结构)。
|
|
289
|
+
if (!events.some(({ event }) => event.type === 'step/start')) return out;
|
|
290
|
+
let stepStart = undefined;
|
|
291
|
+
for (const { event, lineNo } of events) {
|
|
292
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
293
|
+
if (event.type === 'step/start') {
|
|
294
|
+
if (stepStart !== undefined) {
|
|
295
|
+
out.push(violation('T1', loc, `step/start at seq ${event.seq} arrived before turn ${stepStart.turn}/step ${stepStart.step} ended——token meter 折叠会抛错`));
|
|
296
|
+
}
|
|
297
|
+
stepStart = { turn: event.data?.turn, step: event.data?.step };
|
|
298
|
+
} else if (event.type === 'step/end') {
|
|
299
|
+
if (stepStart === undefined || stepStart.turn !== event.data?.turn || stepStart.step !== event.data?.step) {
|
|
300
|
+
out.push(violation('T1', loc, `step/end at seq ${event.seq} has no matching step/start event(turn=${String(event.data?.turn)}, step=${String(event.data?.step)})——token meter 折叠会抛错`));
|
|
301
|
+
}
|
|
302
|
+
stepStart = undefined;
|
|
303
|
+
} else if (event.type === 'assistant/message') {
|
|
304
|
+
const turn = event.data?.turn;
|
|
305
|
+
const step = event.data?.step;
|
|
306
|
+
if (stepStart === undefined || stepStart.turn !== turn || stepStart.step !== step) {
|
|
307
|
+
const open = stepStart === undefined ? '无打开的 step' : `打开的 step 为 turn ${stepStart.turn}/step ${stepStart.step}`;
|
|
308
|
+
out.push(violation('T1', loc, `assistant/message at seq ${event.seq} has no matching step/start event(turn=${String(turn)}, step=${String(step)};${open})——token meter 折叠会抛错,/compact 与压力测量永久失败(retrace 的 turn-null 编辑/撤回 marker 即命中此条)`));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
268
315
|
/** 从事件推导 wire 消息(与 dsh-session deriveEventMessage 同语义)。 */
|
|
269
316
|
export function deriveWireMessage(event) {
|
|
270
317
|
if (event.type === 'user/message') {
|
package/lib/contracts.js
CHANGED
|
@@ -197,6 +197,14 @@ export const CONTRACT_RULES = [
|
|
|
197
197
|
description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
|
|
198
198
|
},
|
|
199
199
|
|
|
200
|
+
{
|
|
201
|
+
id: 'T1',
|
|
202
|
+
title: 'token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start',
|
|
203
|
+
layer: LAYER.ENGINE,
|
|
204
|
+
severity: SEVERITY.ERROR,
|
|
205
|
+
source: '@deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)',
|
|
206
|
+
description: 'token meter 折叠要求 assistant/message 与 step/end 与打开的 step/start(turn/step 完全一致)匹配;违反即 /compact 与压力测量永久失败。retrace 的 turn-null 编辑/撤回 marker(空 assistant/message replace)命中此条——foldSurface 认可其合法性但 token meter 崩溃(M1 只约束 append 形态的盲区),压缩前需清理。',
|
|
207
|
+
},
|
|
200
208
|
// ── M · 客户端引擎层 ────────────────────────────────────────────────────
|
|
201
209
|
{
|
|
202
210
|
id: 'M1',
|
package/lib/index.js
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
export { loadSessionLog } from './log-reader.js';
|
|
8
8
|
export { validateSessionLog } from './validate.js';
|
|
9
9
|
export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
10
|
-
export { repairSession, strictScanText, removeMarkersText, rebuildZstdText } from './repair.js';
|
|
10
|
+
export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
|
|
11
11
|
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|
|
12
|
+
export { tokenMeterViolations } from './checks.js';
|
package/lib/prewrite.js
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
* 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
|
|
18
18
|
* 保证"体检看到的问题 = 写入前拦下的问题"。
|
|
19
19
|
*/
|
|
20
|
-
import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, violation } from './checks.js';
|
|
20
|
+
import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, tokenMeterViolations, violation } from './checks.js';
|
|
21
|
+
|
|
22
|
+
/** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
|
|
23
|
+
function isKnownMarkerCandidate(event) {
|
|
24
|
+
return Boolean(event) && event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
/** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
|
|
23
28
|
function normalizeCandidate(candidate, nextSeq) {
|
|
@@ -78,6 +83,21 @@ export function createPreWriter(input = {}) {
|
|
|
78
83
|
if (folded.error) {
|
|
79
84
|
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
80
85
|
}
|
|
86
|
+
// T1 —— token-meter 配对(事故根因 3 固化)。写前校验只判定**拟写事件自身**
|
|
87
|
+
// 的 step 配对:retrace 的 turn-null 编辑/撤回 marker 必然命中(空
|
|
88
|
+
// assistant/message replace 无 step 可配对),但编辑功能必须可用——白名单
|
|
89
|
+
// 降级为 warning(已知设计债,压缩前需 doctor 清理);非 marker 的
|
|
90
|
+
// assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
|
|
91
|
+
// (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
|
|
92
|
+
const lastCandidate = candidateEvents[candidateEvents.length - 1];
|
|
93
|
+
for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
|
|
94
|
+
if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
|
|
95
|
+
if (isKnownMarkerCandidate(lastCandidate)) {
|
|
96
|
+
violations.push({ ...t1, severity: 'warning', message: `${t1.message}(已知 retrace marker 设计债:压缩前需 doctor 清理)` });
|
|
97
|
+
} else {
|
|
98
|
+
violations.push(t1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
81
101
|
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
82
102
|
for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
83
103
|
return {
|
package/lib/repair.js
CHANGED
|
@@ -31,7 +31,7 @@ import path from 'node:path';
|
|
|
31
31
|
import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
32
32
|
import { decompressZstd, loadSessionLog } from './log-reader.js';
|
|
33
33
|
import { validateSessionLog } from './validate.js';
|
|
34
|
-
import { CHUNK_ROW_TYPES, MARKER_PREFIXES } from './checks.js';
|
|
34
|
+
import { CHUNK_ROW_TYPES, MARKER_PREFIXES, SURFACE_TYPES } from './checks.js';
|
|
35
35
|
|
|
36
36
|
/** 复刻 dsh-session expandRow:chunk 行展开为完整事件(含 data/turn/step)。 */
|
|
37
37
|
function expandChunkRow(row) {
|
|
@@ -208,6 +208,327 @@ export function removeMarkersText(text) {
|
|
|
208
208
|
return { text: fixed, removed, renumbered, markerSeqs };
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* 通用"删除事件 + 全量重编号"引擎:按谓词删行,seq/seq0/sourceEventSeqs/
|
|
213
|
+
* surfaceOp 范围同步平移。
|
|
214
|
+
* @param {string[]} parts - text.split('\n')。
|
|
215
|
+
* @param {(event: object) => boolean} dropPredicate - 命中即删除该事件行。
|
|
216
|
+
* @returns {{ text: string, removed: number, renumbered: number }}
|
|
217
|
+
*/
|
|
218
|
+
function renumberWithDrops(parts, dropPredicate) {
|
|
219
|
+
const dropped = new Set();
|
|
220
|
+
for (const raw of parts) {
|
|
221
|
+
if (!raw.trim()) continue;
|
|
222
|
+
let v;
|
|
223
|
+
try {
|
|
224
|
+
v = JSON.parse(raw);
|
|
225
|
+
} catch {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (typeof v.seq === 'number' && dropPredicate(v)) dropped.add(v.seq);
|
|
229
|
+
}
|
|
230
|
+
const sortedDrops = [...dropped].sort((a, b) => a - b);
|
|
231
|
+
const shiftFor = (seq) => {
|
|
232
|
+
let lo = 0;
|
|
233
|
+
let hi = sortedDrops.length;
|
|
234
|
+
while (lo < hi) {
|
|
235
|
+
const mid = (lo + hi) >> 1;
|
|
236
|
+
if (sortedDrops[mid] < seq) lo = mid + 1;
|
|
237
|
+
else hi = mid;
|
|
238
|
+
}
|
|
239
|
+
return lo;
|
|
240
|
+
};
|
|
241
|
+
const isDrop = (seq) => dropped.has(seq);
|
|
242
|
+
const out = [];
|
|
243
|
+
let removed = 0;
|
|
244
|
+
let renumbered = 0;
|
|
245
|
+
for (const raw of parts) {
|
|
246
|
+
if (!raw.trim()) {
|
|
247
|
+
out.push(raw);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
let v;
|
|
251
|
+
try {
|
|
252
|
+
v = JSON.parse(raw);
|
|
253
|
+
} catch {
|
|
254
|
+
out.push(raw);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (typeof v.seq === 'number' && dropPredicate(v)) {
|
|
258
|
+
removed++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
let touched = false;
|
|
262
|
+
if (typeof v.seq === 'number') {
|
|
263
|
+
const s = shiftFor(v.seq);
|
|
264
|
+
if (s) {
|
|
265
|
+
v.seq -= s;
|
|
266
|
+
touched = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') {
|
|
270
|
+
const s = shiftFor(v.seq0);
|
|
271
|
+
if (s) {
|
|
272
|
+
v.seq0 -= s;
|
|
273
|
+
touched = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(v.sourceEventSeqs)) {
|
|
277
|
+
const next = v.sourceEventSeqs.filter((x) => !isDrop(x)).map((x) => x - shiftFor(x));
|
|
278
|
+
if (next.length !== v.sourceEventSeqs.length || next.some((x, k) => x !== v.sourceEventSeqs[k])) {
|
|
279
|
+
v.sourceEventSeqs = next;
|
|
280
|
+
touched = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (v.surfaceOp && v.surfaceOp.op === 'replace') {
|
|
284
|
+
const ns = v.surfaceOp.start - shiftFor(v.surfaceOp.start);
|
|
285
|
+
const ne = v.surfaceOp.end - shiftFor(v.surfaceOp.end);
|
|
286
|
+
if (ns !== v.surfaceOp.start || ne !== v.surfaceOp.end) {
|
|
287
|
+
v.surfaceOp.start = ns;
|
|
288
|
+
v.surfaceOp.end = ne;
|
|
289
|
+
touched = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
out.push(touched ? JSON.stringify(v) : raw);
|
|
293
|
+
if (touched) renumbered++;
|
|
294
|
+
}
|
|
295
|
+
let fixed = out.join('\n');
|
|
296
|
+
if (!fixed.endsWith('\n')) fixed += '\n';
|
|
297
|
+
return { text: fixed, removed, renumbered };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 删除"本轮运行失败"的轮次(turn/end 带 reason.kind==='error' 的完整轮次:
|
|
302
|
+
* [turnStart..turnEnd],含其中的 user 消息与失败产出),并全量重编号。
|
|
303
|
+
* 用于清掉界面上的失败报错气泡。
|
|
304
|
+
* @param {string} text - JSONL 全文。
|
|
305
|
+
* @returns {{ text: string, removed: number, renumbered: number, failedTurns: number }}
|
|
306
|
+
*/
|
|
307
|
+
export function dropFailedTurnsText(text) {
|
|
308
|
+
const parts = text.split('\n');
|
|
309
|
+
const spans = [];
|
|
310
|
+
let curTurnStart = null;
|
|
311
|
+
for (const raw of parts) {
|
|
312
|
+
if (!raw.trim()) continue;
|
|
313
|
+
let v;
|
|
314
|
+
try {
|
|
315
|
+
v = JSON.parse(raw);
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (v.type === 'turn/start') curTurnStart = v.seq;
|
|
320
|
+
else if (v.type === 'turn/end') {
|
|
321
|
+
if (v.data?.reason?.kind === 'error' && curTurnStart !== null) spans.push([curTurnStart, v.seq]);
|
|
322
|
+
curTurnStart = null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (spans.length === 0) return { text, removed: 0, renumbered: 0, failedTurns: 0 };
|
|
326
|
+
const inSpan = (seq) => spans.some(([s, e]) => seq >= s && seq <= e);
|
|
327
|
+
const r = renumberWithDrops(parts, (v) => inSpan(v.seq));
|
|
328
|
+
return { ...r, failedTurns: spans.length };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 裁剪到最近 keepMessages 条 append 消息(保留其所在 turn 的结构),
|
|
333
|
+
* 同时移除全部 retrace/message-editor marker,并全量重编号。
|
|
334
|
+
* 用于"完整历史超出模型 context 窗口"(MiMo 1M tokens 实锤)。
|
|
335
|
+
* @param {string} text - JSONL 全文。
|
|
336
|
+
* @param {number} keepMessages - 保留的 append 消息数。
|
|
337
|
+
* @returns {{ text: string, removed: number, renumbered: number, kept: number, cutoff: number }}
|
|
338
|
+
*/
|
|
339
|
+
export function trimLastMessagesText(text, keepMessages) {
|
|
340
|
+
const parts = text.split('\n');
|
|
341
|
+
const msgSeqs = [];
|
|
342
|
+
let lastTurnStart = null;
|
|
343
|
+
for (const raw of parts) {
|
|
344
|
+
if (!raw.trim()) continue;
|
|
345
|
+
let v;
|
|
346
|
+
try {
|
|
347
|
+
v = JSON.parse(raw);
|
|
348
|
+
} catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (v.type === 'user/message' || (v.type === 'assistant/message' && v.surfaceOp === 'append')) msgSeqs.push(v.seq);
|
|
352
|
+
}
|
|
353
|
+
const total = msgSeqs.length;
|
|
354
|
+
if (total <= keepMessages) return { text, removed: 0, renumbered: 0, kept: total, cutoff: 0 };
|
|
355
|
+
const cutoffMsg = msgSeqs[total - keepMessages];
|
|
356
|
+
// 往前取到包含 cutoffMsg 的那个 turn 的 turn/start,保住轮次结构
|
|
357
|
+
let cutoff = cutoffMsg;
|
|
358
|
+
for (const raw of parts) {
|
|
359
|
+
if (!raw.trim()) continue;
|
|
360
|
+
let v;
|
|
361
|
+
try {
|
|
362
|
+
v = JSON.parse(raw);
|
|
363
|
+
} catch {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (v.type === 'turn/start' && v.seq < cutoffMsg) cutoff = v.seq;
|
|
367
|
+
}
|
|
368
|
+
const drop = (v) => (typeof v.seq === 'number' && v.seq < cutoff) || isRetraceMarker(v);
|
|
369
|
+
const r = renumberWithDrops(parts, drop);
|
|
370
|
+
return { ...r, kept: keepMessages, cutoff };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 从被遮蔽的 user/assistant 消息做提取式摘要(跨范围均匀采样,不依赖模型)。 */
|
|
374
|
+
function extractiveSummary(msgEvents) {
|
|
375
|
+
const lines = [];
|
|
376
|
+
const step = Math.max(1, Math.ceil(msgEvents.length / 18));
|
|
377
|
+
const sampled = [];
|
|
378
|
+
for (let i = 0; i < msgEvents.length; i += step) sampled.push(msgEvents[i]);
|
|
379
|
+
const last = msgEvents[msgEvents.length - 1];
|
|
380
|
+
if (sampled[sampled.length - 1] !== last) sampled.push(last);
|
|
381
|
+
for (const ev of sampled) {
|
|
382
|
+
let text = '';
|
|
383
|
+
if (ev.type === 'user/message') {
|
|
384
|
+
text = (ev.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
385
|
+
} else if (ev.type === 'assistant/message') {
|
|
386
|
+
text = (ev.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
387
|
+
}
|
|
388
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
389
|
+
if (!text) continue;
|
|
390
|
+
const chunk = text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
391
|
+
lines.push(`${ev.type === 'user/message' ? '问' : '答'} ${chunk}`);
|
|
392
|
+
if (lines.join('\n').length > 1800) break;
|
|
393
|
+
}
|
|
394
|
+
if (lines.length === 0) return '(早期对话无文本内容)';
|
|
395
|
+
return `【早期对话提取式摘要 · 完整原文保留在会话日志与备份中】\n${lines.join('\n')}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* DSH 官方压缩(compaction):不删除任何事件——在日志尾部追加
|
|
400
|
+
* compaction/start → compaction/summary → checkpoint(user/message, replace
|
|
401
|
+
* [start..end]) → compaction/end,把 [start..end] 从模型表面遮蔽,替换为
|
|
402
|
+
* 提取式摘要。旧事件全部保留(append-only 审计),日志一行不删。
|
|
403
|
+
* @param {string} text - JSONL 全文(严格连续)。
|
|
404
|
+
* @param {number} keepMessages - 保留的最近 append 消息数。
|
|
405
|
+
* @returns {{ text: string, compacted: boolean, kept: number, shadowed: number, summary: string }}
|
|
406
|
+
*/
|
|
407
|
+
export function compactLastMessagesText(text, keepMessages) {
|
|
408
|
+
const parts = text.split('\n');
|
|
409
|
+
const bySeq = new Map();
|
|
410
|
+
const surfaceNodes = [];
|
|
411
|
+
for (let i = 1; i < parts.length; i++) {
|
|
412
|
+
const raw = parts[i].trim();
|
|
413
|
+
if (!raw) continue;
|
|
414
|
+
const decoded = decodeLine(raw);
|
|
415
|
+
if (decoded === null) continue;
|
|
416
|
+
for (const ev of decoded) {
|
|
417
|
+
bySeq.set(ev.seq, ev);
|
|
418
|
+
if (SURFACE_TYPES.has(ev.type)) {
|
|
419
|
+
const op = ev.surfaceOp;
|
|
420
|
+
if (op === 'append') surfaceNodes.push(ev.seq);
|
|
421
|
+
else if (op && op.op === 'replace') {
|
|
422
|
+
const s = surfaceNodes.indexOf(op.start);
|
|
423
|
+
const e = surfaceNodes.indexOf(op.end);
|
|
424
|
+
if (s !== -1 && e !== -1 && s <= e) surfaceNodes.splice(s, e - s + 1);
|
|
425
|
+
surfaceNodes.push(ev.seq);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const appendMsgs = surfaceNodes.filter((seq) => {
|
|
431
|
+
const ev = bySeq.get(seq);
|
|
432
|
+
return ev && (ev.type === 'user/message' || (ev.type === 'assistant/message' && ev.surfaceOp === 'append'));
|
|
433
|
+
});
|
|
434
|
+
const total = appendMsgs.length;
|
|
435
|
+
if (total <= keepMessages) return { text, compacted: false, kept: total, shadowed: 0, summary: '' };
|
|
436
|
+
const target = appendMsgs[total - keepMessages];
|
|
437
|
+
let boundary = target;
|
|
438
|
+
for (const seq of appendMsgs) {
|
|
439
|
+
const ev = bySeq.get(seq);
|
|
440
|
+
if (ev.type === 'user/message' && seq <= target) boundary = seq;
|
|
441
|
+
}
|
|
442
|
+
const shadowedSeqs = surfaceNodes.filter((seq) => seq < boundary);
|
|
443
|
+
const start = shadowedSeqs[0];
|
|
444
|
+
const end = shadowedSeqs[shadowedSeqs.length - 1];
|
|
445
|
+
const summary = extractiveSummary(shadowedSeqs.map((seq) => bySeq.get(seq)).filter(Boolean));
|
|
446
|
+
// 压缩事件插入到"第一个保留事件"之前(seq = boundary..boundary+3),
|
|
447
|
+
// 之后的事件整体 +4 重编号——保证表面顺序为 [checkpoint, 近期轮次…]。
|
|
448
|
+
const boundarySeq = boundary;
|
|
449
|
+
const compactionId = `dsh-fix-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
450
|
+
const startSeq = boundarySeq;
|
|
451
|
+
const summarySeq = boundarySeq + 1;
|
|
452
|
+
const checkpointSeq = boundarySeq + 2;
|
|
453
|
+
const endSeq = boundarySeq + 3;
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const appended = [
|
|
456
|
+
{ type: 'compaction/start', seq: startSeq, time: now, data: { compactionId, turn: null } },
|
|
457
|
+
{
|
|
458
|
+
type: 'compaction/summary',
|
|
459
|
+
seq: summarySeq,
|
|
460
|
+
time: now,
|
|
461
|
+
data: {
|
|
462
|
+
compactionId,
|
|
463
|
+
summary,
|
|
464
|
+
shadowedRange: { start, end },
|
|
465
|
+
shadowedSeqs,
|
|
466
|
+
shadowedTokenCount: Math.round(summary.length / 4),
|
|
467
|
+
provider: 'dsh-log-contract',
|
|
468
|
+
model: 'extractive',
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
type: 'user/message',
|
|
473
|
+
seq: checkpointSeq,
|
|
474
|
+
time: now,
|
|
475
|
+
surfaceOp: { op: 'replace', start, end },
|
|
476
|
+
sourceEventSeqs: [startSeq, summarySeq, ...shadowedSeqs],
|
|
477
|
+
data: {
|
|
478
|
+
id: `checkpoint-${compactionId}`,
|
|
479
|
+
role: 'user',
|
|
480
|
+
source: { kind: 'user' },
|
|
481
|
+
content: [{ type: 'text', text: summary }],
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
{ type: 'compaction/end', seq: endSeq, time: now, data: { compactionId, turn: null } },
|
|
485
|
+
];
|
|
486
|
+
// 找到第一个事件 seq >= boundary 的行(保留区起点),在其前插入压缩事件
|
|
487
|
+
let insertIdx = parts.length - 1;
|
|
488
|
+
for (let i = 1; i < parts.length; i++) {
|
|
489
|
+
const raw = parts[i].trim();
|
|
490
|
+
if (!raw) continue;
|
|
491
|
+
const decoded = decodeLine(raw);
|
|
492
|
+
if (decoded === null || decoded.length === 0) continue;
|
|
493
|
+
if (decoded[0].seq >= boundarySeq) {
|
|
494
|
+
insertIdx = i;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const shiftFields = (v) => {
|
|
499
|
+
if (typeof v.seq === 'number') v.seq += 4;
|
|
500
|
+
if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') v.seq0 += 4;
|
|
501
|
+
if (Array.isArray(v.sourceEventSeqs)) v.sourceEventSeqs = v.sourceEventSeqs.map((x) => x + 4);
|
|
502
|
+
if (v.surfaceOp && v.surfaceOp.op === 'replace') {
|
|
503
|
+
v.surfaceOp.start += 4;
|
|
504
|
+
v.surfaceOp.end += 4;
|
|
505
|
+
}
|
|
506
|
+
return v;
|
|
507
|
+
};
|
|
508
|
+
const out = [];
|
|
509
|
+
for (let i = 0; i < parts.length; i++) {
|
|
510
|
+
if (i === insertIdx) for (const e of appended) out.push(JSON.stringify(e));
|
|
511
|
+
const raw = parts[i];
|
|
512
|
+
if (i > 0 && i >= insertIdx && raw.trim()) {
|
|
513
|
+
let v;
|
|
514
|
+
try {
|
|
515
|
+
v = JSON.parse(raw);
|
|
516
|
+
} catch {
|
|
517
|
+
out.push(raw);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (typeof v.seq === 'number') {
|
|
521
|
+
out.push(JSON.stringify(shiftFields(v)));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
out.push(raw);
|
|
526
|
+
}
|
|
527
|
+
const newText = out.join('\n');
|
|
528
|
+
if (!newText.endsWith('\n')) return { text: newText + '\n', compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
|
|
529
|
+
return { text: newText, compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
|
|
530
|
+
}
|
|
531
|
+
|
|
211
532
|
/**
|
|
212
533
|
* 按官方 writer 帧格式重建 .jsonl.zstd(帧1=header,帧2=其余,均带 checksum)。
|
|
213
534
|
* @param {string} text - JSONL 全文(以 "\n" 结尾)。
|
|
@@ -232,7 +553,10 @@ export function rebuildZstdText(text) {
|
|
|
232
553
|
/**
|
|
233
554
|
* 对单个会话日志执行诊断 +(可选)修复。
|
|
234
555
|
* @param {string} file - .jsonl 或 .jsonl.zstd 路径。
|
|
235
|
-
* @param {{
|
|
556
|
+
* @param {{
|
|
557
|
+
* removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
|
|
558
|
+
* apply?: boolean, backupDir?: string
|
|
559
|
+
* }} opts
|
|
236
560
|
* @returns {{
|
|
237
561
|
* file, ok, issues: Array<{kind:string, detail:string}>,
|
|
238
562
|
* removed, renumbered, backupPath, applied,
|
|
@@ -270,25 +594,45 @@ export function repairSession(file, opts = {}) {
|
|
|
270
594
|
}
|
|
271
595
|
let removed = 0;
|
|
272
596
|
let renumbered = 0;
|
|
597
|
+
const applyFix = (label, r, detail) => {
|
|
598
|
+
removed += r.removed;
|
|
599
|
+
renumbered += r.renumbered;
|
|
600
|
+
if (r.removed > 0) {
|
|
601
|
+
issues.push({ kind: label, detail });
|
|
602
|
+
plain = r.text;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
if (opts.dropFailedTurns) {
|
|
606
|
+
const r = dropFailedTurnsText(plain);
|
|
607
|
+
applyFix('failed-turns', r, `移除 ${r.failedTurns} 个失败轮次("本轮运行失败"报错气泡,重编号 ${r.renumbered} 行)`);
|
|
608
|
+
}
|
|
273
609
|
if (opts.removeMarkers) {
|
|
274
610
|
const r = removeMarkersText(plain);
|
|
275
|
-
removed
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
611
|
+
applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
|
|
612
|
+
}
|
|
613
|
+
if (typeof opts.trimLast === 'number') {
|
|
614
|
+
const r = trimLastMessagesText(plain, opts.trimLast);
|
|
615
|
+
applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
|
|
616
|
+
}
|
|
617
|
+
if (typeof opts.compactLast === 'number') {
|
|
618
|
+
const r = compactLastMessagesText(plain, opts.compactLast);
|
|
619
|
+
if (r.compacted) {
|
|
620
|
+
issues.push({ kind: 'compact', detail: `官方压缩:遮蔽 ${r.shadowed} 个 surface 节点,保留最近 ${r.kept} 条消息;旧事件全部保留(日志零删除)` });
|
|
279
621
|
plain = r.text;
|
|
280
|
-
// 修复后复检
|
|
281
|
-
const scan2 = strictScanText(plain);
|
|
282
|
-
if (scan2.failures.length > 0) issues.push({ kind: 'post-scan', detail: `修复后仍有 ${scan2.failures.length} 处 seq 不连续` });
|
|
283
|
-
const check2 = validateSessionLog(loadSessionLogFromText(plain));
|
|
284
|
-
if (!check2.ok) {
|
|
285
|
-
const errs2 = check2.violations.filter((v) => v.severity === 'error');
|
|
286
|
-
issues.push({ kind: 'post-contract', detail: `修复后仍有 ${errs2.length} 个 error 级违规` });
|
|
287
|
-
}
|
|
288
622
|
}
|
|
289
623
|
}
|
|
290
|
-
|
|
291
|
-
const
|
|
624
|
+
// 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
|
|
625
|
+
const scanFinal = strictScanText(plain);
|
|
626
|
+
const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
|
|
627
|
+
if (scanFinal.failures.length > 0) {
|
|
628
|
+
const f = scanFinal.failures[0];
|
|
629
|
+
issues.push({ kind: 'final-scan', detail: `修复后仍不连续:line ${f.line} 期望 ${f.expected} 实际 ${f.got}——${scanFinal.failures.length} 处` });
|
|
630
|
+
}
|
|
631
|
+
if (!checkFinal.ok) {
|
|
632
|
+
const errs = checkFinal.violations.filter((v) => v.severity === 'error');
|
|
633
|
+
issues.push({ kind: 'final-contract', detail: `修复后仍有 ${errs.length} 个 error 级违规(首条:${errs[0]?.id ?? '-'} ${errs[0]?.message?.slice(0, 90) ?? ''})` });
|
|
634
|
+
}
|
|
635
|
+
const ok = scanFinal.failures.length === 0 && checkFinal.ok;
|
|
292
636
|
let backupPath = null;
|
|
293
637
|
let applied = false;
|
|
294
638
|
if (opts.apply && ok) {
|
package/lib/validate.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
pluginViolations,
|
|
18
18
|
replaySurface,
|
|
19
19
|
violation,
|
|
20
|
+
tokenMeterViolations,
|
|
20
21
|
wireViolations,
|
|
21
22
|
} from './checks.js';
|
|
22
23
|
|
|
@@ -101,6 +102,9 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
101
102
|
violations.push(...replay.violations);
|
|
102
103
|
|
|
103
104
|
const folded = finalFold(events.map((e) => e.event));
|
|
105
|
+
|
|
106
|
+
// ── T · token meter 配对(事故根因 3)──
|
|
107
|
+
violations.push(...tokenMeterViolations(events));
|
|
104
108
|
if (folded.error) {
|
|
105
109
|
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
106
110
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-contract",
|
|
3
3
|
"description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|