mixdog 0.9.45 → 0.9.46

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 (116) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/headless-command.mjs +139 -0
  32. package/src/headless-role.mjs +121 -10
  33. package/src/help.mjs +4 -1
  34. package/src/rules/agent/00-common.md +3 -3
  35. package/src/rules/agent/00-core.md +8 -9
  36. package/src/rules/agent/20-skip-protocol.md +2 -3
  37. package/src/rules/agent/30-explorer.md +50 -56
  38. package/src/rules/agent/40-cycle1-agent.md +10 -12
  39. package/src/rules/agent/41-cycle2-agent.md +12 -9
  40. package/src/rules/agent/42-cycle3-agent.md +4 -6
  41. package/src/rules/lead/01-general.md +5 -6
  42. package/src/rules/lead/02-channels.md +1 -1
  43. package/src/rules/lead/lead-brief.md +14 -17
  44. package/src/rules/lead/lead-tool.md +3 -3
  45. package/src/rules/shared/01-tool.md +41 -43
  46. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  48. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  49. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  50. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  51. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  52. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  53. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  56. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  58. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  59. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  60. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  62. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  64. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  65. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  66. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  67. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  69. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  71. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  74. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  76. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  77. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  78. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  80. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  81. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  82. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  83. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  84. package/src/runtime/memory/index.mjs +0 -1
  85. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  86. package/src/runtime/memory/lib/http-router.mjs +0 -193
  87. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  88. package/src/runtime/memory/tool-defs.mjs +5 -6
  89. package/src/runtime/shared/config.mjs +11 -34
  90. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  91. package/src/runtime/shared/pristine-execution.mjs +356 -0
  92. package/src/runtime/shared/provider-api-key.mjs +43 -0
  93. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  94. package/src/session-runtime/context-status.mjs +61 -13
  95. package/src/session-runtime/mcp-glue.mjs +29 -2
  96. package/src/session-runtime/plugin-mcp.mjs +7 -0
  97. package/src/session-runtime/resource-api.mjs +38 -5
  98. package/src/session-runtime/runtime-core.mjs +5 -1
  99. package/src/session-runtime/session-turn-api.mjs +14 -2
  100. package/src/session-runtime/settings-api.mjs +5 -0
  101. package/src/session-runtime/tool-catalog.mjs +13 -2
  102. package/src/session-runtime/tool-defs.mjs +1 -3
  103. package/src/standalone/agent-task-status.mjs +50 -11
  104. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  105. package/src/standalone/explore-tool.mjs +257 -49
  106. package/src/standalone/seeds.mjs +1 -0
  107. package/src/tui/App.jsx +23 -10
  108. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  109. package/src/tui/app/use-transcript-window.mjs +12 -21
  110. package/src/tui/components/ContextPanel.jsx +19 -25
  111. package/src/tui/dist/index.mjs +77 -65
  112. package/src/tui/engine/agent-envelope.mjs +16 -5
  113. package/src/tui/engine/labels.mjs +1 -1
  114. package/src/workflows/default/WORKFLOW.md +21 -51
  115. package/src/workflows/solo/WORKFLOW.md +12 -17
  116. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -124,22 +124,22 @@ function estimateCompactionPromptTokens(input, perMessageChars) {
124
124
  }
125
125
 
126
126
  function previousSummaryBodyForCompactionPrompt(previousSummary) {
127
- const text = String(previousSummary || '').trim();
128
- if (!text) return '';
127
+ const text = String(previousSummary || '');
128
+ if (!text.trim()) return '';
129
129
  return stripNestedSummaryHeaderLines(text);
130
130
  }
131
131
 
