dsh-log-contract 0.3.5 → 0.3.7
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 +192 -99
- package/README.zh.md +253 -0
- package/bin/dsh-log-contract.mjs +61 -6
- package/lib/checks.js +5 -1
- package/lib/index.js +2 -2
- package/lib/repair.js +509 -5
- package/lib/validate.js +69 -0
- package/package.json +10 -7
package/lib/repair.js
CHANGED
|
@@ -156,10 +156,11 @@ export function clipCrossStepSourcesText(text) {
|
|
|
156
156
|
if (event.type !== 'assistant/message' || !Array.isArray(event.sourceEventSeqs) || event.sourceEventSeqs.length === 0) continue;
|
|
157
157
|
const { turn, step } = event.data ?? {};
|
|
158
158
|
if (turn == null || step == null) continue; // turn-null marker 由 neutralize 处理
|
|
159
|
+
if (event.data?.usage === void 0) continue; // 无 usage 不触发 _estimateProviderAssistant(官方 :592 前提)——replace marker 的 sourceEventSeqs 是 S5 遮蔽语义,非 chunk 引用合法,不可裁
|
|
159
160
|
let dirty = false;
|
|
160
161
|
const kept = event.sourceEventSeqs.filter((s) => {
|
|
161
162
|
const src = bySeq.get(s)?.event;
|
|
162
|
-
if (!src || src.type !== 'assistant/chunk')
|
|
163
|
+
if (!src || src.type !== 'assistant/chunk') { dirty = true; return false; } // 非 chunk/缺失引用官方 :644 直接 throw → 裁剪
|
|
163
164
|
if (src.data?.turn === turn && src.data?.step === step) return true;
|
|
164
165
|
dirty = true;
|
|
165
166
|
return false;
|
|
@@ -316,10 +317,19 @@ export function removeMarkersText(text) {
|
|
|
316
317
|
* @param {(event: object) => boolean} dropPredicate - 命中即删除该事件行。
|
|
317
318
|
* @returns {{ text: string, removed: number, renumbered: number }}
|
|
318
319
|
*/
|
|
319
|
-
function renumberWithDrops(parts, dropPredicate) {
|
|
320
|
+
function renumberWithDrops(parts, dropPredicate, dropLinePredicate = null) {
|
|
321
|
+
// 行身份删除(dropLinePredicate):双流交织时保留行与删除行可能共享 seq
|
|
322
|
+
// 值,按 seq 值判删会误删保留行——extractTurn 等场景必须按行身份删除。
|
|
323
|
+
// 两种判据任一命中即删;dropLinePredicate 用行索引(parts 下标)。
|
|
320
324
|
const dropped = new Set();
|
|
321
|
-
|
|
325
|
+
const droppedLines = new Set();
|
|
326
|
+
for (let i = 0; i < parts.length; i++) {
|
|
327
|
+
const raw = parts[i];
|
|
322
328
|
if (!raw.trim()) continue;
|
|
329
|
+
if (dropLinePredicate && dropLinePredicate(i)) {
|
|
330
|
+
droppedLines.add(i);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
323
333
|
let v;
|
|
324
334
|
try {
|
|
325
335
|
v = JSON.parse(raw);
|
|
@@ -343,11 +353,16 @@ function renumberWithDrops(parts, dropPredicate) {
|
|
|
343
353
|
const out = [];
|
|
344
354
|
let removed = 0;
|
|
345
355
|
let renumbered = 0;
|
|
346
|
-
for (
|
|
356
|
+
for (let i = 0; i < parts.length; i++) {
|
|
357
|
+
const raw = parts[i];
|
|
347
358
|
if (!raw.trim()) {
|
|
348
359
|
out.push(raw);
|
|
349
360
|
continue;
|
|
350
361
|
}
|
|
362
|
+
if (dropLinePredicate && dropLinePredicate(i)) {
|
|
363
|
+
removed++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
351
366
|
let v;
|
|
352
367
|
try {
|
|
353
368
|
v = JSON.parse(raw);
|
|
@@ -398,6 +413,101 @@ function renumberWithDrops(parts, dropPredicate) {
|
|
|
398
413
|
return { text: fixed, removed, renumbered };
|
|
399
414
|
}
|
|
400
415
|
|
|
416
|
+
/**
|
|
417
|
+
* 按行身份保留 + 全量重编号(extract-turn / keep-ranges 用)。
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ 与 renumberWithDrops 不同:本函数按「行是否保留」决定删除(双流交织时
|
|
420
|
+
* 保留行与删除行可能共享 seq 值,按 seq 值判删会误删保留行——2026-08-31
|
|
421
|
+
* 独立审查实测复现),且保留行**全量重映射** seq 从 0 连续(参考原工具
|
|
422
|
+
* keep-ranges.mjs 的 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
|
|
423
|
+
* surfaceOp 范围。header 行(首行)恒保留且不改。
|
|
424
|
+
* @param {string[]} parts - 按 \n 切分的行数组。
|
|
425
|
+
* @param {(lineIdx: number) => boolean} keepLinePredicate - 行身份保留判定。
|
|
426
|
+
* @returns {{ text: string, removed: number, renumbered: number }}
|
|
427
|
+
*/
|
|
428
|
+
function renumberKeptLines(parts, keepLinePredicate) {
|
|
429
|
+
// 第一遍:按物理序收集保留行的事件 seq(chunk 行展开,参考 keep-ranges 原工具)
|
|
430
|
+
const keptLineIdx = [];
|
|
431
|
+
for (let i = 0; i < parts.length; i++) {
|
|
432
|
+
const raw = parts[i];
|
|
433
|
+
if (!raw.trim()) continue;
|
|
434
|
+
if (i === 0) { keptLineIdx.push(i); continue; } // header
|
|
435
|
+
if (keepLinePredicate(i)) keptLineIdx.push(i);
|
|
436
|
+
}
|
|
437
|
+
// 建立 old→new 映射:保留行的展开事件按物理序分配 0,1,2,…
|
|
438
|
+
const seqMap = new Map();
|
|
439
|
+
let counter = 0;
|
|
440
|
+
for (const li of keptLineIdx) {
|
|
441
|
+
if (li === 0) continue; // header 无 seq
|
|
442
|
+
const decoded = decodeLine(parts[li]);
|
|
443
|
+
if (!decoded) continue;
|
|
444
|
+
for (const ev of decoded) {
|
|
445
|
+
if (typeof ev.seq === 'number' && !seqMap.has(ev.seq)) seqMap.set(ev.seq, counter++);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const remap = (s) => (typeof s === 'number' && seqMap.has(s) ? seqMap.get(s) : null);
|
|
449
|
+
// 第二遍:重写保留行
|
|
450
|
+
const out = [];
|
|
451
|
+
let removed = 0;
|
|
452
|
+
let renumbered = 0;
|
|
453
|
+
const keptSet = new Set(keptLineIdx);
|
|
454
|
+
for (let i = 0; i < parts.length; i++) {
|
|
455
|
+
const raw = parts[i];
|
|
456
|
+
if (!raw.trim()) {
|
|
457
|
+
out.push(raw);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (!keptSet.has(i)) {
|
|
461
|
+
removed++;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (i === 0) { out.push(raw); continue; } // header 原样
|
|
465
|
+
let v;
|
|
466
|
+
try {
|
|
467
|
+
v = JSON.parse(raw);
|
|
468
|
+
} catch {
|
|
469
|
+
out.push(raw);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
let touched = false;
|
|
473
|
+
if (typeof v.seq === 'number') {
|
|
474
|
+
const ns = remap(v.seq);
|
|
475
|
+
if (ns !== null && ns !== v.seq) {
|
|
476
|
+
v.seq = ns;
|
|
477
|
+
touched = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') {
|
|
481
|
+
const ns = remap(v.seq0);
|
|
482
|
+
if (ns !== null && ns !== v.seq0) {
|
|
483
|
+
v.seq0 = ns;
|
|
484
|
+
touched = true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (Array.isArray(v.sourceEventSeqs)) {
|
|
488
|
+
const next = v.sourceEventSeqs.map((s) => remap(s) ?? s);
|
|
489
|
+
if (next.some((x, k) => x !== v.sourceEventSeqs[k])) {
|
|
490
|
+
v.sourceEventSeqs = next;
|
|
491
|
+
touched = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (v.surfaceOp && v.surfaceOp.op === 'replace') {
|
|
495
|
+
const ns = remap(v.surfaceOp.start);
|
|
496
|
+
const ne = remap(v.surfaceOp.end);
|
|
497
|
+
if (ns !== null && ne !== null && (ns !== v.surfaceOp.start || ne !== v.surfaceOp.end)) {
|
|
498
|
+
v.surfaceOp.start = ns;
|
|
499
|
+
v.surfaceOp.end = ne;
|
|
500
|
+
touched = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
out.push(touched ? JSON.stringify(v) : raw);
|
|
504
|
+
if (touched) renumbered++;
|
|
505
|
+
}
|
|
506
|
+
let fixed = out.join('\n');
|
|
507
|
+
if (!fixed.endsWith('\n')) fixed += '\n';
|
|
508
|
+
return { text: fixed, removed, renumbered };
|
|
509
|
+
}
|
|
510
|
+
|
|
401
511
|
/**
|
|
402
512
|
* 删除"本轮运行失败"的轮次(turn/end 带 reason.kind==='error' 的完整轮次:
|
|
403
513
|
* [turnStart..turnEnd],含其中的 user 消息与失败产出),并全量重编号。
|
|
@@ -429,6 +539,338 @@ export function dropFailedTurnsText(text) {
|
|
|
429
539
|
return { ...r, failedTurns: spans.length };
|
|
430
540
|
}
|
|
431
541
|
|
|
542
|
+
/**
|
|
543
|
+
* 尾部 seq 统一平移(fix-tail 收编,源:tools/trim-session.mjs fix-tail 模式)。
|
|
544
|
+
*
|
|
545
|
+
* 修复多写入者/旧光标造成的尾部 seq 回归/间隙:从 startSeq 起(含)的所有
|
|
546
|
+
* 事件的 seq / seq0 / sourceEventSeqs 统一加减 delta。
|
|
547
|
+
* @param {string} text - JSONL 全文。
|
|
548
|
+
* @param {number} startSeq - 平移起点(该 seq 及之后的事件全部平移)。
|
|
549
|
+
* @param {number} delta - 偏移量(减数语义:要加 N 传 −N;要减 N 传 +N)。
|
|
550
|
+
* @returns {{ text: string, changed: number, startSeq: number, delta: number }}
|
|
551
|
+
*/
|
|
552
|
+
export function tailRenumberText(text, startSeq, delta) {
|
|
553
|
+
if (!Number.isInteger(delta) || delta === 0) return { text, changed: 0, startSeq, delta };
|
|
554
|
+
const parts = text.split('\n');
|
|
555
|
+
const out = [];
|
|
556
|
+
let changed = 0;
|
|
557
|
+
for (const raw of parts) {
|
|
558
|
+
if (!raw.trim()) {
|
|
559
|
+
out.push(raw);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
let v;
|
|
563
|
+
try {
|
|
564
|
+
v = JSON.parse(raw);
|
|
565
|
+
} catch {
|
|
566
|
+
out.push(raw);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
let modified = false;
|
|
570
|
+
const seq = v.seq;
|
|
571
|
+
if (typeof seq === 'number' && seq >= startSeq) {
|
|
572
|
+
const ns = seq - delta;
|
|
573
|
+
if (ns < 0) return { text, changed: 0, startSeq, delta, error: `seq ${seq} 平移后为负(delta 过大或起点有误)` };
|
|
574
|
+
v.seq = ns;
|
|
575
|
+
modified = true;
|
|
576
|
+
}
|
|
577
|
+
if (typeof v.seq0 === 'number' && v.seq0 >= startSeq) {
|
|
578
|
+
const ns = v.seq0 - delta;
|
|
579
|
+
if (ns < 0) return { text, changed: 0, startSeq, delta, error: `seq0 ${v.seq0} 平移后为负(delta 过大或起点有误)` };
|
|
580
|
+
v.seq0 = ns;
|
|
581
|
+
modified = true;
|
|
582
|
+
}
|
|
583
|
+
if (Array.isArray(v.sourceEventSeqs)) {
|
|
584
|
+
let mapped;
|
|
585
|
+
try {
|
|
586
|
+
mapped = v.sourceEventSeqs.map((s) => {
|
|
587
|
+
if (s < startSeq) return s;
|
|
588
|
+
const ns = s - delta;
|
|
589
|
+
if (ns < 0) throw new Error(`sourceEventSeqs ${s} 平移后为负`);
|
|
590
|
+
return ns;
|
|
591
|
+
});
|
|
592
|
+
} catch (err) {
|
|
593
|
+
// 与 seq/seq0 的优雅返回一致:错误走 error 字段而非未捕获异常
|
|
594
|
+
return { text, changed: 0, startSeq, delta, error: err.message };
|
|
595
|
+
}
|
|
596
|
+
if (mapped.some((s, i) => s !== v.sourceEventSeqs[i])) {
|
|
597
|
+
v.sourceEventSeqs = mapped;
|
|
598
|
+
modified = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (modified) changed++;
|
|
602
|
+
out.push(JSON.stringify(v));
|
|
603
|
+
}
|
|
604
|
+
return { text: out.join('\n'), changed, startSeq, delta };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* fork 边界孤儿 spliced 原地归零(--neutralize-orphan)。
|
|
609
|
+
*
|
|
610
|
+
* 2026-08-28 fork 边界事故:裁剪后历史 inbox spliced 残留(已消费消息被
|
|
611
|
+
* 重新排队 → UI 显示"待排队消息")。与 neutralize 同类:原地改
|
|
612
|
+
* `removedCount → 0`(start/inserted 不变,seq/行数不变 → 附着力安全)。
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ 判定(2026-08-31 独立审查修正):不能对"所有 removedCount>0 的
|
|
615
|
+
* next-turn spliced"下手——DSH 每个轮次消费消息都会写这种 spliced
|
|
616
|
+
* (archive 实测 3069 处 removedCount>0,但真 I1 违规只 2 处)。只归零
|
|
617
|
+
* **实际触发 I1 违规**的那条:按官方 inbox 重放(checks.js
|
|
618
|
+
* inboxReplayViolations 同款状态机),`start + removedCount > 队列长`
|
|
619
|
+
* 即孤儿——归零后队列重放不再越界、已消费消息不再重复投递。
|
|
620
|
+
*
|
|
621
|
+
* @param {string} text - JSONL 全文。
|
|
622
|
+
* @returns {{ text: string, neutralized: number, seqs: number[] }}
|
|
623
|
+
*/
|
|
624
|
+
export function neutralizeOrphanText(text) {
|
|
625
|
+
const parts = text.split('\n');
|
|
626
|
+
// 解析 header.seedLength(与 checks.js inboxReplayViolations 同口径)
|
|
627
|
+
let seedLength = 0;
|
|
628
|
+
try {
|
|
629
|
+
const hdr = JSON.parse(parts[0] || '');
|
|
630
|
+
if (typeof hdr?.seedLength === 'number' && Number.isSafeInteger(hdr.seedLength)) seedLength = hdr.seedLength;
|
|
631
|
+
} catch { /* header 缺失时从 0 重放 */ }
|
|
632
|
+
const state = { 'next-turn': [], 'next-step': [] };
|
|
633
|
+
const seqs = [];
|
|
634
|
+
let neutralized = 0;
|
|
635
|
+
for (let i = 1; i < parts.length; i++) {
|
|
636
|
+
const raw = parts[i];
|
|
637
|
+
if (!raw.trim()) continue;
|
|
638
|
+
let v;
|
|
639
|
+
try {
|
|
640
|
+
v = JSON.parse(raw);
|
|
641
|
+
} catch {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!v || typeof v !== 'object' || v.type !== 'agent/inbox/spliced') continue;
|
|
645
|
+
if (v.seq < seedLength) continue; // seed 区域不重放(与官方一致)
|
|
646
|
+
const splice = v.data;
|
|
647
|
+
if (!splice || typeof splice.target !== 'string' || !['next-turn', 'next-step'].includes(splice.target)) continue;
|
|
648
|
+
const inbox = state[splice.target];
|
|
649
|
+
const removedCount = splice.removedCount ?? 0;
|
|
650
|
+
const start = splice.start ?? 0;
|
|
651
|
+
const inserted = Array.isArray(splice.inserted) ? splice.inserted : [];
|
|
652
|
+
// 只有越界(start+removedCount > 队列长)才判为孤儿——与 I1 规则同判定
|
|
653
|
+
if (start + removedCount > inbox.length && removedCount > 0) {
|
|
654
|
+
v.data.removedCount = 0;
|
|
655
|
+
parts[i] = JSON.stringify(v);
|
|
656
|
+
seqs.push(v.seq);
|
|
657
|
+
neutralized++;
|
|
658
|
+
// 归零后照常推进重放状态(removedCount=0 → 插入 inserted,不删除)
|
|
659
|
+
inbox.splice(start, 0, ...inserted);
|
|
660
|
+
} else {
|
|
661
|
+
// 合法 spliced:正常推进重放状态
|
|
662
|
+
inbox.splice(start, removedCount, ...inserted);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return { text: parts.join('\n'), neutralized, seqs };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 双流交织恢复:保留指定轮次(extract-turn 收编,源:tools/recover-interleaved.mjs)。
|
|
670
|
+
*
|
|
671
|
+
* 并发写入把两条事件流交织进同一文件(旧光标重放流 + 真实新轮次)。本原语:
|
|
672
|
+
* 保留 keepTurn 轮次的全部事件 + 其间的无 turn 系统事件(spliced/user/request
|
|
673
|
+
* 等),丢弃其余(重放流、turn-null marker、非目标轮次),全量重编号;
|
|
674
|
+
* 可选把该轮次的第二个 turn/start 起改号为 secondTurnTo(避免未闭合 turn 冲突)。
|
|
675
|
+
* @param {string} text - JSONL 全文(含 header 行)。
|
|
676
|
+
* @param {number} keepTurn - 要保留的轮次号。
|
|
677
|
+
* @param {number|null} secondTurnTo - 第二个同名 turn/start 起改号(默认 null 不改)。
|
|
678
|
+
* @returns {{ text: string, removed: number, renumbered: number, kept: number, secondRenamed: boolean }}
|
|
679
|
+
*/
|
|
680
|
+
export function extractTurnText(text, keepTurn, secondTurnTo = null) {
|
|
681
|
+
const parts = text.split('\n');
|
|
682
|
+
const KEEP_NULL_TYPES = new Set(['agent/inbox/spliced', 'user/message', 'request/header', 'session/end-seed', 'command/run', 'command/done']);
|
|
683
|
+
// 第一轮:按行身份决定去留(保留 turn===keepTurn 的行 + 无 turn 系统事件
|
|
684
|
+
// 白名单),并就地改第二个同名轮次的 turn 号。
|
|
685
|
+
// ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq(独立审查
|
|
686
|
+
// 实测复现内容静默丢失)——按行身份删除才安全。
|
|
687
|
+
const dropLines = new Set();
|
|
688
|
+
let sawFirstTurnStart = false;
|
|
689
|
+
let inRenumberTurn = false;
|
|
690
|
+
let kept = 0;
|
|
691
|
+
for (let i = 0; i < parts.length; i++) {
|
|
692
|
+
const raw = parts[i];
|
|
693
|
+
if (!raw.trim()) continue;
|
|
694
|
+
let v;
|
|
695
|
+
try {
|
|
696
|
+
v = JSON.parse(raw);
|
|
697
|
+
} catch {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (i === 0 || v.type === 'session') { kept++; continue; } // header 永远保留
|
|
701
|
+
const d = v.data || {};
|
|
702
|
+
const turn = d.turn;
|
|
703
|
+
const t = v.type;
|
|
704
|
+
let keep = false;
|
|
705
|
+
if (turn === keepTurn) keep = true;
|
|
706
|
+
else if (turn == null && KEEP_NULL_TYPES.has(t)) keep = true;
|
|
707
|
+
if (t === 'turn/start' && d.turn === keepTurn) {
|
|
708
|
+
if (sawFirstTurnStart) inRenumberTurn = true;
|
|
709
|
+
else sawFirstTurnStart = true;
|
|
710
|
+
}
|
|
711
|
+
if (keep) {
|
|
712
|
+
kept++;
|
|
713
|
+
if (inRenumberTurn && turn === keepTurn && secondTurnTo !== null) {
|
|
714
|
+
v.data.turn = secondTurnTo;
|
|
715
|
+
parts[i] = JSON.stringify(v);
|
|
716
|
+
}
|
|
717
|
+
} else {
|
|
718
|
+
dropLines.add(i);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (kept === 0) return { text, removed: 0, renumbered: 0, kept: 0, secondRenamed: false };
|
|
722
|
+
// 重编号:按行身份保留 + 全量重映射 seq(从 0 连续),同步所有引用
|
|
723
|
+
const r = renumberKeptLines(parts, (i) => !dropLines.has(i));
|
|
724
|
+
return { ...r, kept, secondRenamed: secondTurnTo !== null && inRenumberTurn };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 只保留指定 seq 区间,其余删除 + 全量重编号(keep-ranges 收编,
|
|
729
|
+
* 源:tools/keep-ranges.mjs)。
|
|
730
|
+
*
|
|
731
|
+
* 从交织/污染文件中提取干净区段。区间为 1-based 行号(含端点),
|
|
732
|
+
* 如 "10-20,40-50";区段外的行丢弃。header 行(首行)永远保留。
|
|
733
|
+
* @param {string} text - JSONL 全文(含 header 行)。
|
|
734
|
+
* @param {string} rangesSpec - 区间串,如 "10-20,40-50"。
|
|
735
|
+
* @returns {{ text: string, removed: number, renumbered: number, keptLines: number }}
|
|
736
|
+
*/
|
|
737
|
+
export function keepRangesText(text, rangesSpec) {
|
|
738
|
+
const ranges = String(rangesSpec).split(',').map((part) => {
|
|
739
|
+
const m = /^(\d+)-(\d+)$/.exec(part.trim());
|
|
740
|
+
if (!m) throw new Error(`区间格式错误: "${part}"(应为 a-b,如 10-20)`);
|
|
741
|
+
const a = Number(m[1]);
|
|
742
|
+
const b = Number(m[2]);
|
|
743
|
+
if (a > b) throw new Error(`区间起点大于终点: "${part}"`);
|
|
744
|
+
return [a, b];
|
|
745
|
+
});
|
|
746
|
+
const parts = text.split('\n');
|
|
747
|
+
// 按行身份删除(双流交织时保留行与删除行可能共享 seq——独立审查发现)
|
|
748
|
+
const dropLines = new Set();
|
|
749
|
+
let keptLines = 0;
|
|
750
|
+
for (let i = 0; i < parts.length; i++) {
|
|
751
|
+
if (i === 0) { keptLines++; continue; } // header 永远保留
|
|
752
|
+
const one = i + 1; // 1-based 行号
|
|
753
|
+
const inRange = ranges.some(([a, b]) => one >= a && one <= b);
|
|
754
|
+
if (inRange) keptLines++;
|
|
755
|
+
else dropLines.add(i);
|
|
756
|
+
}
|
|
757
|
+
if (keptLines === 1) return { text, removed: 0, renumbered: 0, keptLines, error: '区间未命中任何数据行(仅 header 保留)——请检查 1-based 行号' };
|
|
758
|
+
const r = renumberKeptLines(parts, (i) => !dropLines.has(i));
|
|
759
|
+
return { ...r, keptLines };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 消息文本 token 估算(L5:中文密度校准)。
|
|
764
|
+
*
|
|
765
|
+
* 2026-08 事故教训:chars/4 对中文密集内容低估 ~3.7×(实际 ≈ 字符数×0.94)。
|
|
766
|
+
* 本函数按消息类型取文本并加权:
|
|
767
|
+
* - 中文/全角字符:×0.94(实测密度,不是 /4);
|
|
768
|
+
* - 其余字符(ASCII/空格/标点):×0.25(近似词元密度);
|
|
769
|
+
* - JSON 结构开销(每消息 envelope):+12 tokens 固定。
|
|
770
|
+
* @param {string} text - JSONL 全文(含 header)。
|
|
771
|
+
* @returns {{ tokens: number, cjk: number, other: number }}
|
|
772
|
+
*/
|
|
773
|
+
export function estimateTokensText(text) {
|
|
774
|
+
const parts = text.split('\n');
|
|
775
|
+
let cjk = 0;
|
|
776
|
+
let other = 0;
|
|
777
|
+
let messages = 0;
|
|
778
|
+
for (const raw of parts) {
|
|
779
|
+
if (!raw.trim()) continue;
|
|
780
|
+
let v;
|
|
781
|
+
try {
|
|
782
|
+
v = JSON.parse(raw);
|
|
783
|
+
} catch {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
let body = '';
|
|
787
|
+
if (v.type === 'user/message') {
|
|
788
|
+
body = (v.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
789
|
+
messages++;
|
|
790
|
+
} else if (v.type === 'assistant/message' && v.surfaceOp === 'append') {
|
|
791
|
+
body = (v.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
792
|
+
messages++;
|
|
793
|
+
}
|
|
794
|
+
// 按码点分类:CJK 统一表意文字 + 全角符号按中文字符计
|
|
795
|
+
for (const ch of body) {
|
|
796
|
+
const cp = ch.codePointAt(0);
|
|
797
|
+
if (
|
|
798
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK 统一表意
|
|
799
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK 扩展 A
|
|
800
|
+
(cp >= 0xff00 && cp <= 0xffef) || // 全角
|
|
801
|
+
(cp >= 0x3000 && cp <= 0x303f) // CJK 标点
|
|
802
|
+
) cjk++;
|
|
803
|
+
else if (cp > 0x20) other++;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const tokens = Math.ceil(cjk * 0.94 + other * 0.25 + messages * 12);
|
|
807
|
+
return { tokens, cjk, other, messages };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* 按 token 预算裁剪(L5):自动选保留消息数,使「估算 token ≤ 预算」。
|
|
812
|
+
* 保留 --trim-last N 按消息数语义;--trim-budget 走本函数。
|
|
813
|
+
* 下限保护:至少保留 5 条消息(防空会话)。
|
|
814
|
+
*
|
|
815
|
+
* 精确做法:按消息类型逐条累计 token 估算(复用 estimateTokensText 的
|
|
816
|
+
* 单条消息算法),从最近消息往回累加,直到超过预算——避免比例估算误差。
|
|
817
|
+
* @param {string} text - JSONL 全文。
|
|
818
|
+
* @param {number} budget - token 预算(如 450000)。
|
|
819
|
+
* @returns {{ text: string, removed: number, renumbered: number, kept: number, cutoff: number, estimatedTokens: number }}
|
|
820
|
+
*/
|
|
821
|
+
export function trimLastMessagesByBudget(text, budget) {
|
|
822
|
+
const parts = text.split('\n');
|
|
823
|
+
// 逐条消息的 { seq, tokens }:从最近往回累计
|
|
824
|
+
const msgs = [];
|
|
825
|
+
for (const raw of parts) {
|
|
826
|
+
if (!raw.trim()) continue;
|
|
827
|
+
let v;
|
|
828
|
+
try {
|
|
829
|
+
v = JSON.parse(raw);
|
|
830
|
+
} catch {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
let body = '';
|
|
834
|
+
if (v.type === 'user/message') {
|
|
835
|
+
body = (v.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
836
|
+
} else if (v.type === 'assistant/message' && v.surfaceOp === 'append') {
|
|
837
|
+
body = (v.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
838
|
+
} else {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
let cjk = 0;
|
|
842
|
+
let other = 0;
|
|
843
|
+
for (const ch of body) {
|
|
844
|
+
const cp = ch.codePointAt(0);
|
|
845
|
+
if (
|
|
846
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
847
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
848
|
+
(cp >= 0xff00 && cp <= 0xffef) ||
|
|
849
|
+
(cp >= 0x3000 && cp <= 0x303f)
|
|
850
|
+
) cjk++;
|
|
851
|
+
else if (cp > 0x20) other++;
|
|
852
|
+
}
|
|
853
|
+
msgs.push({ seq: v.seq, tokens: Math.ceil(cjk * 0.94 + other * 0.25 + 12) });
|
|
854
|
+
}
|
|
855
|
+
const total = msgs.length;
|
|
856
|
+
const MIN_KEEP = 5;
|
|
857
|
+
// 从最近往回累计,找到「估算 ≤ 预算」的最大保留数
|
|
858
|
+
let acc = 0;
|
|
859
|
+
let kept = 0;
|
|
860
|
+
for (let i = total - 1; i >= 0; i--) {
|
|
861
|
+
if (acc + msgs[i].tokens > budget) break;
|
|
862
|
+
acc += msgs[i].tokens;
|
|
863
|
+
kept++;
|
|
864
|
+
}
|
|
865
|
+
if (kept < MIN_KEEP) kept = MIN_KEEP;
|
|
866
|
+
if (kept >= total) {
|
|
867
|
+
const all = estimateTokensText(text);
|
|
868
|
+
return { text, removed: 0, renumbered: 0, kept: total, cutoff: 0, estimatedTokens: all.tokens };
|
|
869
|
+
}
|
|
870
|
+
const r = trimLastMessagesText(text, kept);
|
|
871
|
+
return { ...r, estimatedTokens: acc };
|
|
872
|
+
}
|
|
873
|
+
|
|
432
874
|
/**
|
|
433
875
|
* 裁剪到最近 keepMessages 条 append 消息(保留其所在 turn 的结构),
|
|
434
876
|
* 同时移除全部 retrace/message-editor marker,并全量重编号。
|
|
@@ -738,6 +1180,61 @@ export function repairSession(file, opts = {}) {
|
|
|
738
1180
|
const r = trimLastMessagesText(plain, opts.trimLast);
|
|
739
1181
|
applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
|
|
740
1182
|
}
|
|
1183
|
+
if (typeof opts.trimBudget === 'number') {
|
|
1184
|
+
const r = trimLastMessagesByBudget(plain, opts.trimBudget);
|
|
1185
|
+
if (r.removed > 0) {
|
|
1186
|
+
issues.push({ kind: 'trim-budget', detail: `按 token 预算 ${opts.trimBudget} 裁剪:保留 ${r.kept} 条消息(估算 ≈${r.estimatedTokens} tokens,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)` });
|
|
1187
|
+
plain = r.text;
|
|
1188
|
+
} else {
|
|
1189
|
+
issues.push({ kind: 'trim-budget', detail: `估算 ${r.estimatedTokens} tokens ≤ 预算 ${opts.trimBudget}——无需裁剪(${r.kept} 条消息全保留)` });
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// L4 新原语(2026-08-30 任务书 §L4 收编 tools/ 验证工具)
|
|
1193
|
+
if (typeof opts.tailRenumberDelta === 'number') {
|
|
1194
|
+
// 起点自动推导:从首个可平移的 seq 起(即所有事件都平移)。
|
|
1195
|
+
// fix-tail 原工具是 <startLine> <delta> 双参;任务书 §L4 简化为单参 delta
|
|
1196
|
+
// (尾部全部平移)。若需部分平移,传 --tail-renumber 前先 --keep-ranges。
|
|
1197
|
+
const r = tailRenumberText(plain, 0, opts.tailRenumberDelta);
|
|
1198
|
+
if (r.error) {
|
|
1199
|
+
issues.push({ kind: 'tail-renumber', detail: r.error });
|
|
1200
|
+
} else if (r.changed > 0) {
|
|
1201
|
+
issues.push({ kind: 'tail-renumber', detail: `尾部 ${r.changed} 行 seq 平移 delta=${r.delta}` });
|
|
1202
|
+
plain = r.text;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (opts.neutralizeOrphan) {
|
|
1206
|
+
const r = neutralizeOrphanText(plain);
|
|
1207
|
+
if (r.neutralized > 0) {
|
|
1208
|
+
issues.push({ kind: 'neutralize-orphan', detail: `原地归零 ${r.neutralized} 个孤儿 inbox spliced(removedCount→0,seq/行数不变)→ 不再重复排队` });
|
|
1209
|
+
plain = r.text;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (typeof opts.extractTurn === 'number') {
|
|
1213
|
+
const r = extractTurnText(plain, opts.extractTurn, opts.extractTurnTo ?? null);
|
|
1214
|
+
if (r.kept === 0) {
|
|
1215
|
+
issues.push({ kind: 'extract-turn', detail: `轮次 ${opts.extractTurn} 不存在(无保留行)` });
|
|
1216
|
+
} else {
|
|
1217
|
+
issues.push({ kind: 'extract-turn', detail: `保留轮次 ${opts.extractTurn}(${r.kept} 行,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)${r.secondRenamed ? `,第二同名轮次改号 ${opts.extractTurnTo}` : ''}` });
|
|
1218
|
+
removed += r.removed;
|
|
1219
|
+
renumbered += r.renumbered;
|
|
1220
|
+
plain = r.text;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (typeof opts.keepRanges === 'string') {
|
|
1224
|
+
try {
|
|
1225
|
+
const r = keepRangesText(plain, opts.keepRanges);
|
|
1226
|
+
if (r.error) {
|
|
1227
|
+
issues.push({ kind: 'keep-ranges', detail: r.error });
|
|
1228
|
+
} else {
|
|
1229
|
+
issues.push({ kind: 'keep-ranges', detail: `保留区间 ${opts.keepRanges}(${r.keptLines} 行,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)` });
|
|
1230
|
+
removed += r.removed;
|
|
1231
|
+
renumbered += r.renumbered;
|
|
1232
|
+
plain = r.text;
|
|
1233
|
+
}
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
issues.push({ kind: 'keep-ranges', detail: String(err.message ?? err) });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
741
1238
|
if (typeof opts.compactLast === 'number') {
|
|
742
1239
|
const r = compactLastMessagesText(plain, opts.compactLast);
|
|
743
1240
|
if (r.compacted) {
|
|
@@ -745,6 +1242,13 @@ export function repairSession(file, opts = {}) {
|
|
|
745
1242
|
plain = r.text;
|
|
746
1243
|
}
|
|
747
1244
|
}
|
|
1245
|
+
// 请求的修复是否"自身失败"(独立于终检):tail-renumber 报 error、
|
|
1246
|
+
// keep-ranges 报 error、extract-turn 无保留行、keep-ranges 未命中——
|
|
1247
|
+
// 任一发生 → ok=false(修复没做成,不能报成功)。
|
|
1248
|
+
const requestedFailed = issues.some((i) =>
|
|
1249
|
+
i.kind === 'tail-renumber' && /负|错误/.test(i.detail) ||
|
|
1250
|
+
i.kind === 'keep-ranges' && i.detail.includes('未命中') ||
|
|
1251
|
+
i.kind === 'extract-turn' && i.detail.includes('不存在'));
|
|
748
1252
|
// 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
|
|
749
1253
|
const scanFinal = strictScanText(plain);
|
|
750
1254
|
const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
|
|
@@ -756,7 +1260,7 @@ export function repairSession(file, opts = {}) {
|
|
|
756
1260
|
const errs = checkFinal.violations.filter((v) => v.severity === 'error');
|
|
757
1261
|
issues.push({ kind: 'final-contract', detail: `修复后仍有 ${errs.length} 个 error 级违规(首条:${errs[0]?.id ?? '-'} ${errs[0]?.message?.slice(0, 90) ?? ''})` });
|
|
758
1262
|
}
|
|
759
|
-
const ok = scanFinal.failures.length === 0 && checkFinal.ok;
|
|
1263
|
+
const ok = !requestedFailed && scanFinal.failures.length === 0 && checkFinal.ok;
|
|
760
1264
|
let backupPath = null;
|
|
761
1265
|
let applied = false;
|
|
762
1266
|
if (opts.apply && ok) {
|
package/lib/validate.js
CHANGED
|
@@ -168,4 +168,73 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* resumeVerdict —— `check --resume` 三档结论(L3)。
|
|
173
|
+
*
|
|
174
|
+
* 回答用户「这个会话还能不能用」,三档蕴含(可压缩 ⊂ 可继续 ⊂ 可加载):
|
|
175
|
+
* ✅ 可加载 loadable —— 结构规则全绿(S1-S9/E 系列/W1/W2 等 error 级全清)
|
|
176
|
+
* ✅ 可继续 resumable —— 结构绿 + I1(inbox 重放)绿
|
|
177
|
+
* ✅ 可压缩 compactable —— 前两档绿 + T1/T2(token-meter 配对)绿
|
|
178
|
+
*
|
|
179
|
+
* 纯聚合输出,不新增校验逻辑:直接复用 validateSessionLog 的 violations
|
|
180
|
+
* 按规则 id 分组判定(2026-08-30 事故:离线 check 全绿但实机 token-meter 崩
|
|
181
|
+
* 的教训——T1/T2 必须单独看,不能只看 error 总数)。
|
|
182
|
+
*
|
|
183
|
+
* @param {{ok: boolean, violations: Array, summary: object}} result validateSessionLog 的返回值
|
|
184
|
+
* @returns {{
|
|
185
|
+
* verdict: 'loadable' | 'resumable' | 'compactable' | 'broken',
|
|
186
|
+
* loadable: boolean, resumable: boolean, compactable: boolean,
|
|
187
|
+
* blocking: { loadable: Array, resumable: Array, compactable: Array },
|
|
188
|
+
* }}
|
|
189
|
+
*/
|
|
190
|
+
export function resumeVerdict(result) {
|
|
191
|
+
const { ok, violations = [] } = result;
|
|
192
|
+
// 三档各自的「阻断规则集」——按任务书 §L3 档位定义:
|
|
193
|
+
// 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2 的 error;
|
|
194
|
+
// 可继续:+ I1(inbox 重放);
|
|
195
|
+
// 可压缩:+ T1/T2(token-meter 配对)。
|
|
196
|
+
const byId = {};
|
|
197
|
+
for (const v of violations) {
|
|
198
|
+
if (v.severity !== 'error') continue;
|
|
199
|
+
(byId[v.id] ??= []).push(v);
|
|
200
|
+
}
|
|
201
|
+
const has = (id) => (byId[id]?.length ?? 0) > 0;
|
|
202
|
+
|
|
203
|
+
// 可加载阻断 = 除 I1/T1/T2 外的所有 error 违规(结构/信封/物理序/工具配对等)。
|
|
204
|
+
// 注意:不能用 result.ok(它把 T1/T2/I1 也计为 error)——三档判定按任务书定义,
|
|
205
|
+
// T1/T2 只影响「可压缩」档、I1 只影响「可继续」档。
|
|
206
|
+
const loadableBlockers = violations.filter(
|
|
207
|
+
(v) => v.severity === 'error' && !['I1', 'T1', 'T2'].includes(v.id),
|
|
208
|
+
).map((v) => v.id);
|
|
209
|
+
const loadable = loadableBlockers.length === 0;
|
|
210
|
+
|
|
211
|
+
const resumableBlockers = loadable ? (has('I1') ? ['I1'] : []) : [];
|
|
212
|
+
const resumable = loadable && !has('I1');
|
|
213
|
+
|
|
214
|
+
const compactableBlockers = resumable
|
|
215
|
+
? (has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [])
|
|
216
|
+
: [];
|
|
217
|
+
const compactable = resumable && !has('T1') && !has('T2');
|
|
218
|
+
|
|
219
|
+
// 最差档位
|
|
220
|
+
const verdict = !loadable ? 'broken' : !resumable ? 'loadable' : !compactable ? 'resumable' : 'compactable';
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
verdict,
|
|
224
|
+
loadable,
|
|
225
|
+
resumable,
|
|
226
|
+
compactable,
|
|
227
|
+
blocking: {
|
|
228
|
+
loadable: [...new Set(loadableBlockers)],
|
|
229
|
+
resumable: resumableBlockers,
|
|
230
|
+
compactable: compactableBlockers,
|
|
231
|
+
},
|
|
232
|
+
violationsByTier: {
|
|
233
|
+
structural: [...new Set(loadableBlockers)],
|
|
234
|
+
inbox: has('I1') ? ['I1'] : [],
|
|
235
|
+
tokenMeter: has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
171
240
|
export { ruleById };
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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.3.
|
|
4
|
+
"version": "0.3.7",
|
|
5
|
+
"packageManager": "pnpm@11.7.0",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "lib/index.js",
|
|
7
8
|
"bin": {
|
|
@@ -24,8 +25,14 @@
|
|
|
24
25
|
"bin/**/*.mjs",
|
|
25
26
|
"docs/**/*.md",
|
|
26
27
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.zh.md"
|
|
28
30
|
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "node scripts/check-syntax.mjs",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"prepublishOnly": "pnpm check && pnpm test"
|
|
35
|
+
},
|
|
29
36
|
"keywords": [
|
|
30
37
|
"dsh",
|
|
31
38
|
"deepseek-harness",
|
|
@@ -58,9 +65,5 @@
|
|
|
58
65
|
"@deepseek-ai/dsh-session": "0.1.0-rc.7",
|
|
59
66
|
"esbuild": "0.28.2",
|
|
60
67
|
"vitest": "4.1.11"
|
|
61
|
-
},
|
|
62
|
-
"scripts": {
|
|
63
|
-
"check": "node scripts/check-syntax.mjs",
|
|
64
|
-
"test": "vitest run"
|
|
65
68
|
}
|
|
66
|
-
}
|
|
69
|
+
}
|