open-multi-agent-kit 0.80.4 → 0.80.6

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/config.d.ts +4 -9
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +46 -15
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  7. package/dist/core/compaction/branch-summarization.js +6 -1
  8. package/dist/core/compaction/branch-summarization.js.map +1 -1
  9. package/dist/core/compaction/compaction.d.ts +82 -1
  10. package/dist/core/compaction/compaction.d.ts.map +1 -1
  11. package/dist/core/compaction/compaction.js +606 -10
  12. package/dist/core/compaction/compaction.js.map +1 -1
  13. package/dist/core/harness-control-events.d.ts +79 -0
  14. package/dist/core/harness-control-events.d.ts.map +1 -0
  15. package/dist/core/harness-control-events.js +339 -0
  16. package/dist/core/harness-control-events.js.map +1 -0
  17. package/dist/core/harness-control-replay.d.ts +26 -0
  18. package/dist/core/harness-control-replay.d.ts.map +1 -0
  19. package/dist/core/harness-control-replay.js +155 -0
  20. package/dist/core/harness-control-replay.js.map +1 -0
  21. package/dist/core/keybindings.d.ts +12 -0
  22. package/dist/core/keybindings.d.ts.map +1 -1
  23. package/dist/core/keybindings.js +31 -1
  24. package/dist/core/keybindings.js.map +1 -1
  25. package/dist/core/mcp-inventory.d.ts +40 -0
  26. package/dist/core/mcp-inventory.d.ts.map +1 -0
  27. package/dist/core/mcp-inventory.js +104 -0
  28. package/dist/core/mcp-inventory.js.map +1 -0
  29. package/dist/core/settings-manager.d.ts +3 -0
  30. package/dist/core/settings-manager.d.ts.map +1 -1
  31. package/dist/core/settings-manager.js +4 -0
  32. package/dist/core/settings-manager.js.map +1 -1
  33. package/dist/core/slash-commands.d.ts.map +1 -1
  34. package/dist/core/slash-commands.js +4 -1
  35. package/dist/core/slash-commands.js.map +1 -1
  36. package/dist/core/spec-kit/compiler.d.ts +67 -0
  37. package/dist/core/spec-kit/compiler.d.ts.map +1 -0
  38. package/dist/core/spec-kit/compiler.js +268 -0
  39. package/dist/core/spec-kit/compiler.js.map +1 -0
  40. package/dist/core/transaction-coordinator.d.ts +30 -0
  41. package/dist/core/transaction-coordinator.d.ts.map +1 -0
  42. package/dist/core/transaction-coordinator.js +173 -0
  43. package/dist/core/transaction-coordinator.js.map +1 -0
  44. package/dist/migrations.d.ts +35 -0
  45. package/dist/migrations.d.ts.map +1 -1
  46. package/dist/migrations.js +409 -3
  47. package/dist/migrations.js.map +1 -1
  48. package/dist/modes/interactive/components/control-panel.d.ts +29 -0
  49. package/dist/modes/interactive/components/control-panel.d.ts.map +1 -0
  50. package/dist/modes/interactive/components/control-panel.js +90 -0
  51. package/dist/modes/interactive/components/control-panel.js.map +1 -0
  52. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  53. package/dist/modes/interactive/components/custom-editor.js +21 -13
  54. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  55. package/dist/modes/interactive/components/index.d.ts +3 -0
  56. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  57. package/dist/modes/interactive/components/index.js +3 -0
  58. package/dist/modes/interactive/components/index.js.map +1 -1
  59. package/dist/modes/interactive/components/mcp-selector.d.ts +28 -0
  60. package/dist/modes/interactive/components/mcp-selector.d.ts.map +1 -0
  61. package/dist/modes/interactive/components/mcp-selector.js +138 -0
  62. package/dist/modes/interactive/components/mcp-selector.js.map +1 -0
  63. package/dist/modes/interactive/components/model-selector.d.ts +12 -3
  64. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/model-selector.js +120 -27
  66. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  67. package/dist/modes/interactive/components/settings-selector.d.ts +19 -1
  68. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  69. package/dist/modes/interactive/components/settings-selector.js +21 -8
  70. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  71. package/dist/modes/interactive/components/skills-selector.d.ts +32 -0
  72. package/dist/modes/interactive/components/skills-selector.d.ts.map +1 -0
  73. package/dist/modes/interactive/components/skills-selector.js +130 -0
  74. package/dist/modes/interactive/components/skills-selector.js.map +1 -0
  75. package/dist/modes/interactive/components/thinking-selector.d.ts +7 -8
  76. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  77. package/dist/modes/interactive/components/thinking-selector.js +7 -48
  78. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  79. package/dist/modes/interactive/interactive-mode.d.ts +9 -0
  80. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  81. package/dist/modes/interactive/interactive-mode.js +340 -97
  82. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  83. package/dist/modes/interactive/theme/catppuccin-mocha.json +86 -0
  84. package/dist/modes/interactive/theme/dracula.json +86 -0
  85. package/dist/modes/interactive/theme/gruvbox-dark.json +86 -0
  86. package/dist/modes/interactive/theme/kanagawa-dragon.json +86 -0
  87. package/dist/modes/interactive/theme/omk-control-panel.json +91 -0
  88. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  89. package/dist/modes/interactive/theme/theme.js +41 -16
  90. package/dist/modes/interactive/theme/theme.js.map +1 -1
  91. package/dist/modes/interactive/theme/tokyo-night-storm.json +86 -0
  92. package/docs/compaction.md +21 -8
  93. package/docs/quickstart.md +1 -1
  94. package/docs/themes.md +10 -2
  95. package/docs/usage.md +2 -1
  96. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  97. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  98. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  99. package/examples/extensions/gondolin/package-lock.json +2 -2
  100. package/examples/extensions/gondolin/package.json +1 -1
  101. package/examples/extensions/sandbox/package-lock.json +2 -2
  102. package/examples/extensions/sandbox/package.json +1 -1
  103. package/examples/extensions/with-deps/package-lock.json +2 -2
  104. package/examples/extensions/with-deps/package.json +1 -1
  105. package/package.json +4 -4