132
132
  function priorSummaryNeedsNormalization(text) {
133
- const body = String(text || '').trim();
134
- if (!body) return false;
133
+ const body = String(text || '');
134
+ if (!body.trim()) return false;
135
135
  if (!/^##\s+/m.test(body)) return true;
136
136
  if (!summaryIsSchemaValid(body)) return true;
137
137
  return summaryHasUnrecognizedHeadings(body);
138
138
  }
139
139
 
140
140
  function normalizePriorSummaryForCompactionPrompt(fullBody) {
141
- const text = String(fullBody || '').trim();
142
- if (!text) return '';
141
+ const text = String(fullBody || '');
142
+ if (!text.trim()) return '';
143
143
  if (!priorSummaryNeedsNormalization(text)) return text;
144
144
  return repairSemanticSummary(text, { head: [], tail: [] });
145
145
  }
@@ -154,9 +154,9 @@ function fitPreviousSummaryForCompactionPrompt(input, perMessageChars, targetTok
154
154
  previousSummaryBodyForCompactionPrompt(input.previousSummary),
155
155
  );
156
156
  const withSummary = (summaryText) => {
157
- const trimmed = String(summaryText || '').trim();
158
- if (!trimmed) return { ...input, previousSummary: null };
159
- return { ...input, previousSummary: trimmed };
157
+ const value = String(summaryText || '');
158
+ if (!value.trim()) return { ...input, previousSummary: null };
159
+ return { ...input, previousSummary: value };
160
160
  };
161
161
 
162
162
  if (estimateCompactionPromptTokens(withSummary(fullBody), perMessageChars) <= targetTokens) {
@@ -412,23 +412,162 @@ export const RECALL_TAIL_SHORT_TRUNCATION_MARKER = '[truncated]';
412
412
 
413
413
  const PRIOR_COMPACTED_CONTEXT_OPEN = '<prior-compacted-context>';
414
414
  const PRIOR_COMPACTED_CONTEXT_CLOSE = '</prior-compacted-context>';
415
+ // Matches ONLY a structural wrapper BOUNDARY line — a line whose sole content is
416
+ // the open or close tag (optionally surrounded by whitespace). It deliberately
417
+ // does NOT match an inline occurrence embedded in real content (e.g. a user note
418
+ // like "keep <prior-compacted-context> literal here"), so flattening the wrapper
419
+ // can never splice words together or corrupt marker-like text. The production
420
+ // wrapper is always emitted on its own line, so boundary-line stripping removes
421
+ // every real wrapper while leaving inline literals verbatim.
422
+ const PRIOR_COMPACTED_CONTEXT_BOUNDARY_RE = /^[ \t]*<\/?prior-compacted-context>[ \t]*$/;
423
+
424
+ // Strip the STRUCTURAL <prior-compacted-context> / </prior-compacted-context>
425
+ // boundary lines from prior text so re-wrapping never nests, while preserving
426
+ // any inline marker-like text inside real content exactly as written. Returns
427
+ // the bare inner content with the blank lines the removed boundary lines leave
428
+ // behind collapsed.
429
+ export function stripPriorCompactedContextWrappers(text) {
430
+ const raw = String(text ?? '');
431
+ if (!raw) return '';
432
+ // Keep every non-structural byte, including leading/trailing and repeated
433
+ // newlines. Removing only the structural line itself (not trim/collapse)
434
+ // preserves the layout of all remaining content.
435
+ return raw
436
+ .split('\n')
437
+ .filter((line) => !PRIOR_COMPACTED_CONTEXT_BOUNDARY_RE.test(line))
438
+ .join('\n');
439
+ }
415
440
 
416
- function formatPriorCompactedContextBlock(priorText) {
417
- const prior = String(priorText || '').trim();
418
- if (!prior) return '';
419
- return `${PRIOR_COMPACTED_CONTEXT_OPEN}\n${prior}\n${PRIOR_COMPACTED_CONTEXT_CLOSE}`;
441
+ // Remove only STRUCTURALLY IDENTICAL blank-line-separated blocks (byte-for-byte
442
+ // repeats) so a prior context re-fed across many compaction cycles keeps every
443
+ // distinct requirement/fact and cannot grow without bound. The dedupe is
444
+ // content-preserving: the key is the block's EXACT text — internal whitespace is
445
+ // never collapsed and meaningful block content is never trimmed — so distinct
446
+ // strings such as `printf 'a b'` and `printf 'a b'` are BOTH kept verbatim.
447
+ // Only an exact repeat of a previously emitted block is dropped; blank
448
+ // separators (whitespace-only splits) are skipped, never real content.
449
+ function dedupePriorCompactedBlocks(text) {
450
+ const raw = String(text ?? '');
451
+ if (!raw.trim()) return '';
452
+ const seen = new Set();
453
+ const parts = raw.split(/(\n{2,})/);
454
+ const out = [];
455
+ let separator = '';
456
+ for (let i = 0; i < parts.length; i += 1) {
457
+ const part = parts[i];
458
+ if (i % 2 === 1) {
459
+ separator += part;
460
+ continue;
461
+ }
462
+ // Whitespace-only parts are layout, not a duplicate candidate.
463
+ if (!part.trim()) {
464
+ out.push(separator, part);
465
+ separator = '';
466
+ continue;
467
+ }
468
+ if (seen.has(part)) {
469
+ // Drop only the exact repeated structural block and its preceding
470
+ // separator; all retained text and whitespace stay byte-for-byte.
471
+ separator = '';
472
+ continue;
473
+ }
474
+ out.push(separator, part);
475
+ separator = '';
476
+ seen.add(part);
477
+ }
478
+ out.push(separator);
479
+ return out.join('');
480
+ }
481
+
482
+ // Canonicalize prior compacted context to AT MOST ONE wrapper: flatten any
483
+ // nested/duplicated wrappers accumulated by earlier cycles, dedupe repeated
484
+ // blocks, then wrap the surviving content exactly once. Repeated compaction can
485
+ // therefore never nest or duplicate the prior context, each distinct
486
+ // requirement/fact is preserved exactly once, and repeated-cycle token size
487
+ // stays bounded.
488
+ //
489
+ // Optimization-safe empty-prior interpretation: when there is NO prior content
490
+ // (empty / blank / boundary-tag-only input) this returns '' so the generated
491
+ // summary carries ZERO wrappers instead of an empty
492
+ // <prior-compacted-context></prior-compacted-context> pair. The production
493
+ // summary body joins only non-empty parts (makeRecall*SummaryMessageParts), so
494
+ // an empty wrapper cannot be carried and would only waste tokens. "Exactly one
495
+ // wrapper" is thus realized as: exactly one when prior content exists, none when
496
+ // it does not — and never more than one, never nested.
497
+ export function formatPriorCompactedContextBlock(priorText) {
498
+ const flattened = dedupePriorCompactedBlocks(stripPriorCompactedContextWrappers(priorText));
499
+ if (!flattened) return '';
500
+ return `${PRIOR_COMPACTED_CONTEXT_OPEN}\n${flattened}\n${PRIOR_COMPACTED_CONTEXT_CLOSE}`;
420
501
  }
421
502
 
422
503
  export function stripNestedSummaryHeaderLines(text) {
423
- const lines = String(text ?? '').split('\n');
504
+ const raw = String(text ?? '');
505
+ // A generated recall summary has a canonical, provenance-bearing shape:
506
+ // header + "\n\n" + OPEN + "\n" + prior + "\n" + CLOSE + "\n\n" + recall
507
+ // Extracting it as lines loses ownership of a run of newlines: in
508
+ // `X\n` + join + live `X`, generic block dedupe cannot know that one
509
+ // newline belongs to X and must survive dropping the duplicate live X.
510
+ // Peel only the emitted wrapper/header/join bytes and retain the inner
511
+ // prior slice verbatim. The live recall is dropped only when it is the
512
+ // same payload the wrapper already carries.
513
+ const openMatch = /^[ \t]*<prior-compacted-context>[ \t]*\n/m.exec(raw);
514
+ if (openMatch) {
515
+ const closeRe = /\n[ \t]*<\/prior-compacted-context>[ \t]*(?=\n|$)/g;
516
+ closeRe.lastIndex = openMatch.index + openMatch[0].length;
517
+ const closeMatch = closeRe.exec(raw);
518
+ if (closeMatch) {
519
+ const prior = raw.slice(openMatch.index + openMatch[0].length, closeMatch.index);
520
+ const remainder = raw.slice(closeMatch.index + closeMatch[0].length);
521
+ // The only bytes between generated wrapper and live recall are
522
+ // this part-join's two newlines. Do not consume any other newline:
523
+ // those belong to either prior or live content.
524
+ const live = remainder.startsWith('\n\n') ? remainder.slice(2) : remainder;
525
+ if (!live || prior.trim() === live) return prior;
526
+ if (!prior) return live;
527
+ return `${prior}\n\n${live}`;
528
+ }
529
+ }
530
+
531
+ const lines = raw.split('\n');
424
532
  const out = [];
533
+ let followsStructuralHeader = false;
425
534
  for (const line of lines) {
426
- if (line.startsWith(SUMMARY_PREFIX)) continue;
427
- if (/^messages=\d+\s+(?:sha256=|compact_type=)/.test(line.trim())) continue;
428
- if (/^compact_type=/.test(line.trim())) continue;
535
+ if (line.startsWith(SUMMARY_PREFIX)) {
536
+ followsStructuralHeader = true;
537
+ continue;
538
+ }
539
+ if (/^messages=\d+\s+(?:sha256=|compact_type=)/.test(line.trim())) {
540
+ followsStructuralHeader = true;
541
+ continue;
542
+ }
543
+ if (/^compact_type=/.test(line.trim())) {
544
+ followsStructuralHeader = true;
545
+ continue;
546
+ }
547
+ // Summary parts are joined with "\n\n". The first empty line after
548
+ // stripped summary metadata is that join's structural separator, not
549
+ // prior content; retaining it makes every refeed gain a newline when
550
+ // formatPriorCompactedContextBlock wraps the result again.
551
+ if (followsStructuralHeader && line === '') {
552
+ followsStructuralHeader = false;
553
+ continue;
554
+ }
555
+ followsStructuralHeader = false;
556
+ // A prior summary re-fed as <previous-summary>/prior may still carry
557
+ // the canonical <prior-compacted-context> wrapper from an earlier
558
+ // cycle; drop those tag-only lines so the caller re-wraps exactly once
559
+ // (or treats the body as bare prior) instead of nesting.
560
+ if (/^<prior-compacted-context>$/.test(line.trim())) {
561
+ // The immediately preceding blank is the header→wrapper join from
562
+ // a generated summary. Remove only that wrapper-owned separator;
563
+ // blank lines inside the wrapper body remain untouched.
564
+ if (out.length > 0 && out[out.length - 1] === '') out.pop();
565
+ continue;
566
+ }
567
+ if (/^<\/prior-compacted-context>$/.test(line.trim())) continue;
429
568
  out.push(line);
430
569
  }
431
- return out.join('\n').trim();
570
+ return out.join('\n');
432
571
  }
433
572
 
434
573
  function makeRecallFastTrackSummaryMessageParts(oldHistory, recallPart, priorPart, recallMeta = {}) {
@@ -444,7 +583,7 @@ function makeRecallFastTrackSummaryMessageParts(oldHistory, recallPart, priorPar
444
583
 
445
584
  export function fitRecallFastTrackSummaryMessage(oldHistory, recallText, remainingTokens, recallMeta = {}, priorPart = '') {
446
585
  const recall = String(recallText || '').trim();
447
- const prior = String(priorPart || '').trim();
586
+ const prior = String(priorPart || '');
448
587
 
449
588
  let fittedPrior = prior;
450
589
  if (prior) {
@@ -594,7 +733,7 @@ function makeRecallRootsSummaryMessageParts(oldHistory, rootsPart, priorPart, re
594
733
  // blocks can only shrink (never grow) the serialized size, the minimal-drop
595
734
  // threshold is found via binary search on the drop count.
596
735
  export function fitRecallRootsMessage(oldHistory, recallText, remainingTokens, recallMeta = {}, priorPart = '') {
597
- const prior = String(priorPart || '').trim();
736
+ const prior = String(priorPart || '');
598
737
 
599
738
  let fittedPrior = prior;
600
739
  if (prior) {
@@ -253,6 +253,7 @@ function contextMessageContribution(message) {
253
253
  role,
254
254
  tokens,
255
255
  reminderBuckets: null,
256
+ systemWorkflowTokens: 0,
256
257
  toolCallCount: 0,
257
258
  toolCallTokens: 0,
258
259
  toolResultCount: role === 'tool' ? 1 : 0,
@@ -270,6 +271,13 @@ function contextMessageContribution(message) {
270
271
  buckets.otherTokens = Math.max(0, contribution.tokens - sectionTokens);
271
272
  contribution.reminderBuckets = buckets;
272
273
  }
274
+ if (role === 'system') {
275
+ for (const section of splitMarkdownSections(text)) {
276
+ if (reminderSectionBucket(section) === 'workflow') {
277
+ contribution.systemWorkflowTokens += estimateTokens(section);
278
+ }
279
+ }
280
+ }
273
281
  if (fingerprint.role === 'assistant' && Array.isArray(message?.toolCalls) && message.toolCalls.length) {
274
282
  contribution.toolCallCount = message.toolCalls.length;
275
283
  try { contribution.toolCallTokens = estimateTokens(JSON.stringify(message.toolCalls)); }
@@ -317,7 +325,8 @@ function applyContextMessageContribution(state, contribution, direction) {
317
325
  state.rows[role].tokens += direction * tokens;
318
326
  if (role === 'system') {
319
327
  state.semantic.system.count += direction;
320
- state.semantic.system.tokens += direction * tokens;
328
+ state.semantic.system.tokens += direction * (tokens - contribution.systemWorkflowTokens);
329
+ state.semantic.workflow.tokens += direction * contribution.systemWorkflowTokens;
321
330
  } else if (role === 'user') {
322
331
  if (contribution.reminderBuckets) {
323
332
  state.semantic.reminders.count += direction;
@@ -358,8 +367,11 @@ function contextSummaryResult(state, count) {
358
367
 
359
368
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
360
369
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
361
- export const MAIN_COMPACTION_TRIGGER_RATIO = 0.95;
370
+ // Main/user recall-fasttrack compacts at 95% by default. Lower triggers remain
371
+ // opt-in through the mainBuffer* configuration and MIXDOG_MAIN_COMPACT_BUFFER_*.
372
+ export const DEFAULT_MAIN_COMPACTION_BUFFER_RATIO = 0.05;
362
373
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
374
+ const MAX_BUFFER_INPUT_RATIO = 0.999_999;
363
375
  export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
364
376
  const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
365
377
 
@@ -384,11 +396,11 @@ export function normalizeCompactionBufferRatio(value, fallback = DEFAULT_COMPACT
384
396
  export function resolveBufferRatioCandidate(percentInputs = [], ratioInputs = []) {
385
397
  for (const raw of percentInputs) {
386
398
  const n = Number(raw);
387
- if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
399
+ if (Number.isFinite(n) && n > 0) return Math.min(MAX_BUFFER_INPUT_RATIO, n / 100);
388
400
  }
389
401
  for (const raw of ratioInputs) {
390
402
  const n = Number(raw);
391
- if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
403
+ if (Number.isFinite(n) && n > 0) return Math.min(MAX_BUFFER_INPUT_RATIO, n > 1 ? n / 100 : n);
392
404
  }
393
405
  return null;
394
406
  }
@@ -401,6 +413,42 @@ export function resolveCompactBufferRatio(cfg = {}) {
401
413
  return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
402
414
  }
403
415
 
416
+ // Main/user settings are deliberately separate from the semantic-agent buffer:
417
+ // `buffer*` and MIXDOG_AGENT_COMPACT_BUFFER_* remain agent-only controls.
418
+ // Source precedence is config before environment; within each source the fixed
419
+ // unit precedence is tokens, percent, then ratio. Invalid/zero values are
420
+ // ignored, and percent/ratio inputs are capped below 100%/1.0.
421
+ function positiveTokenCandidate(values = []) {
422
+ for (const value of values) {
423
+ const tokens = positiveTokenInt(value);
424
+ if (tokens) return tokens;
425
+ }
426
+ return null;
427
+ }
428
+
429
+ function resolveMainBufferSetting(cfg = {}) {
430
+ const configTokens = positiveTokenCandidate([cfg.mainBufferTokens, cfg.mainBuffer]);
431
+ if (configTokens) return { tokens: configTokens };
432
+ const configRatio = resolveBufferRatioCandidate(
433
+ [cfg.mainBufferPercent, cfg.mainBufferPct],
434
+ [cfg.mainBufferRatio, cfg.mainBufferFraction],
435
+ );
436
+ if (configRatio !== null) return { ratio: configRatio };
437
+
438
+ const envTokens = positiveTokenCandidate([process.env.MIXDOG_MAIN_COMPACT_BUFFER_TOKENS]);
439
+ if (envTokens) return { tokens: envTokens };
440
+ const envRatio = resolveBufferRatioCandidate(
441
+ [process.env.MIXDOG_MAIN_COMPACT_BUFFER_PERCENT],
442
+ [process.env.MIXDOG_MAIN_COMPACT_BUFFER_RATIO],
443
+ );
444
+ return envRatio === null ? null : { ratio: envRatio };
445
+ }
446
+
447
+ export function resolveMainCompactBufferRatio(cfg = {}) {
448
+ const setting = resolveMainBufferSetting(cfg);
449
+ return setting?.ratio ?? DEFAULT_MAIN_COMPACTION_BUFFER_RATIO;
450
+ }
451
+
404
452
  export function compactionBufferTokensForBoundary(boundaryTokens, opts = {}) {
405
453
  const boundary = Math.max(0, Math.floor(Number(boundaryTokens) || 0));
406
454
  const explicit = Math.max(0, Math.floor(Number(opts.explicitTokens) || 0));
@@ -483,6 +531,18 @@ export function resolveCompactBufferTokens(boundaryTokens, cfg = {}, opts = {})
483
531
  });
484
532
  }
485
533
 
534
+ export function resolveMainCompactBufferTokens(boundaryTokens, cfg = {}, opts = {}) {
535
+ const boundary = positiveTokenInt(boundaryTokens);
536
+ const setting = resolveMainBufferSetting(cfg);
537
+ const configured = setting?.tokens || 0;
538
+ if (!boundary) return configured || positiveTokenInt(opts.defaultTokens) || DEFAULT_COMPACTION_BUFFER_TOKENS;
539
+ return compactionBufferTokensForBoundary(boundary, {
540
+ explicitTokens: configured,
541
+ ratio: setting?.ratio ?? DEFAULT_MAIN_COMPACTION_BUFFER_RATIO,
542
+ maxRatio: opts.maxRatio ?? MAX_COMPACTION_BUFFER_RATIO,
543
+ });
544
+ }
545
+
486
546
  export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens = 0) {
487
547
  return resolveSessionCompactPolicy(sessionOrConfig, boundaryTokens).triggerTokens;
488
548
  }
@@ -495,8 +555,8 @@ export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens
495
555
  // (trigger = limit) for every session type;
496
556
  // - agent-owned semantic sessions otherwise keep the default early-trigger
497
557
  // buffer (config-driven, default 10% -> compact at 90% of the boundary);
498
- // - main/user recall-fasttrack sessions keep 5% headroom and compact at 95%
499
- // of the effective boundary.
558
+ // - main/user recall-fasttrack sessions use their independently configurable
559
+ // buffer (default 25% -> compact at 75% of the effective boundary).
500
560
  // Returns the sanitized explicit limit (null when absent/legacy full-window)
501
561
  // plus triggerTokens / bufferTokens / bufferRatio for the given boundary.
502
562
  export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens = 0) {
@@ -507,7 +567,9 @@ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens
507
567
  autoCompactTokenLimit: null,
508
568
  triggerTokens: null,
509
569
  bufferTokens: 0,
510
- bufferRatio: resolveCompactBufferRatio(cfg),
570
+ bufferRatio: isAgentOwner(sessionOrConfig)
571
+ ? resolveCompactBufferRatio(cfg)
572
+ : resolveMainCompactBufferRatio(cfg),
511
573
  };
512
574
  }
513
575
  const rawLimit = positiveTokenInt(sessionOrConfig?.autoCompactTokenLimit ?? cfg?.autoCompactTokenLimit);
@@ -518,7 +580,7 @@ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens
518
580
  } else if (isAgentOwner(sessionOrConfig)) {
519
581
  triggerTokens = Math.max(1, boundary - resolveCompactBufferTokens(boundary, cfg));
520
582
  } else {
521
- triggerTokens = Math.max(1, Math.floor(boundary * MAIN_COMPACTION_TRIGGER_RATIO));
583
+ triggerTokens = Math.max(1, boundary - resolveMainCompactBufferTokens(boundary, cfg));
522
584
  }
523
585
  const bufferTokens = Math.max(0, boundary - triggerTokens);
524
586
  const bufferRatio = bufferTokens / boundary;
@@ -617,8 +679,19 @@ const toolSchemaTokenMemo = new WeakMap();
617
679
  const requestReserveTokenMemo = new WeakMap();
618
680
 
619
681
  function serializeToolSchemas(tools) {
620
- try { return JSON.stringify(Array.isArray(tools) ? tools : []); }
621
- catch { return (Array.isArray(tools) ? tools : []).map(t => String(t?.name ?? '')).join(''); }
682
+ const list = Array.isArray(tools) ? tools : [];
683
+ try {
684
+ return JSON.stringify(list.map(tool => {
685
+ const wireTool = {
686
+ name: tool?.name,
687
+ description: tool?.description,
688
+ input_schema: tool?.inputSchema ?? tool?.input_schema ?? tool?.parameters ?? tool?.schema,
689
+ };
690
+ if (tool?.deferLoading === true || tool?.defer_loading === true) wireTool.defer_loading = true;
691
+ return wireTool;
692
+ }));
693
+ }
694
+ catch { return list.map(t => String(t?.name ?? '')).join(''); }
622
695
  }
623
696
 
624
697
  export function toolSchemaSignature(tools) {
@@ -81,6 +81,32 @@ function resolveCompactKeepTokens(cfg = {}) {
81
81
  || envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
82
82
  || DEFAULT_COMPACTION_KEEP_TOKENS;
83
83
  }
84
+
85
+ function compactTriggerMarginTokens(boundaryTokens) {
86
+ const boundary = positiveTokenInt(boundaryTokens);
87
+ if (!boundary) return 1;
88
+ return Math.max(1, Math.min(1_024, Math.floor(boundary * 0.01)));
89
+ }
90
+
91
+ function legacyCompactTargetBudget(boundaryTokens, targetTokens, reserveTokens) {
92
+ const boundary = positiveTokenInt(boundaryTokens);
93
+ if (!boundary) return null;
94
+ return Math.max(1, Math.min(boundary, targetTokens + reserveTokens));
95
+ }
96
+
97
+ function compactTargetBudgetForTrigger(boundaryTokens, targetTokens, reserveTokens, triggerTokens, singleShot = false, force = false) {
98
+ const legacyTarget = legacyCompactTargetBudget(boundaryTokens, targetTokens, reserveTokens);
99
+ const trigger = positiveTokenInt(triggerTokens);
100
+ // Degenerate reserve/window combinations cannot leave any post-compact
101
+ // headroom. Keep the legacy target and let the caller compact once only,
102
+ // rather than inventing a zero/negative margin that would immediately loop.
103
+ if (singleShot || !trigger) return legacyTarget;
104
+ const boundedTarget = Math.max(1, Math.min(legacyTarget, trigger - 1));
105
+ // Forced/manual compaction must retain a viable legacy budget when the
106
+ // strict no-repeat clamp would consume all non-reserve working space.
107
+ return force && boundedTarget <= reserveTokens ? legacyTarget : boundedTarget;
108
+ }
109
+
84
110
  export function resolveWorkerCompactPolicy(sessionRef, tools) {
85
111
  if (!sessionRef) return null;
86
112
  const cfg = sessionRef.compaction || {};
@@ -95,19 +121,37 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
95
121
  if (!boundaryTokens) return null;
96
122
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
97
123
  // Shared session-compaction policy (context-utils): agent semantic keeps the
98
- // default early-trigger buffer (90%); main/user keep 5% headroom (95%);
124
+ // default early-trigger buffer (90%); main/user use their independently
125
+ // configurable 25% default headroom (75%);
99
126
  // a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
100
127
  // is the sanitized (null when legacy full-window) value so telemetry never
101
128
  // re-persists a boundary-collapsing limit.
102
129
  const policy = resolveSessionCompactPolicy(sessionRef, compactBoundaryTokens);
103
130
  const explicitAutoCompactTokenLimit = policy.autoCompactTokenLimit;
104
- const bufferTokens = policy.bufferTokens;
105
- const bufferRatio = policy.bufferRatio;
106
- const triggerTokens = policy.triggerTokens;
107
131
  const configuredReserve = positiveTokenInt(cfg.reservedTokens)
108
132
  || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
109
133
  || 0;
110
134
  const requestReserve = estimateRequestReserveTokens(tools);
135
+ const reserveTokens = requestReserve + configuredReserve;
136
+ const compactTargetTokens = resolveCompactTargetTokens(compactBoundaryTokens, cfg) || compactBoundaryTokens;
137
+ const legacyTargetBudget = legacyCompactTargetBudget(compactBoundaryTokens, compactTargetTokens, reserveTokens);
138
+ // Reserve is included in every next-send pressure calculation, so its
139
+ // relationship to the actual trigger (not the raw boundary) determines
140
+ // whether a post-compact transcript can ever fall below the trigger.
141
+ const singleShot = reserveTokens >= policy.triggerTokens;
142
+ // Main/user recall-fasttrack must not compact into its next trigger. Keep a
143
+ // 1%-of-boundary (up to 1,024-token) gap above the effective post-compact
144
+ // target. Explicit sub-boundary limits and agent semantic triggers retain
145
+ // their established precedence/behavior.
146
+ const minMainTrigger = Math.min(
147
+ compactBoundaryTokens,
148
+ (legacyTargetBudget || 0) + compactTriggerMarginTokens(compactBoundaryTokens),
149
+ );
150
+ const triggerTokens = !singleShot && !isAgentOwner(sessionRef) && !explicitAutoCompactTokenLimit
151
+ ? Math.max(policy.triggerTokens, minMainTrigger)
152
+ : policy.triggerTokens;
153
+ const bufferTokens = Math.max(0, compactBoundaryTokens - triggerTokens);
154
+ const bufferRatio = bufferTokens / compactBoundaryTokens;
111
155
  const keepTokens = resolveCompactKeepTokens(cfg);
112
156
  const compactType = resolveCompactTypeSetting(sessionRef, cfg);
113
157
  return {
@@ -119,6 +163,8 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
119
163
  triggerTokens,
120
164
  bufferTokens,
121
165
  bufferRatio,
166
+ compactTargetTokens,
167
+ singleShot,
122
168
  contextWindow,
123
169
  rawContextWindow: positiveTokenInt(sessionRef.rawContextWindow ?? cfg.rawContextWindow) || contextWindow,
124
170
  effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
@@ -131,7 +177,7 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
131
177
  tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
132
178
  keepTokens,
133
179
  preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_AGENT_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
134
- reserveTokens: requestReserve + configuredReserve,
180
+ reserveTokens,
135
181
  requestReserveTokens: requestReserve,
136
182
  configuredReserveTokens: configuredReserve,
137
183
  toolSchemaSignature: toolSchemaSignature(tools),
@@ -257,8 +303,20 @@ export function compactTargetBudget(policy) {
257
303
  const boundary = positiveTokenInt(policy?.boundaryTokens);
258
304
  if (!boundary) return null;
259
305
  const reserve = Math.max(0, Number(policy?.reserveTokens) || 0);
260
- const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
261
- return Math.max(1, Math.min(boundary, targetEffective + reserve));
306
+ const targetEffective = positiveTokenInt(policy?.compactTargetTokens)
307
+ || resolveCompactTargetTokens(boundary, policy)
308
+ || boundary;
309
+ const trigger = positiveTokenInt(policy?.triggerTokens);
310
+ const singleShot = policy?.singleShot === true
311
+ || (trigger > 0 && reserve >= trigger);
312
+ return compactTargetBudgetForTrigger(
313
+ boundary,
314
+ targetEffective,
315
+ reserve,
316
+ trigger,
317
+ singleShot,
318
+ policy?.force === true,
319
+ );
262
320
  }
263
321
  export function shouldCompactForSession(messageTokensEst, policy, {
264
322
  forceReactive = false,
@@ -267,7 +325,14 @@ export function shouldCompactForSession(messageTokensEst, policy, {
267
325
  pressureTokens,
268
326
  } = {}) {
269
327
  if (!policy?.auto || !policy.boundaryTokens) return false;
328
+ // send-with-recovery permits exactly one context-overflow retry per send
329
+ // (`contextOverflowRetryUsed`), so this can consume at most one additional
330
+ // reactive compact after a one-shot attempt; a second overflow is surfaced.
270
331
  if (forceReactive) return true;
332
+ // A reserve at/above the trigger (or a one-token boundary)
333
+ // can never satisfy target < trigger. Permit one legacy compact attempt,
334
+ // then suppress automatic repeats until an operator intervenes.
335
+ if (policy.singleShot === true && sessionRef?.compaction?.singleShotConsumed === true) return false;
271
336
  if (messageTokensEst === null) return true;
272
337
  const pressure = Number.isFinite(Number(pressureTokens))
273
338
  ? Number(pressureTokens)
@@ -339,6 +404,9 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
339
404
  ? Math.max(0, Math.round(Number(meta.durationMs)))
340
405
  : null,
341
406
  compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
407
+ singleShotConsumed: policy.singleShot === true && meta.stage === 'compacting'
408
+ ? true
409
+ : prev.singleShotConsumed === true,
342
410
  };
343
411
  if (changed) {
344
412
  const changedAt = Date.now();