mixdog 0.9.15 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +487 -211
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +35 -5
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -669,6 +669,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
669
669
  } else if (event.response.status === 'incomplete') {
670
670
  const reason = incompleteReasonFromResponsesEvent(event);
671
671
  if (isMaxOutputIncompleteReason(reason)) {
672
+ // Max-output cutoff with a tool call still in flight means
673
+ // the function-call arguments were truncated — do NOT mark
674
+ // this a clean completion, or partial args surface as a
675
+ // successful tool call. Treat as unsafe/partial instead.
676
+ if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
677
+ const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
678
+ err.streamStalled = true;
679
+ err.pendingToolUse = true;
680
+ err.partialContent = state.content || '';
681
+ throw err;
682
+ }
672
683
  state.completed = true;
673
684
  state.stopReason = 'length';
674
685
  state.completedResponse = event.response || state.completedResponse;
@@ -686,6 +697,13 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
686
697
  case 'response.incomplete': {
687
698
  const reason = incompleteReasonFromResponsesEvent(event);
688
699
  if (isMaxOutputIncompleteReason(reason)) {
700
+ if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
701
+ const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
702
+ err.streamStalled = true;
703
+ err.pendingToolUse = true;
704
+ err.partialContent = state.content || '';
705
+ throw err;
706
+ }
689
707
  state.completed = true;
690
708
  state.stopReason = 'length';
691
709
  state.completedResponse = event.response || state.completedResponse;
@@ -113,7 +113,7 @@ async function _resolveCodexClientVersion() {
113
113
  return CODEX_CLIENT_VERSION_FLOOR;
114
114
  }
115
115
  const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
116
- const CODEX_MODEL_CACHE_SCHEMA_VERSION = 2;
116
+ const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
117
117
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
118
118
 
119
119
  const _codexModelCache = makeModelCache({
@@ -587,6 +587,10 @@ export async function _streamResponse({
587
587
  // Partial-final recovery: attach streamed partial state so
588
588
  // a wedged FINAL no-tool summary can be accepted as partial-final
589
589
  // success by the loop. pendingToolUse gates out mid-flight tools.
590
+ // Fold the held leak-guard tail into `content` FIRST so the
591
+ // partial snapshot below keeps legitimate trailing text; finish()
592
+ // then skips flushLeak (terminalError set) without losing it.
593
+ flushLeak();
590
594
  try {
591
595
  terminalError.partialContent = content;
592
596
  terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
@@ -631,9 +635,14 @@ export async function _streamResponse({
631
635
  };
632
636
  const finish = () => {
633
637
  logReasoningDeltaSuppression();
638
+ // On a terminal error we must NOT flush buffered text/tool calls:
639
+ // finish() rejects below, so flushing would emit partial output the
640
+ // caller then never consumes as a clean result (double-render/
641
+ // double-dispatch risk). The partial-final path reads partialContent
642
+ // off the error instead. Only flush the held-back tail on success.
643
+ if (!terminalError) flushLeak();
634
644
  // Flush any partial-sentinel tail held back mid-stream so
635
645
  // legitimate trailing text is never lost (streamed-text path).
636
- flushLeak();
637
646
  cleanup();
638
647
  if (terminalError) { reject(terminalError); return; }
639
648
  resolve({
@@ -694,6 +703,12 @@ export async function _streamResponse({
694
703
  // first-meaningful watchdog (keepalive/metadata frames do
695
704
  // NOT reach this case, so they never clear it).
696
705
  clearFirstMeaningfulWatchdog();
706
+ // Arm the semantic idle/stall timer from response.created
707
+ // too: it otherwise only arms on the first meaningful delta
708
+ // (bumpSemanticIdle), so a created+keepalive-only stream that
709
+ // never emits a delta would stall unbounded until the outer
710
+ // watchdog. Arming here bounds that gap.
711
+ resetSemanticIdle();
697
712
  break;
698
713
  case 'response.output_text.delta':
699
714
  try {
@@ -748,6 +763,10 @@ export async function _streamResponse({
748
763
  // response.output_item.done.
749
764
  _toolInFlight = true;
750
765
  }
766
+ // Item lifecycle is genuine progress: reset the semantic-idle
767
+ // timer so long server-side tool latency after item-added
768
+ // (before any arg delta) is not mistaken for a silent stall.
769
+ resetSemanticIdle();
751
770
  break;
752
771
  case 'response.function_call_arguments.delta':
753
772
  _toolInFlight = true;
@@ -826,6 +845,9 @@ export async function _streamResponse({
826
845
  if (event.item?.type === 'custom_tool_call') {
827
846
  pushCustomToolCall(event.item);
828
847
  }
848
+ // Item-done is genuine lifecycle progress — reset semantic
849
+ // idle so latency before the next item/args does not stall.
850
+ resetSemanticIdle();
829
851
  break;
830
852
  case 'response.completed': {
831
853
  const completedServiceTier = event.response?.service_tier || event.response?.serviceTier || '';
@@ -1048,7 +1070,13 @@ export async function _streamResponse({
1048
1070
  try { onStreamDelta?.(); } catch {}
1049
1071
  bumpSemanticIdle();
1050
1072
  }
1051
- // Trace-only events (response.in_progress, etc.)
1073
+ // response.in_progress is a server lifecycle heartbeat during
1074
+ // long tool/generation latency — reset semantic idle so it is
1075
+ // not counted as silence (resetIdle already ran at the top).
1076
+ else if (event.type === 'response.in_progress') {
1077
+ resetSemanticIdle();
1078
+ }
1079
+ // Other trace-only events fall through.
1052
1080
  break;
1053
1081
  }
1054
1082
  };
@@ -134,6 +134,25 @@ function _scopedDependencyRoots(toolName, args, cwd) {
134
134
  }
135
135
  if (rawPaths.length > 0) {
136
136
  for (const p of rawPaths) add(p);
137
+ // `glob` results are gated on the pattern's static (non-magic) prefix,
138
+ // not just cwd/path root — a pattern like "src/**/*.mjs" must register
139
+ // "src", not just cwd, or edits under src/ that are not directly under
140
+ // the given path root will not invalidate the cached glob result.
141
+ if (toolName === 'glob' && canonicalArgs && typeof canonicalArgs.pattern !== 'undefined') {
142
+ const patterns = [];
143
+ _collectPathValues(canonicalArgs.pattern, patterns);
144
+ for (const pattern of patterns) {
145
+ if (typeof pattern !== 'string' || !_hasGlobMagic(pattern)) continue;
146
+ const patternRoot = _extractGlobRoot(pattern);
147
+ if (_pathIsAbs(patternRoot)) {
148
+ add(patternRoot);
149
+ } else if (rawPaths.length > 0) {
150
+ for (const p of rawPaths) add(join(p, patternRoot));
151
+ } else {
152
+ add(patternRoot);
153
+ }
154
+ }
155
+ }
137
156
  } else if (cwd && typeof cwd === 'string') {
138
157
  add(cwd);
139
158
  }
@@ -22,11 +22,11 @@ export {
22
22
  export const SUMMARY_PREFIX = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.';
23
23
  export const SUMMARY_OUTPUT_TOKENS = 4_096;
24
24
  // Unified context-share rule: every derived "how much of the model context
25
- // may this budget take" ratio uses ONE number — 5%. Consumers:
25
+ // may this budget take" ratio uses ONE number — 10%. Consumers:
26
26
  // - compact target budget (loop/compact-policy.mjs COMPACT_TARGET_RATIO)
27
27
  // - recall-fasttrack injection cap (loop.mjs recallTokenCap)
28
28
  // Keep them in lockstep; do not fork per-consumer ratios without a decision.
29
- export const CONTEXT_SHARE_RATIO = 0.05;
29
+ export const CONTEXT_SHARE_RATIO = 0.10;
30
30
  // Floor for the recall-injection cap so small-context models still get a
31
31
  // usable recall slice (cap = max(floor, contextWindow * CONTEXT_SHARE_RATIO)).
32
32
  export const RECALL_TOKEN_CAP_FLOOR_TOKENS = 2_048;
@@ -672,7 +672,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
672
672
  };
673
673
  // Recall injection room is capped separately from the mandatory
674
674
  // (system + preserved tail) budget: recall text never gets more than
675
- // opts.recallTokenCap (5% of context window, floor 2048 tokens, set by
675
+ // opts.recallTokenCap (10% of context window, floor 2048 tokens, set by
676
676
  // the caller); the remaining budget stays reserved for live
677
677
  // conversation. Only applied when the cap is a positive finite number,
678
678
  // preserving current uncapped behavior otherwise.
@@ -253,21 +253,43 @@ export function fitCompactionPrompt(input, targetTokens) {
253
253
  const buildReduced = (k) => {
254
254
  const kept = k > 0 ? head.slice(head.length - k) : [];
255
255
  const omitted = head.length - kept.length;
256
- const stubHead = omitted > 0
257
- ? [{ role: 'user', content: `[${omitted} older messages omitted]` }, ...kept]
258
- : kept;
259
- let inp = { ...baseNoFacts, head: stubHead };
260
- // Also shrink/drop a prior <previous-summary> (same as the normal fitAt
261
- // path) a large prior summary can keep the prompt over budget even at
262
- // K=0. fitPreviousSummaryForCompactionPrompt is a no-op when there is no
263
- // previousSummary, so this is safe for the summary-less case.
264
- if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
265
- const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
266
- if (!fitted) return null;
267
- inp = fitted;
268
- if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
269
- }
270
- return buildCompactionPrompt(inp, 0);
256
+ const finalize = (stubHead) => {
257
+ let inp = { ...baseNoFacts, head: stubHead };
258
+ // Also shrink/drop a prior <previous-summary> (same as the normal
259
+ // fitAt path) a large prior summary can keep the prompt over
260
+ // budget even at K=0. fitPreviousSummaryForCompactionPrompt is a
261
+ // no-op when there is no previousSummary, so this is safe for the
262
+ // summary-less case.
263
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
264
+ const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
265
+ if (!fitted) return null;
266
+ inp = fitted;
267
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
268
+ }
269
+ return buildCompactionPrompt(inp, 0);
270
+ };
271
+ if (omitted <= 0) return finalize(kept);
272
+ // The omitted head messages never reappear in the session afterward
273
+ // (the caller replaces the whole head with the produced summary), so
274
+ // a bare "[N older messages omitted]" stub used to discard their
275
+ // content with zero trace. Prefer a compact per-message digest line
276
+ // for each omitted message so at least a sliver of detail survives
277
+ // into the summary input; only fall back to the count-only stub if
278
+ // even the digest cannot fit the emergency budget, preserving the
279
+ // original guarantee that this reduction always finds a fit.
280
+ const digestLines = head.slice(0, omitted).map((m, i) => {
281
+ const role = m?.role || 'unknown';
282
+ const text = truncateMiddle(extractText(m).trim(), 30);
283
+ return text ? `${i + 1}. ${role}: ${text}` : `${i + 1}. ${role}`;
284
+ });
285
+ const digestStub = {
286
+ role: 'user',
287
+ content: [`[${omitted} older messages compacted to a digest below]`, ...digestLines].join('\n'),
288
+ };
289
+ const withDigest = finalize([digestStub, ...kept]);
290
+ if (withDigest) return withDigest;
291
+ const countStub = { role: 'user', content: `[${omitted} older messages omitted]` };
292
+ return finalize([countStub, ...kept]);
271
293
  };
272
294
  let lo = 0;
273
295
  let hi = head.length;
@@ -641,3 +641,58 @@ export function sanitizeAnthropicContentPairs(messages) {
641
641
  }
642
642
  return out;
643
643
  }
644
+
645
+ /**
646
+ * Fold a plain user text turn into the trailing tool_result block of the
647
+ * previous user message (Claude Code parity: utils/messages.ts
648
+ * mergeUserContentBlocks / smooshIntoToolResult). Any sibling text after a
649
+ * tool_result renders as `</function_results>\n\nHuman:<...>` on the
650
+ * Anthropic wire; repeated mid-conversation this teaches the model to emit
651
+ * 3-token empty end_turn completions (upstream A/B sai-20260310-161901:
652
+ * 92% → 0% after smooshing). Observed in mixdog as the empty-turn nudge
653
+ * livelock: each contract nudge was pushed as its own user turn right after
654
+ * a tool_result turn, reinforcing the empty-completion pattern.
655
+ *
656
+ * Returns true when the text was folded (caller must NOT push the message);
657
+ * false when the message must keep its own turn (no tool_result tail,
658
+ * tool_reference result, or non-text content such as images).
659
+ */
660
+ export function foldUserTextIntoToolResultTail(result, content) {
661
+ const last = result[result.length - 1];
662
+ if (last?.role !== 'user' || !Array.isArray(last.content) || last.content.length === 0) return false;
663
+ const tail = last.content[last.content.length - 1];
664
+ if (tail?.type !== 'tool_result') return false;
665
+ // tool_reference results must keep their exact shape — leave as sibling.
666
+ if (Array.isArray(tail.content) && tail.content.some((b) => b?.type === 'tool_reference')) return false;
667
+ // Only fold pure text (string or all-text blocks). Images/documents keep
668
+ // their own user turn.
669
+ let texts;
670
+ if (typeof content === 'string') {
671
+ texts = content.trim() ? [content.trim()] : [];
672
+ } else if (Array.isArray(content) && content.every((b) => b?.type === 'text' && typeof b.text === 'string')) {
673
+ texts = content.map((b) => b.text.trim()).filter(Boolean);
674
+ } else {
675
+ return false;
676
+ }
677
+ if (texts.length === 0) return true; // empty text turn — drop it entirely
678
+ const joined = texts.join('\n\n');
679
+ if (typeof tail.content === 'string') {
680
+ last.content[last.content.length - 1] = {
681
+ ...tail,
682
+ content: tail.content.trim() ? `${tail.content}\n\n${joined}` : joined,
683
+ };
684
+ return true;
685
+ }
686
+ if (Array.isArray(tail.content)) {
687
+ const blocks = tail.content.slice();
688
+ const prev = blocks[blocks.length - 1];
689
+ if (prev?.type === 'text' && typeof prev.text === 'string') {
690
+ blocks[blocks.length - 1] = { ...prev, text: `${prev.text}\n\n${joined}` };
691
+ } else {
692
+ blocks.push({ type: 'text', text: joined });
693
+ }
694
+ last.content[last.content.length - 1] = { ...tail, content: blocks };
695
+ return true;
696
+ }
697
+ return false;
698
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Cheap, bounded top-level-only scan for the `closed`/`generation`
3
+ * lifecycle fields in a serialized session JSON document — a substitute
4
+ * for JSON.parse on the session-store save-guard hot path.
5
+ *
6
+ * Why not JSON.parse: `generation` is a top-level field on every persisted
7
+ * session (added by _ensureLifecycleFields on first save), so a raw
8
+ * substring pre-check (`raw.includes('"generation"')`) is true for nearly
9
+ * every guarded write — paying full-parse-and-allocate cost (building the
10
+ * entire messages array, unescaping every string) on the hot path even
11
+ * though only two scalar fields are ever consulted.
12
+ *
13
+ * Why not a regex-only check: a stored message body can itself contain the
14
+ * literal text `{"closed":true}` (a tool result, a pasted JSON blob, an
15
+ * assistant-authored snippet) inside `messages[i].content`. A substring or
16
+ * naively-anchored regex cannot tell that occurrence apart from the real
17
+ * top-level lifecycle field, so it is spoofable. This scanner walks the raw
18
+ * text as a real (bracket-depth + string-escape aware) tokenizer, but only
19
+ * *interprets* key/value pairs at depth 1 (directly inside the root
20
+ * object); every nested object/array/string value — including the entire
21
+ * `messages` array — is skipped by depth counting alone, never allocated
22
+ * or parsed. That keeps it both spoof-proof (nested `"closed"` can never be
23
+ * mistaken for the top-level one) and cheap (no JS object/array/string
24
+ * allocation for content we don't care about).
25
+ *
26
+ * Returns `{ closed, generation }` (either key absent if the field wasn't
27
+ * present at depth 1) or `null` if `raw` is not a well-formed top-level
28
+ * JSON object (caller should treat that the same as a parse failure).
29
+ *
30
+ * Scope contract: this is not a general JSON validator. `raw` is always our
31
+ * own JSON.stringify output (or truncated/concatenated fragments thereof
32
+ * from partial writes) — never hand-authored or third-party JSON. It is
33
+ * only responsible for detecting the failure modes a writer like ours can
34
+ * actually produce: mid-write truncation, nested-field spoofing, mismatched
35
+ * brackets, and trailing garbage from a botched/partial write. It does NOT
36
+ * validate separator-level well-formedness (missing/trailing commas,
37
+ * missing colons beyond the one check above, etc.) since JSON.stringify
38
+ * cannot emit those — a well-formed prefix is assumed between structural
39
+ * checks. Anything the scanner doesn't positively confirm returns `null`,
40
+ * and the caller falls back to `JSON.parse` for the definitive answer.
41
+ */
42
+
43
+ function isWs(ch) {
44
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
45
+ }
46
+
47
+ // True iff raw[i..] is nothing but whitespace to EOF. Enforces the "single
48
+ // complete root JSON object" contract: trailing garbage after the root `}`
49
+ // (e.g. a concatenated second document, or corruption) must not be silently
50
+ // accepted as if `raw` were exactly one well-formed object.
51
+ function isTrailingWhitespaceOnly(raw, i) {
52
+ const len = raw.length;
53
+ while (i < len) {
54
+ if (!isWs(raw[i])) return false;
55
+ i++;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ // Advances past a JSON string literal starting at raw[i] === '"'.
61
+ // Escape handling only needs to skip exactly one char after each `\` —
62
+ // even for `\u0041`-style escapes, the trailing hex digits can never be
63
+ // `"` or `\`, so a naive 2-char skip never misses the real closing quote.
64
+ function skipString(raw, i) {
65
+ const len = raw.length;
66
+ i++; // opening quote
67
+ while (i < len) {
68
+ const ch = raw[i];
69
+ if (ch === '\\') { i += 2; continue; }
70
+ if (ch === '"') return i + 1;
71
+ i++;
72
+ }
73
+ return i;
74
+ }
75
+
76
+ // Advances past one JSON value (string/object/array/number/true/false/null)
77
+ // starting at raw[i]. Objects/arrays are skipped via universal bracket-depth
78
+ // counting (any `{`/`[` opens, any `}`/`]` closes) with string-awareness so
79
+ // braces inside string content never perturb the count — never descends
80
+ // into the structure to interpret its keys.
81
+ function skipValue(raw, i) {
82
+ const len = raw.length;
83
+ const c = raw[i];
84
+ if (c === '"') return skipString(raw, i);
85
+ if (c === '{' || c === '[') {
86
+ let depth = 1;
87
+ i++;
88
+ while (i < len && depth > 0) {
89
+ const ch = raw[i];
90
+ if (ch === '"') { i = skipString(raw, i); continue; }
91
+ if (ch === '{' || ch === '[') depth++;
92
+ else if (ch === '}' || ch === ']') depth--;
93
+ i++;
94
+ }
95
+ return i;
96
+ }
97
+ // number / true / false / null — run to the next structural delimiter.
98
+ while (i < len && raw[i] !== ',' && raw[i] !== '}' && raw[i] !== ']' && !isWs(raw[i])) i++;
99
+ return i;
100
+ }
101
+
102
+ export function scanTopLevelLifecycle(raw) {
103
+ const len = raw.length;
104
+ let i = 0;
105
+ while (i < len && isWs(raw[i])) i++;
106
+ if (raw[i] !== '{') return null;
107
+ i++;
108
+ const result = {};
109
+ let found = 0;
110
+ while (i < len) {
111
+ while (i < len && isWs(raw[i])) i++;
112
+ if (i >= len) return null;
113
+ if (raw[i] === '}') {
114
+ i++;
115
+ return isTrailingWhitespaceOnly(raw, i) ? result : null;
116
+ }
117
+ if (raw[i] === ',') { i++; continue; }
118
+ if (raw[i] !== '"') return null;
119
+ const keyStart = i;
120
+ i = skipString(raw, i);
121
+ let key;
122
+ try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return null; }
123
+ while (i < len && isWs(raw[i])) i++;
124
+ if (raw[i] !== ':') return null;
125
+ i++;
126
+ while (i < len && isWs(raw[i])) i++;
127
+ if (i >= len) return null;
128
+ if (key === 'closed' || key === 'generation') {
129
+ const valStart = i;
130
+ i = skipValue(raw, i);
131
+ try { result[key] = JSON.parse(raw.slice(valStart, i)); } catch { return null; }
132
+ found++;
133
+ // Both lifecycle fields resolved. We no longer need to interpret
134
+ // further keys, but we MUST still confirm the root object is not
135
+ // truncated (e.g. a session file cut off mid-write as
136
+ // `{"closed":true,"generation":1,"messages":[`) before trusting
137
+ // `result` — otherwise a truncated-but-field-bearing prefix would
138
+ // be treated as well-formed. Finish with a cheap depth-only walk
139
+ // (no key/value JSON.parse) to the matching close of the root
140
+ // object; only return `result` once that close is actually
141
+ // reached before EOF.
142
+ if (found === 2) {
143
+ // Track exact bracket types (not just depth) so a mismatched
144
+ // pair anywhere — including the root itself, e.g. malformed
145
+ // `{"closed":true,"generation":1]` closing the root object
146
+ // with `]` instead of `}` — is rejected rather than silently
147
+ // accepted because *a* bracket happened to bring the count to
148
+ // zero. `stack[0]` is always '}' (the root object we opened
149
+ // at function entry); the walk must consume exactly that to
150
+ // finish, and any closer that doesn't match the innermost
151
+ // opener is a structural error.
152
+ const stack = ['}'];
153
+ while (i < len) {
154
+ const ch = raw[i];
155
+ if (ch === '"') { i = skipString(raw, i); continue; }
156
+ if (ch === '{') { stack.push('}'); i++; continue; }
157
+ if (ch === '[') { stack.push(']'); i++; continue; }
158
+ if (ch === '}' || ch === ']') {
159
+ if (stack.pop() !== ch) return null; // mismatched bracket pair
160
+ i++;
161
+ if (stack.length === 0) {
162
+ // Root object closed with '}' — only trust it if
163
+ // nothing but whitespace follows to EOF.
164
+ return isTrailingWhitespaceOnly(raw, i) ? result : null;
165
+ }
166
+ continue;
167
+ }
168
+ i++;
169
+ }
170
+ return null; // ran off the end (or hit EOF) before the root object closed: truncated
171
+ }
172
+ } else {
173
+ i = skipValue(raw, i);
174
+ }
175
+ }
176
+ return null; // ran off the end without a closing '}': malformed/truncated
177
+ }
@@ -10,29 +10,29 @@ import {
10
10
  import {
11
11
  compactTypeIsRecallFastTrack,
12
12
  compactTypeIsSemantic,
13
- normalizeCompactType,
14
13
  DEFAULT_COMPACT_TYPE,
15
14
  DEFAULT_COMPACTION_KEEP_TOKENS,
16
15
  CONTEXT_SHARE_RATIO,
16
+ COMPACT_TYPE_RECALL_FASTTRACK,
17
17
  } from '../compact.mjs';
18
18
  import { positiveTokenInt, envFlag, envTokenInt } from './env.mjs';
19
19
  import { isAgentOwner } from '../../agent-owner.mjs';
20
20
 
21
21
  const COMPACT_SAFETY_PERCENT = 1.00;
22
22
  // Unified context-share rule (compact/constants.mjs CONTEXT_SHARE_RATIO): the
23
- // post-compaction target is 5% of the boundary/context window — the same 5%
23
+ // post-compaction target is 10% of the boundary/context window — the same 10%
24
24
  // the recall-fasttrack injection cap uses (loop.mjs recallTokenCap). One
25
25
  // number governs every "share of model context" budget.
26
26
  const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
27
27
  const COMPACT_TARGET_MIN_TOKENS = 4_000;
28
- const COMPACT_TARGET_MAX_TOKENS = 16_000;
29
28
 
30
29
  function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
31
- if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
32
- if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
33
- if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
34
- // The compact type is already explicit (`semantic` by default). Honor it
35
- // directly instead of substituting another compaction path.
30
+ // Types are hard-locked (agent -> semantic, main/user -> recall-fasttrack).
31
+ // Semantic must always be available as a compact path or agent sessions
32
+ // would have none (loop.mjs throws when no type is available). Env/cfg
33
+ // off-switches no longer apply.
34
+ void sessionRef;
35
+ void cfg;
36
36
  return true;
37
37
  }
38
38
 
@@ -43,12 +43,9 @@ function resolveCompactTypeSetting(sessionRef, cfg = {}) {
43
43
  // fasttrack compact would inject unrelated main-session memories and drop
44
44
  // the agent's own working context. Env/config overrides do not apply.
45
45
  if (isAgentOwner(sessionRef)) return DEFAULT_COMPACT_TYPE;
46
- const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
47
- ?? process.env.MIXDOG_COMPACT_TYPE
48
- ?? cfg.type
49
- ?? cfg.compactType
50
- ?? cfg.compact_type;
51
- return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
46
+ // Non-agent (main/user) sessions are ALWAYS recall-fasttrack. Hard-locked:
47
+ // config/env overrides no longer change the type.
48
+ return COMPACT_TYPE_RECALL_FASTTRACK;
52
49
  }
53
50
 
54
51
  function resolveCompactTargetRatio(cfg = {}) {
@@ -74,12 +71,8 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
74
71
  || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
75
72
  || envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
76
73
  || COMPACT_TARGET_MIN_TOKENS);
77
- const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
78
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
79
- || envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
80
- || COMPACT_TARGET_MAX_TOKENS);
81
74
  const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
82
- return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
75
+ return Math.max(1, Math.min(boundary, Math.max(minTarget, byRatio)));
83
76
  }
84
77
  function resolveCompactKeepTokens(cfg = {}) {
85
78
  return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
@@ -10,6 +10,8 @@ import { createHash } from 'crypto';
10
10
  import { executeInternalTool } from '../../internal-tools.mjs';
11
11
  import {
12
12
  recallFastTrackCompactMessages,
13
+ CONTEXT_SHARE_RATIO,
14
+ RECALL_TOKEN_CAP_FLOOR_TOKENS,
13
15
  } from '../compact.mjs';
14
16
  import {
15
17
  compactDiagnosticError,
@@ -146,6 +148,13 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
146
148
  const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
147
149
  diagnostics.finalRecallChars = digestText.length;
148
150
  diagnostics.finalRecallBytes = compactByteLength(digestText);
151
+ const contextWindow = positiveTokenInt(compactPolicy?.contextWindow)
152
+ || positiveTokenInt(compactPolicy?.boundaryTokens)
153
+ || positiveTokenInt(sessionRef?.contextWindow)
154
+ || positiveTokenInt(sessionRef?.compactBoundaryTokens);
155
+ const recallTokenCap = contextWindow
156
+ ? Math.max(RECALL_TOKEN_CAP_FLOOR_TOKENS, Math.floor(contextWindow * CONTEXT_SHARE_RATIO))
157
+ : null;
149
158
  const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
150
159
  reserveTokens: compactPolicy.reserveTokens,
151
160
  force: true,
@@ -156,6 +165,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
156
165
  tailTurns: compactPolicy.tailTurns,
157
166
  keepTokens: compactPolicy.keepTokens,
158
167
  preserveRecentTokens: compactPolicy.preserveRecentTokens,
168
+ recallTokenCap,
159
169
  });
160
170
  diagnostics.totalMs = Date.now() - startedAt;
161
171
  if (result && typeof result === 'object') {
@@ -243,6 +243,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
243
243
  let _capFinalTurnUsed = false;
244
244
  // True while the granted hard-cap final turn is active (no tool defs).
245
245
  let _capFinalToolsDisabled = false;
246
+ // Consecutive empty-turn contract nudges. A model that answers the same
247
+ // nudge with another empty turn is in a deterministic livelock (same
248
+ // context in → same empty completion out); re-sending an identical nudge
249
+ // 199× just burns the iteration budget (observed: sess_10400…9dfdc436,
250
+ // 199 identical nudges to the 200-iteration cap). Cap the streak and end
251
+ // the loop as an explicit empty termination instead.
252
+ let _emptyNudgeStreak = 0;
253
+ const EMPTY_NUDGE_MAX = 3;
246
254
  // Completion-first steering ladder controller. Owns the (cumulative) level-1
247
255
  // fire count, the all-read-only / serial-single / same-file-grep streaks,
248
256
  // and the level-2 latch. Threaded via live getters so it reads the loop's
@@ -1158,17 +1166,30 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1158
1166
  continue;
1159
1167
  }
1160
1168
  if (!hasContent && !isHidden) {
1169
+ _emptyNudgeStreak += 1;
1170
+ if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
1171
+ // Livelock: identical nudges keep producing identical empty
1172
+ // completions. Stop re-prompting; classifyTerminationReason
1173
+ // tags this final empty response as 'empty' so the caller
1174
+ // surfaces an explicit error instead of a silent finish.
1175
+ process.stderr.write(`[loop] empty-turn nudge cap ${EMPTY_NUDGE_MAX} reached (sess=${sessionId || 'unknown'}); ending loop as empty termination.\n`);
1176
+ break;
1177
+ }
1161
1178
  let nudgeMsg;
1162
1179
  if (isIncompleteStop) {
1163
1180
  nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
1164
1181
  } else {
1165
- nudgeMsg = '[mixdog-runtime] Your previous response was empty (no handoff text and no tool call). Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.';
1182
+ // Vary the nudge per attempt a byte-identical repeat
1183
+ // reinforces the empty-completion pattern it is trying to
1184
+ // break (the request context stays effectively constant).
1185
+ nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
1166
1186
  }
1167
1187
  messages.push({ role: 'user', content: nudgeMsg });
1168
1188
  continue;
1169
1189
  }
1170
1190
  break;
1171
1191
  }
1192
+ _emptyNudgeStreak = 0;
1172
1193
  const calls = response.toolCalls;
1173
1194
  toolCallsTotal += calls.length;
1174
1195
  // Surface any mid-turn assistant text (preamble that precedes a tool
@@ -12,6 +12,8 @@ import {
12
12
  effectiveBudget as compactEffectiveBudget,
13
13
  compactTypeIsRecallFastTrack,
14
14
  compactTypeIsSemantic,
15
+ CONTEXT_SHARE_RATIO,
16
+ RECALL_TOKEN_CAP_FLOOR_TOKENS,
15
17
  } from '../compact.mjs';
16
18
  import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactBufferRatio } from '../context-utils.mjs';
17
19
  import { executeInternalTool } from '../../internal-tools.mjs';
@@ -195,6 +197,11 @@ export async function runSessionCompaction(session, opts = {}) {
195
197
  if (compactTypeIsRecallFastTrack(compactType)) {
196
198
  try {
197
199
  const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
200
+ const contextWindow = positiveContextWindow(session.contextWindow) || boundary;
201
+ const recallTokenCap = Math.max(
202
+ RECALL_TOKEN_CAP_FLOOR_TOKENS,
203
+ Math.floor(contextWindow * CONTEXT_SHARE_RATIO),
204
+ );
198
205
  recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
199
206
  reserveTokens,
200
207
  force: true,
@@ -209,6 +216,7 @@ export async function runSessionCompaction(session, opts = {}) {
209
216
  tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
210
217
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
211
218
  preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
219
+ recallTokenCap,
212
220
  });
213
221
  if (Array.isArray(recallFastTrackResult?.messages)) {
214
222
  compacted = recallFastTrackResult.messages;