@@ -5,6 +5,7 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
  import { completeSimple } from "@earendil-works/omk-ai";
8
+ import { recordHarnessControlEvent } from "../harness-control-events.js";
8
9
  import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
9
10
  import { buildSessionContext } from "../session-manager.js";
10
11
  import { computeFileLists, createFileOps, extractFileOpsFromMessage, formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, } from "./utils.js";
@@ -64,11 +65,13 @@ function getMessageFromEntryForCompaction(entry) {
64
65
  return getMessageFromEntry(entry);
65
66
  }
66
67
  export const DEFAULT_COMPACTION_MAX_USAGE_RATIO = 0.9;
68
+ export const DEFAULT_COMPACTION_SUMMARY_INPUT_TOKENS = 80000;
67
69
  export const DEFAULT_COMPACTION_SETTINGS = {
68
70
  enabled: true,
69
71
  reserveTokens: 16384,
70
72
  keepRecentTokens: 20000,
71
73
  maxUsageRatio: DEFAULT_COMPACTION_MAX_USAGE_RATIO,
74
+ summaryInputTokens: DEFAULT_COMPACTION_SUMMARY_INPUT_TOKENS,
72
75
  };
73
76
  // ============================================================================
74
77
  // Token calculation
@@ -374,6 +377,401 @@ export function findCutPoint(entries, startIndex, endIndex, keepRecentTokens) {
374
377
  };
375
378
  }
376
379
  // ============================================================================
380
+ // Summarization Input Packing
381
+ // ============================================================================
382
+ const CHARS_PER_TOKEN_ESTIMATE = 4;
383
+ const SUMMARY_INPUT_PROMPT_OVERHEAD_TOKENS = 2048;
384
+ const SUMMARY_INPUT_TOKENIZER_SAFETY_MARGIN = 512;
385
+ const MIN_SUMMARY_INPUT_TOKENS = 512;
386
+ function estimateTextTokens(text) {
387
+ return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
388
+ }
389
+ function isFinitePositiveInteger(value) {
390
+ return value !== undefined && Number.isFinite(value) && value > 0;
391
+ }
392
+ export function resolveSummaryInputTokenBudgetV3(model, maxOutputTokens, options) {
393
+ const configuredSummaryInputTokens = typeof options === "number" ? options : options?.configuredSummaryInputTokens;
394
+ if (configuredSummaryInputTokens !== undefined && configuredSummaryInputTokens <= 0) {
395
+ return { emergency: false, fixedPromptTokens: 0 };
396
+ }
397
+ const configuredBudget = configuredSummaryInputTokens ?? DEFAULT_COMPACTION_SUMMARY_INPUT_TOKENS;
398
+ if (!isFinitePositiveInteger(configuredBudget)) {
399
+ return { emergency: false, fixedPromptTokens: 0 };
400
+ }
401
+ const contextWindow = model.contextWindow > 0 ? Math.floor(model.contextWindow) : Number.POSITIVE_INFINITY;
402
+ if (!Number.isFinite(contextWindow)) {
403
+ return { budgetTokens: Math.floor(configuredBudget), emergency: false, fixedPromptTokens: 0 };
404
+ }
405
+ const fixedPromptTokens = typeof options === "object"
406
+ ? estimateTextTokens([
407
+ options.systemPrompt,
408
+ options.basePrompt,
409
+ options.customInstructions,
410
+ options.previousSummary,
411
+ options.wrapperText,
412
+ ]
413
+ .filter((value) => typeof value === "string" && value.length > 0)
414
+ .join("\n"))
415
+ : SUMMARY_INPUT_PROMPT_OVERHEAD_TOKENS;
416
+ const safetyMarginTokens = typeof options === "object" ? (options.safetyMarginTokens ?? SUMMARY_INPUT_TOKENIZER_SAFETY_MARGIN) : 0;
417
+ const availableInputTokens = contextWindow - Math.max(0, Math.floor(maxOutputTokens)) - fixedPromptTokens - safetyMarginTokens;
418
+ if (availableInputTokens < MIN_SUMMARY_INPUT_TOKENS) {
419
+ return {
420
+ emergency: true,
421
+ reason: "fixed prompt overhead exceeds viable summarization input budget",
422
+ fixedPromptTokens,
423
+ availableInputTokens,
424
+ };
425
+ }
426
+ return {
427
+ budgetTokens: Math.max(MIN_SUMMARY_INPUT_TOKENS, Math.min(Math.floor(configuredBudget), availableInputTokens)),
428
+ emergency: false,
429
+ fixedPromptTokens,
430
+ availableInputTokens,
431
+ };
432
+ }
433
+ export function resolveSummaryInputTokenBudget(model, maxOutputTokens, options) {
434
+ return resolveSummaryInputTokenBudgetV3(model, maxOutputTokens, options).budgetTokens;
435
+ }
436
+ function scoreSummaryLine(line) {
437
+ const trimmed = line.trim();
438
+ if (trimmed.length === 0)
439
+ return 0;
440
+ let score = 0;
441
+ if (/\b(?:[\w.-]+\/)+[\w.-]+\.(?:ts|tsx|js|jsx|json|md|css|scss|py|rs|go|java|kt|swift|sh|yml|yaml|toml)\b/.test(trimmed)) {
442
+ score += 6;
443
+ }
444
+ if (/\b(?:error|failed|failure|exception|warning|blocked|regression|timeout|overflow|abort)\b/i.test(trimmed) ||
445
+ /(?:오류|실패|경고|차단|회귀|시간 초과|타임아웃)/.test(trimmed)) {
446
+ score += 5;
447
+ }
448
+ if (/\b(?:npm|node|pnpm|yarn|bun|git|gh|tsgo|vitest|biome|pytest|cargo|go test)\b/.test(trimmed)) {
449
+ score += 4;
450
+ }
451
+ if (/^(?:#{1,6}\s|[-*]\s|\d+\.\s|\[[ xX]\]|```)/.test(trimmed)) {
452
+ score += 3;
453
+ }
454
+ if (/\b(?:goal|constraint|decision|next step|todo|changed|created|modified|verified|passed|failed)\b/i.test(trimmed) ||
455
+ /(?:목표|제약|결정|다음 단계|검증|통과|수정|생성|변경|증거)/.test(trimmed)) {
456
+ score += 3;
457
+ }
458
+ if (trimmed.length <= 220) {
459
+ score += 1;
460
+ }
461
+ else if (trimmed.length > 1000) {
462
+ score -= 3;
463
+ }
464
+ return score;
465
+ }
466
+ function selectHighSignalLines(text, budgetChars) {
467
+ if (budgetChars <= 0)
468
+ return "";
469
+ const scoredLines = text
470
+ .split("\n")
471
+ .map((line, index) => ({ index, line, score: scoreSummaryLine(line) }))
472
+ .filter((item) => item.score > 0)
473
+ .sort((a, b) => b.score - a.score || a.index - b.index);
474
+ const selected = [];
475
+ let usedChars = 0;
476
+ for (const item of scoredLines) {
477
+ const lineChars = item.line.length + 1;
478
+ if (usedChars + lineChars > budgetChars)
479
+ continue;
480
+ selected.push({ index: item.index, line: item.line });
481
+ usedChars += lineChars;
482
+ }
483
+ if (selected.length === 0) {
484
+ return text.slice(0, budgetChars);
485
+ }
486
+ return selected
487
+ .sort((a, b) => a.index - b.index)
488
+ .map((item) => item.line)
489
+ .join("\n");
490
+ }
491
+ function scoreSummaryUnit(unit) {
492
+ const lineScores = unit.split("\n").map(scoreSummaryLine);
493
+ const maxLineScore = Math.max(0, ...lineScores);
494
+ const aggregateScore = lineScores.reduce((sum, score) => sum + Math.max(0, score), 0);
495
+ let score = maxLineScore * 2 + Math.min(aggregateScore, 12);
496
+ if (/^```/m.test(unit))
497
+ score += 3;
498
+ if (/^#{1,6}\s/m.test(unit))
499
+ score += 2;
500
+ if (unit.length > 2400)
501
+ score -= 4;
502
+ return score;
503
+ }
504
+ function extractSummaryUnitSignals(text) {
505
+ const signals = new Set();
506
+ for (const match of text.matchAll(/\b(?:[\w.-]+\/)+[\w.-]+\.(?:ts|tsx|js|jsx|json|md|css|scss|py|rs|go|java|kt|swift|sh|yml|yaml|toml)\b/g)) {
507
+ signals.add(`path:${match[0]}`);
508
+ }
509
+ for (const match of text.matchAll(/\b(?:npm|node|pnpm|yarn|bun|git|gh|tsgo|vitest|biome|pytest|cargo|go test)\b[^\n]*/g)) {
510
+ signals.add(`cmd:${match[0].trim().slice(0, 160)}`);
511
+ }
512
+ for (const match of text.matchAll(/\b(?:HCP-\d+|R\d+)\b/g)) {
513
+ signals.add(`id:${match[0]}`);
514
+ }
515
+ for (const match of text.matchAll(/\.omk\/[\w./-]+/g)) {
516
+ signals.add(`evidence:${match[0]}`);
517
+ }
518
+ return [...signals];
519
+ }
520
+ function updateHeadingStack(headingStack, headingLine) {
521
+ const match = headingLine.match(/^(#{1,6})\s+(.+)$/);
522
+ if (!match)
523
+ return headingStack;
524
+ const level = match[1].length;
525
+ const title = match[2].replace(/\s+#+\s*$/, "").trim();
526
+ const nextStack = [...headingStack];
527
+ while (nextStack.length > 0 && nextStack.at(-1).level >= level) {
528
+ nextStack.pop();
529
+ }
530
+ nextStack.push({ level, title });
531
+ return nextStack;
532
+ }
533
+ function inferSummaryUnitStatus(text) {
534
+ if (/^- \[[xX]]/m.test(text) || /\b(?:completed|done|fixed)\b/i.test(text) || /(?:완료|수정됨)/.test(text)) {
535
+ return "done";
536
+ }
537
+ if (/^- \[ ]/m.test(text) || /\b(?:in progress|todo|next)\b/i.test(text) || /(?:진행|다음)/.test(text)) {
538
+ return "in_progress";
539
+ }
540
+ if (/\b(?:blocked|blocker)\b/i.test(text) || /(?:차단|블로커)/.test(text))
541
+ return "blocked";
542
+ if (/\b(?:failed|failure|error|exception|regression)\b/i.test(text) || /(?:실패|오류|회귀)/.test(text)) {
543
+ return "failed";
544
+ }
545
+ if (/\b(?:passed|success|ok)\b/i.test(text) || /(?:통과|성공)/.test(text))
546
+ return "passed";
547
+ return undefined;
548
+ }
549
+ function inferSummaryUnitKind(text, status) {
550
+ const trimmed = text.trim();
551
+ if (status === "blocked")
552
+ return "blocker";
553
+ if (trimmed.startsWith("```"))
554
+ return "code";
555
+ if (/\.omk\/[\w./-]+|\bevidence\b/i.test(text))
556
+ return "evidence";
557
+ if (/\b(?:npm|node|pnpm|yarn|bun|git|gh|tsgo|vitest|biome|pytest|cargo|go test)\b/.test(text))
558
+ return "command";
559
+ if (/^(?:[-*]\s+\[[ xX]])/m.test(text))
560
+ return "checklist";
561
+ if (/^#{1,6}\s/m.test(text))
562
+ return "heading";
563
+ return "text";
564
+ }
565
+ function createSummaryInputSemanticUnit(index, text, sectionPath) {
566
+ const status = inferSummaryUnitStatus(text);
567
+ return {
568
+ index,
569
+ text,
570
+ score: scoreSummaryUnit(text),
571
+ sectionPath,
572
+ kind: inferSummaryUnitKind(text, status),
573
+ refs: extractSummaryUnitSignals(text),
574
+ status,
575
+ };
576
+ }
577
+ export function splitSummaryInputSemanticUnits(text) {
578
+ const lines = text.split("\n");
579
+ const units = [];
580
+ let start = 0;
581
+ let current = [];
582
+ let inFence = false;
583
+ let activeHeadingStack = [];
584
+ let currentSectionPath = [];
585
+ function flush(nextIndex) {
586
+ while (current.length > 0 && current[0]?.trim() === "") {
587
+ current.shift();
588
+ start++;
589
+ }
590
+ while (current.length > 0 && current.at(-1)?.trim() === "") {
591
+ current.pop();
592
+ }
593
+ if (current.length === 0) {
594
+ start = nextIndex;
595
+ currentSectionPath = activeHeadingStack.map((heading) => heading.title);
596
+ return;
597
+ }
598
+ const unitText = current.join("\n");
599
+ units.push(createSummaryInputSemanticUnit(start, unitText, [...currentSectionPath]));
600
+ current = [];
601
+ start = nextIndex;
602
+ currentSectionPath = activeHeadingStack.map((heading) => heading.title);
603
+ }
604
+ for (let index = 0; index < lines.length; index++) {
605
+ const line = lines[index];
606
+ const trimmed = line.trim();
607
+ const startsFence = trimmed.startsWith("```");
608
+ const startsHeading = /^#{1,6}\s/.test(trimmed);
609
+ if (!inFence && startsHeading && current.some((entry) => entry.trim().length > 0)) {
610
+ flush(index);
611
+ }
612
+ if (!inFence && startsHeading) {
613
+ activeHeadingStack = updateHeadingStack(activeHeadingStack, trimmed);
614
+ }
615
+ if (current.length === 0) {
616
+ start = index;
617
+ currentSectionPath = activeHeadingStack.map((heading) => heading.title);
618
+ }
619
+ current.push(line);
620
+ if (startsFence) {
621
+ inFence = !inFence;
622
+ if (!inFence)
623
+ flush(index + 1);
624
+ continue;
625
+ }
626
+ if (!inFence && trimmed.length === 0) {
627
+ flush(index + 1);
628
+ }
629
+ }
630
+ flush(lines.length);
631
+ return units;
632
+ }
633
+ export function buildSummaryDependencyGraph(units) {
634
+ const edges = [];
635
+ const signalsByIndex = new Map();
636
+ for (const unit of units) {
637
+ signalsByIndex.set(unit.index, new Set(unit.refs));
638
+ }
639
+ for (let i = 0; i < units.length; i++) {
640
+ for (let j = i + 1; j < units.length; j++) {
641
+ const leftUnit = units[i];
642
+ const rightUnit = units[j];
643
+ const left = signalsByIndex.get(leftUnit.index) ?? new Set();
644
+ const right = signalsByIndex.get(rightUnit.index) ?? new Set();
645
+ const shared = [...left].find((signal) => right.has(signal));
646
+ const sameSection = leftUnit.sectionPath.length > 0 && leftUnit.sectionPath.join("/") === rightUnit.sectionPath.join("/");
647
+ if (shared || sameSection) {
648
+ const reason = shared ?? `section:${leftUnit.sectionPath.join("/")}`;
649
+ edges.push({ from: leftUnit.index, to: rightUnit.index, reason });
650
+ edges.push({ from: rightUnit.index, to: leftUnit.index, reason });
651
+ }
652
+ }
653
+ }
654
+ return { nodes: units, edges };
655
+ }
656
+ export function selectSummaryDependencyClosure(graph, seedIndexes, maxAdditionalNodes = 8) {
657
+ const selected = new Set(seedIndexes);
658
+ const queue = [...seedIndexes];
659
+ while (queue.length > 0 && selected.size < seedIndexes.length + maxAdditionalNodes) {
660
+ const index = queue.shift();
661
+ const neighbors = graph.edges
662
+ .filter((edge) => edge.from === index)
663
+ .map((edge) => edge.to)
664
+ .filter((neighbor) => !selected.has(neighbor));
665
+ for (const neighbor of neighbors) {
666
+ selected.add(neighbor);
667
+ queue.push(neighbor);
668
+ if (selected.size >= seedIndexes.length + maxAdditionalNodes)
669
+ break;
670
+ }
671
+ }
672
+ return selected;
673
+ }
674
+ function normalizeSummaryNovelty(text) {
675
+ return text
676
+ .toLowerCase()
677
+ .replace(/\b\d+\b/g, "#")
678
+ .replace(/\s+/g, " ")
679
+ .trim();
680
+ }
681
+ export function deduplicateSummaryFactsByNovelty(units) {
682
+ const seen = new Set();
683
+ const result = [];
684
+ for (const unit of units) {
685
+ const sectionKey = unit.sectionPath.join("/");
686
+ const key = [sectionKey, unit.kind, unit.status ?? "", normalizeSummaryNovelty(unit.text)].join("|");
687
+ if (seen.has(key))
688
+ continue;
689
+ seen.add(key);
690
+ result.push(unit);
691
+ }
692
+ return result;
693
+ }
694
+ function selectHighSignalSemanticUnits(text, budgetChars) {
695
+ if (budgetChars <= 0)
696
+ return "";
697
+ const candidateUnits = deduplicateSummaryFactsByNovelty(splitSummaryInputSemanticUnits(text).filter((unit) => unit.score > 0));
698
+ const graph = buildSummaryDependencyGraph(candidateUnits);
699
+ const rankedUnits = [...candidateUnits].sort((a, b) => b.score - a.score || a.index - b.index);
700
+ const seedIndexes = rankedUnits.slice(0, Math.min(12, rankedUnits.length)).map((unit) => unit.index);
701
+ const closureIndexes = selectSummaryDependencyClosure(graph, seedIndexes);
702
+ const units = rankedUnits
703
+ .filter((unit) => closureIndexes.has(unit.index))
704
+ .sort((a, b) => b.score - a.score || a.index - b.index);
705
+ const selected = [];
706
+ let usedChars = 0;
707
+ for (const unit of units) {
708
+ const unitChars = unit.text.length + 2;
709
+ if (unitChars > budgetChars) {
710
+ const excerpt = selectHighSignalLines(unit.text, Math.max(0, budgetChars - usedChars));
711
+ if (excerpt.length > 0) {
712
+ selected.push({ ...unit, text: excerpt });
713
+ usedChars += excerpt.length + 2;
714
+ }
715
+ continue;
716
+ }
717
+ if (usedChars + unitChars > budgetChars)
718
+ continue;
719
+ selected.push(unit);
720
+ usedChars += unitChars;
721
+ }
722
+ if (selected.length === 0) {
723
+ return text.slice(0, budgetChars);
724
+ }
725
+ return selected
726
+ .sort((a, b) => a.index - b.index)
727
+ .map((unit) => unit.text)
728
+ .join("\n\n");
729
+ }
730
+ function trimToCharBudget(text, budgetChars, fromEnd = false) {
731
+ if (budgetChars <= 0)
732
+ return "";
733
+ if (text.length <= budgetChars)
734
+ return text;
735
+ return fromEnd ? text.slice(text.length - budgetChars) : text.slice(0, budgetChars);
736
+ }
737
+ /**
738
+ * Pack serialized conversation text under a token budget using a deterministic
739
+ * extractive strategy: preserve the opening, recent tail, and high-signal
740
+ * middle lines such as paths, commands, errors, and decisions.
741
+ */
742
+ export function packSummaryInputForTokenBudget(text, maxInputTokens) {
743
+ const originalTokens = estimateTextTokens(text);
744
+ if (!isFinitePositiveInteger(maxInputTokens) || originalTokens <= maxInputTokens) {
745
+ return {
746
+ text,
747
+ originalTokens,
748
+ packedTokens: originalTokens,
749
+ omittedTokens: 0,
750
+ wasCompressed: false,
751
+ };
752
+ }
753
+ const targetChars = Math.max(MIN_SUMMARY_INPUT_TOKENS * CHARS_PER_TOKEN_ESTIMATE, Math.floor(maxInputTokens) * 4);
754
+ const marker = `<omk-summary-input-compressed originalTokens="${originalTokens}" targetTokens="${Math.floor(maxInputTokens)}">\nOlder serialized history was packed before summarization. Opening context, high-signal semantic units, and the recent tail were retained; low-signal repeated text was omitted.\n</omk-summary-input-compressed>`;
755
+ const contentBudgetChars = Math.max(512, targetChars - marker.length - 64);
756
+ const headBudgetChars = Math.floor(contentBudgetChars * 0.18);
757
+ const tailBudgetChars = Math.floor(contentBudgetChars * 0.52);
758
+ const middleBudgetChars = Math.max(0, contentBudgetChars - headBudgetChars - tailBudgetChars);
759
+ const head = trimToCharBudget(text, headBudgetChars);
760
+ const tail = trimToCharBudget(text, tailBudgetChars, true);
761
+ const middleStart = Math.min(headBudgetChars, text.length);
762
+ const middleEnd = Math.max(middleStart, text.length - tailBudgetChars);
763
+ const middle = selectHighSignalSemanticUnits(text.slice(middleStart, middleEnd), middleBudgetChars);
764
+ const packedText = [head.trimEnd(), marker, middle.trim(), tail.trimStart()].filter(Boolean).join("\n\n");
765
+ const packedTokens = estimateTextTokens(packedText);
766
+ return {
767
+ text: packedText,
768
+ originalTokens,
769
+ packedTokens,
770
+ omittedTokens: Math.max(0, originalTokens - packedTokens),
771
+ wasCompressed: true,
772
+ };
773
+ }
774
+ // ============================================================================
377
775
  // Summarization
378
776
  // ============================================================================
379
777
  const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -407,7 +805,12 @@ Use this EXACT format:
407
805
  - [Any data, examples, or references needed to continue]
408
806
  - [Or "(none)" if not applicable]
409
807
 
410
- Keep each section concise. Preserve exact file paths, function names, and error messages.`;
808
+ ## Resume Handoff
809
+ - **First action**: [Single concrete next action after compaction]
810
+ - **Re-check before editing**: [Workspace state, files, or commands to verify first; use "(none)" if not needed]
811
+ - **Evidence status**: [Checks/tests already run and results, or "(not run)"]
812
+
813
+ Keep each section concise. Preserve exact file paths, function names, command results, and error messages.`;
411
814
  const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
412
815
 
413
816
  Update the existing structured summary with new information. RULES:
@@ -445,7 +848,150 @@ Use this EXACT format:
445
848
  ## Critical Context
446
849
  - [Preserve important context, add new if needed]
447
850
 
448
- Keep each section concise. Preserve exact file paths, function names, and error messages.`;
851
+ ## Resume Handoff
852
+ - **First action**: [Single concrete next action after compaction]
853
+ - **Re-check before editing**: [Workspace state, files, or commands to verify first; use "(none)" if not needed]
854
+ - **Evidence status**: [Checks/tests already run and results, or "(not run)"]
855
+
856
+ Keep each section concise. Preserve exact file paths, function names, command results, and error messages.`;
857
+ const REQUIRED_SUMMARY_SECTIONS = [
858
+ "## Goal",
859
+ "## Constraints & Preferences",
860
+ "## Progress",
861
+ "### Done",
862
+ "### In Progress",
863
+ "### Blocked",
864
+ "## Key Decisions",
865
+ "## Next Steps",
866
+ "## Critical Context",
867
+ "## Resume Handoff",
868
+ ];
869
+ export function parseCompactionSummary(summary) {
870
+ const sections = {};
871
+ for (const heading of REQUIRED_SUMMARY_SECTIONS) {
872
+ const value = extractSection(summary, heading);
873
+ if (value !== undefined)
874
+ sections[heading] = value;
875
+ }
876
+ return {
877
+ sections,
878
+ missingSections: REQUIRED_SUMMARY_SECTIONS.filter((heading) => sections[heading] === undefined),
879
+ };
880
+ }
881
+ function extractSection(summary, heading) {
882
+ const start = summary.indexOf(heading);
883
+ if (start < 0)
884
+ return undefined;
885
+ const nextHeading = summary.slice(start + heading.length).search(/\n#{2,3}\s/);
886
+ if (nextHeading < 0)
887
+ return summary.slice(start + heading.length).trim();
888
+ return summary.slice(start + heading.length, start + heading.length + nextHeading).trim();
889
+ }
890
+ function extractCriticalFactsFromText(text, limit = 12) {
891
+ return text
892
+ .split("\n")
893
+ .map((line) => line.trim())
894
+ .filter((line) => line.length > 0 && scoreSummaryLine(line) >= 4)
895
+ .slice(0, limit);
896
+ }
897
+ export function createEmergencyCompactionHandoff(options) {
898
+ const facts = options.fallbackFacts ?? extractCriticalFactsFromText(options.conversationText, 16);
899
+ const previousBlocked = options.previousSummary
900
+ ? (extractSection(options.previousSummary, "### Blocked") ??
901
+ extractSection(options.previousSummary, "## Blocked"))
902
+ : undefined;
903
+ return [
904
+ "## Goal",
905
+ "Deterministic emergency compaction handoff because normal summarization could exceed context budget.",
906
+ "",
907
+ "## Constraints & Preferences",
908
+ `- Emergency reason: ${options.reason}`,
909
+ "- Preserve exact paths, commands, blockers, and evidence before continuing.",
910
+ "",
911
+ "## Progress",
912
+ "### Done",
913
+ "- [x] Created emergency handoff without calling the summarization model.",
914
+ "",
915
+ "### In Progress",
916
+ "- [ ] Re-check workspace state and continue from critical facts.",
917
+ "",
918
+ "### Blocked",
919
+ previousBlocked && previousBlocked.length > 0 ? previousBlocked : "- (none)",
920
+ "",
921
+ "## Key Decisions",
922
+ "- **Emergency handoff**: Avoided forced minimum input budget that could overflow the model context.",
923
+ "",
924
+ "## Next Steps",
925
+ "1. Re-check workspace state before editing.",
926
+ "2. Resume from Critical Context and run verification before completion.",
927
+ "",
928
+ "## Critical Context",
929
+ formatFallbackFacts(facts),
930
+ "",
931
+ "## Resume Handoff",
932
+ "- **First action**: Re-check workspace state and relevant files from Critical Context.",
933
+ "- **Re-check before editing**: git status and target file contents.",
934
+ "- **Evidence status**: emergency handoff generated; downstream checks not run by compaction.",
935
+ ].join("\n");
936
+ }
937
+ function formatFallbackFacts(fallbackFacts) {
938
+ if (!fallbackFacts || fallbackFacts.length === 0)
939
+ return "- (not captured)";
940
+ return fallbackFacts.map((fact) => `- ${fact}`).join("\n");
941
+ }
942
+ export function validateCompactionSummaryContract(summary, options = {}) {
943
+ const trimmedSummary = summary.trim();
944
+ const missingSections = REQUIRED_SUMMARY_SECTIONS.filter((section) => !trimmedSummary.includes(section));
945
+ const previousBlocked = options.previousSummary
946
+ ? (extractSection(options.previousSummary, "### Blocked") ??
947
+ extractSection(options.previousSummary, "## Blocked"))
948
+ : undefined;
949
+ const shouldPreservePreviousBlocked = previousBlocked !== undefined && previousBlocked.length > 0 && !trimmedSummary.includes(previousBlocked);
950
+ if (missingSections.length === 0 && !shouldPreservePreviousBlocked) {
951
+ return { summary: trimmedSummary, wasRepaired: false, missingSections: [] };
952
+ }
953
+ const repairedSections = [trimmedSummary || "## Goal\n(not captured)"];
954
+ if (!trimmedSummary.includes("## Constraints & Preferences")) {
955
+ repairedSections.push("## Constraints & Preferences\n- (not captured)");
956
+ }
957
+ if (!trimmedSummary.includes("## Progress")) {
958
+ repairedSections.push("## Progress");
959
+ }
960
+ if (!trimmedSummary.includes("### Done")) {
961
+ repairedSections.push("### Done\n- [x] (not captured)");
962
+ }
963
+ if (!trimmedSummary.includes("### In Progress")) {
964
+ repairedSections.push("### In Progress\n- [ ] Continue from preserved context");
965
+ }
966
+ if (!trimmedSummary.includes("### Blocked")) {
967
+ repairedSections.push(`### Blocked\n${previousBlocked && previousBlocked.length > 0 ? previousBlocked : "- (none)"}`);
968
+ }
969
+ else if (shouldPreservePreviousBlocked) {
970
+ repairedSections.push(`### Blocked\n${previousBlocked}`);
971
+ }
972
+ if (!trimmedSummary.includes("## Key Decisions")) {
973
+ repairedSections.push("## Key Decisions\n- **Preserved context**: Summary contract repaired deterministically.");
974
+ }
975
+ if (!trimmedSummary.includes("## Next Steps")) {
976
+ repairedSections.push("## Next Steps\n1. Re-check workspace state before editing.");
977
+ }
978
+ if (!trimmedSummary.includes("## Critical Context")) {
979
+ repairedSections.push(`## Critical Context\n${formatFallbackFacts(options.fallbackFacts)}`);
980
+ }
981
+ if (!trimmedSummary.includes("## Resume Handoff")) {
982
+ repairedSections.push([
983
+ "## Resume Handoff",
984
+ "- **First action**: Re-check workspace state and continue the latest requested task.",
985
+ "- **Re-check before editing**: git status and relevant files from Critical Context.",
986
+ "- **Evidence status**: (not run)",
987
+ ].join("\n"));
988
+ }
989
+ return {
990
+ summary: repairedSections.join("\n\n"),
991
+ wasRepaired: true,
992
+ missingSections,
993
+ };
994
+ }
449
995
  function createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel) {
450
996
  const options = { maxTokens, signal, apiKey, headers };
451
997
  if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
@@ -464,7 +1010,7 @@ async function completeSummarization(model, context, options, streamFn) {
464
1010
  * Generate a summary of the conversation using the LLM.
465
1011
  * If previousSummary is provided, uses the update prompt to merge.
466
1012
  */
467
- export async function generateSummary(currentMessages, model, reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn) {
1013
+ export async function generateSummary(currentMessages, model, reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, summaryInputTokens) {
468
1014
  const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY);
469
1015
  // Use update prompt if we have a previous summary, otherwise initial prompt
470
1016
  let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
@@ -475,8 +1021,37 @@ export async function generateSummary(currentMessages, model, reserveTokens, api
475
1021
  // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
476
1022
  const llmMessages = convertToLlm(currentMessages);
477
1023
  const conversationText = serializeConversation(llmMessages);
1024
+ const summaryInputBudgetResolution = resolveSummaryInputTokenBudgetV3(model, maxTokens, {
1025
+ configuredSummaryInputTokens: summaryInputTokens,
1026
+ basePrompt,
1027
+ previousSummary,
1028
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1029
+ wrapperText: "<conversation></conversation><previous-summary></previous-summary>",
1030
+ });
1031
+ if (summaryInputBudgetResolution.emergency) {
1032
+ const summary = createEmergencyCompactionHandoff({
1033
+ reason: summaryInputBudgetResolution.reason ?? "summary input budget unavailable",
1034
+ conversationText,
1035
+ previousSummary,
1036
+ });
1037
+ recordHarnessControlEvent("compaction.summary.generated", "completed", {
1038
+ model: model.id,
1039
+ provider: model.provider,
1040
+ maxTokens,
1041
+ emergency: true,
1042
+ reason: summaryInputBudgetResolution.reason,
1043
+ fixedPromptTokens: summaryInputBudgetResolution.fixedPromptTokens,
1044
+ availableInputTokens: summaryInputBudgetResolution.availableInputTokens,
1045
+ });
1046
+ return validateCompactionSummaryContract(summary, {
1047
+ previousSummary,
1048
+ fallbackFacts: extractCriticalFactsFromText(conversationText),
1049
+ }).summary;
1050
+ }
1051
+ const summaryInputBudget = summaryInputBudgetResolution.budgetTokens;
1052
+ const packedConversation = packSummaryInputForTokenBudget(conversationText, summaryInputBudget);
478
1053
  // Build the prompt with conversation wrapped in tags
479
- let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
1054
+ let promptText = `<conversation>\n${packedConversation.text}\n</conversation>\n\n`;
480
1055
  if (previousSummary) {
481
1056
  promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
482
1057
  }
@@ -497,7 +1072,22 @@ export async function generateSummary(currentMessages, model, reserveTokens, api
497
1072
  .filter((c) => c.type === "text")
498
1073
  .map((c) => c.text)
499
1074
  .join("\n");
500
- return textContent;
1075
+ const validation = validateCompactionSummaryContract(textContent, {
1076
+ previousSummary,
1077
+ fallbackFacts: extractCriticalFactsFromText(conversationText),
1078
+ });
1079
+ recordHarnessControlEvent("compaction.summary.generated", "completed", {
1080
+ model: model.id,
1081
+ provider: model.provider,
1082
+ maxTokens,
1083
+ summaryInputBudget,
1084
+ packedTokens: packedConversation.packedTokens,
1085
+ omittedTokens: packedConversation.omittedTokens,
1086
+ wasCompressed: packedConversation.wasCompressed,
1087
+ wasRepaired: validation.wasRepaired,
1088
+ missingSections: validation.missingSections,
1089
+ });
1090
+ return validation.summary;
501
1091
  }
502
1092
  export function prepareCompaction(pathEntries, settings) {
503
1093
  if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
@@ -595,16 +1185,16 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
595
1185
  // Generate both summaries in parallel
596
1186
  const [historyResult, turnPrefixResult] = await Promise.all([
597
1187
  messagesToSummarize.length > 0
598
- ? generateSummary(messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn)
1188
+ ? generateSummary(messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, settings.summaryInputTokens)
599
1189
  : Promise.resolve("No prior history."),
600
- generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, headers, signal, thinkingLevel, streamFn),
1190
+ generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, headers, signal, thinkingLevel, streamFn, settings.summaryInputTokens),
601
1191
  ]);
602
1192
  // Merge into single summary
603
1193
  summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
604
1194
  }
605
1195
  else {
606
1196
  // Just generate history summary
607
- summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn);
1197
+ summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, settings.summaryInputTokens);
608
1198
  }
609
1199
  // Compute file lists and append to summary
610
1200
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
@@ -622,11 +1212,17 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
622
1212
  /**
623
1213
  * Generate a summary for a turn prefix (when splitting a turn).
624
1214
  */
625
- async function generateTurnPrefixSummary(messages, model, reserveTokens, apiKey, headers, signal, thinkingLevel, streamFn) {
1215
+ async function generateTurnPrefixSummary(messages, model, reserveTokens, apiKey, headers, signal, thinkingLevel, streamFn, summaryInputTokens) {
626
1216
  const maxTokens = Math.min(Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY); // Smaller budget for turn prefix
627
1217
  const llmMessages = convertToLlm(messages);
628
1218
  const conversationText = serializeConversation(llmMessages);
629
- const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
1219
+ const packedConversation = packSummaryInputForTokenBudget(conversationText, resolveSummaryInputTokenBudget(model, maxTokens, {
1220
+ configuredSummaryInputTokens: summaryInputTokens,
1221
+ basePrompt: TURN_PREFIX_SUMMARIZATION_PROMPT,
1222
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1223
+ wrapperText: "<conversation></conversation>",
1224
+ }));
1225
+ const promptText = `<conversation>\n${packedConversation.text}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
630
1226
  const summarizationMessages = [
631
1227
  {
632
1228
  role: "user",