mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -461,7 +461,7 @@ export class AnthropicOAuthProvider {
461
461
  const fresh = loadCredentials();
462
462
  if (fresh?.accessToken) {
463
463
  this.credentials = fresh;
464
- process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk (mtime change)\n`);
464
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk (mtime change)\n`);
465
465
  }
466
466
  }
467
467
 
@@ -493,7 +493,7 @@ export class AnthropicOAuthProvider {
493
493
  if (disk?.accessToken && disk.accessToken !== currentToken
494
494
  && (!disk.expiresAt || disk.expiresAt >= validAfter)) {
495
495
  this.credentials = disk;
496
- process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk\n`);
496
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk\n`);
497
497
  return disk;
498
498
  }
499
499
  if (!this.credentials && disk) this.credentials = disk;
@@ -510,13 +510,13 @@ export class AnthropicOAuthProvider {
510
510
  const latestValidAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
511
511
  if (latest?.accessToken && latest.accessToken !== currentToken
512
512
  && (!latest.expiresAt || latest.expiresAt >= latestValidAfter)) {
513
- process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk\n`);
513
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Credentials reloaded from disk\n`);
514
514
  return latest;
515
515
  }
516
516
 
517
517
  if (!latest?.refreshToken) {
518
518
  if (!force && latest?.accessToken && (!latest.expiresAt || latest.expiresAt > Date.now())) {
519
- process.stderr.write(`[anthropic-oauth] WARNING: token expiring but no refresh token; using current token until expiry\n`);
519
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] WARNING: token expiring but no refresh token; using current token until expiry\n`);
520
520
  return latest;
521
521
  }
522
522
  throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
@@ -525,12 +525,12 @@ export class AnthropicOAuthProvider {
525
525
  try {
526
526
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Token ${reason}, refreshing...\n`);
527
527
  const refreshed = await refreshOAuthCredentials(latest);
528
- process.stderr.write(`[anthropic-oauth] Token refreshed, expires in ${Math.round(((refreshed.expiresAt || Date.now()) - Date.now()) / 1000)}s\n`);
528
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Token refreshed, expires in ${Math.round(((refreshed.expiresAt || Date.now()) - Date.now()) / 1000)}s\n`);
529
529
  return refreshed;
530
530
  } catch (err) {
531
531
  if (!force && latest?.accessToken && (!latest.expiresAt || latest.expiresAt > Date.now())) {
532
532
  const msg = err instanceof Error ? err.message : String(err);
533
- process.stderr.write(`[anthropic-oauth] Refresh failed (${msg}); using still-valid current token\n`);
533
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] Refresh failed (${msg}); using still-valid current token\n`);
534
534
  return latest;
535
535
  }
536
536
  throw err;
@@ -27,6 +27,17 @@ function geminiInlineInfo(block) {
27
27
  return { data, mimeType: cleanMimeType(inline.mimeType || inline.mime_type || inline.mediaType || inline.media_type) };
28
28
  }
29
29
 
30
+ // Inline document/file part ({ type: 'file', data, mimeType, filename? }) as
31
+ // submitted by the desktop composer (PDF attachments). Providers receive it
32
+ // as their native document shape; estimators must never serialize its base64.
33
+ function fileInfo(block) {
34
+ if (!block || typeof block !== 'object' || block.type !== 'file') return null;
35
+ if (typeof block.data !== 'string' || !block.data) return null;
36
+ const mimeType = String(block.mimeType || block.mediaType || 'application/pdf').trim().toLowerCase() || 'application/pdf';
37
+ const filename = typeof block.filename === 'string' && block.filename ? block.filename : '';
38
+ return { data: block.data, mimeType, filename };
39
+ }
40
+
30
41
  function imageUrlFromPart(block) {
31
42
  if (!block || typeof block !== 'object') return null;
32
43
  if (block.type === 'image_url') {
@@ -100,7 +111,8 @@ function contentParts(content) {
100
111
  }
101
112
  if (content && typeof content === 'object'
102
113
  && (imageUrlFromPart(content) || imageFileIdFromPart(content)
103
- || imageFileUriFromPart(content) || geminiInlineInfo(content))) return [content];
114
+ || imageFileUriFromPart(content) || geminiInlineInfo(content)
115
+ || fileInfo(content))) return [content];
104
116
  return null;
105
117
  }
106
118
 
@@ -108,7 +120,7 @@ function jsonFallbackFromPart(block) {
108
120
  const text = textFromPart(block);
109
121
  if (text) return text;
110
122
  if (!block || typeof block !== 'object') return block == null ? '' : String(block);
111
- if (imageUrlFromPart(block) || imageFileIdFromPart(block) || imageFileUriFromPart(block) || geminiInlineInfo(block)) return '';
123
+ if (imageUrlFromPart(block) || imageFileIdFromPart(block) || imageFileUriFromPart(block) || geminiInlineInfo(block) || fileInfo(block)) return '';
112
124
  return stringifyFallback(block);
113
125
  }
114
126
 
@@ -170,6 +182,17 @@ export function contentImageDescriptors(content) {
170
182
  return parts.map(imageDescriptor).filter(Boolean);
171
183
  }
172
184
 
185
+ // Byte-free descriptors for inline file/document parts (context estimation).
186
+ export function contentFileDescriptors(content) {
187
+ const parts = contentParts(content);
188
+ if (!parts) return [];
189
+ return parts.flatMap((part) => {
190
+ const file = fileInfo(part);
191
+ if (!file) return [];
192
+ return [{ mimeType: file.mimeType, sizeBytes: Math.floor((file.data.length * 3) / 4) }];
193
+ });
194
+ }
195
+
173
196
  export function contentToText(content, fallback = '') {
174
197
  if (typeof content === 'string') return content;
175
198
  const parts = contentParts(content);
@@ -192,6 +215,10 @@ function sanitizePartForStoredHistory(part) {
192
215
  if (part.type === 'image' || part.type === 'image_url' || part.type === 'input_image' || imageUrlFromPart(part) || imageFileIdFromPart(part) || imageFileUriFromPart(part) || geminiInlineInfo(part)) {
193
216
  return { type: 'text', text: storedHistoryImagePlaceholder(part) };
194
217
  }
218
+ const file = fileInfo(part);
219
+ if (file) {
220
+ return { type: 'text', text: `[File omitted from stored history: ${file.filename || file.mimeType}]` };
221
+ }
195
222
  if (Array.isArray(part.content)) {
196
223
  const nextContent = sanitizeContentForStoredHistory(part.content);
197
224
  if (nextContent !== part.content) return { ...part, content: nextContent };
@@ -217,6 +244,16 @@ export function normalizeContentForAnthropic(content) {
217
244
  const parts = contentParts(content);
218
245
  if (!parts) return content;
219
246
  return parts.map((part) => {
247
+ const file = fileInfo(part);
248
+ if (file) {
249
+ const out = {
250
+ type: 'document',
251
+ source: { type: 'base64', media_type: file.mimeType, data: file.data },
252
+ ...(file.filename ? { title: file.filename } : {}),
253
+ };
254
+ if (part.cache_control) out.cache_control = part.cache_control;
255
+ return out;
256
+ }
220
257
  const info = imageInfo(part);
221
258
  if (info) {
222
259
  const out = {
@@ -276,6 +313,14 @@ export function normalizeContentForOpenAIChat(content, { role = 'user' } = {}) {
276
313
  if (!parts) return content;
277
314
  const out = [];
278
315
  for (const part of parts) {
316
+ const file = fileInfo(part);
317
+ if (file) {
318
+ out.push({
319
+ type: 'file',
320
+ file: { filename: file.filename || 'document.pdf', file_data: `data:${file.mimeType};base64,${file.data}` },
321
+ });
322
+ continue;
323
+ }
279
324
  const fileId = imageFileIdFromPart(part);
280
325
  if (fileId) {
281
326
  out.push({ type: 'text', text: `[unsupported image file_id for OpenAI Chat-compatible request: ${fileId}]` });
@@ -308,6 +353,15 @@ export function normalizeContentForOpenAIResponses(content, { role = 'user' } =
308
353
  }
309
354
  const out = [];
310
355
  for (const part of parts) {
356
+ const file = fileInfo(part);
357
+ if (file) {
358
+ out.push({
359
+ type: 'input_file',
360
+ filename: file.filename || 'document.pdf',
361
+ file_data: `data:${file.mimeType};base64,${file.data}`,
362
+ });
363
+ continue;
364
+ }
311
365
  const fileId = imageFileIdFromPart(part);
312
366
  if (fileId) {
313
367
  out.push({ type: 'input_image', file_id: fileId });
@@ -338,6 +392,11 @@ export function normalizeContentForGeminiParts(content) {
338
392
  }
339
393
  const out = [];
340
394
  for (const part of parts) {
395
+ const file = fileInfo(part);
396
+ if (file) {
397
+ out.push({ inlineData: { mimeType: file.mimeType, data: file.data } });
398
+ continue;
399
+ }
341
400
  const inlineInfo = geminiInlineInfo(part);
342
401
  if (inlineInfo) {
343
402
  out.push({ inlineData: { mimeType: inlineInfo.mimeType, data: inlineInfo.data } });
@@ -144,6 +144,15 @@ const MAX_OUTPUT_EXHAUSTED_NOTICE = '[mixdog-runtime] Output remained truncated
144
144
  // was not done — re-prompt instead of accepting empty as final.
145
145
  // Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
146
146
  // (MAX_TOKENS, OTHER), and case variants.
147
+ export function attachAssistantTranscriptMetadata(message, opts = {}) {
148
+ const transcript = typeof opts.takeAssistantTranscriptMetadata === 'function'
149
+ ? opts.takeAssistantTranscriptMetadata()
150
+ : null;
151
+ if (!transcript) return message;
152
+ const meta = message?.meta && typeof message.meta === 'object' ? message.meta : {};
153
+ return { ...message, meta: { ...meta, transcript } };
154
+ }
155
+
147
156
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
148
157
  let iterations = 0;
149
158
  let toolCallsTotal = 0;
@@ -253,7 +262,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
253
262
  ? resp.providerMetadata
254
263
  : null;
255
264
  if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks) return false;
256
- const message = {
265
+ const message = attachAssistantTranscriptMetadata({
257
266
  role: 'assistant',
258
267
  content,
259
268
  // Anthropic adaptive-thinking signatures must be replayed verbatim
@@ -262,7 +271,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
262
271
  ...(reasoningItems ? { reasoningItems } : {}),
263
272
  ...(reasoningContent ? { reasoningContent } : {}),
264
273
  ...(providerMetadata ? { providerMetadata } : {}),
265
- };
274
+ }, opts);
266
275
  messages.push(message);
267
276
  try { opts.onAssistantMessageCommitted?.(message); } catch {}
268
277
  return true;
@@ -781,7 +790,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
781
790
  // openai-oauth.convertMessagesToResponsesInput emits matching
782
791
  // type:'reasoning' input items on the next turn to keep the openai-oauth
783
792
  // server-side cache prefix stable.
784
- const _assistantTurnMsg = {
793
+ const _assistantTurnMsg = attachAssistantTranscriptMetadata({
785
794
  role: 'assistant',
786
795
  // Sub-agent tool-call turns carry only mid-turn preamble in
787
796
  // response.content (the real result rides the later final-answer
@@ -805,7 +814,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
805
814
  ...(response.providerMetadata && typeof response.providerMetadata === 'object'
806
815
  ? { providerMetadata: response.providerMetadata }
807
816
  : {}),
808
- };
817
+ }, opts);
809
818
  messages.push(_assistantTurnMsg);
810
819
  try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
811
820
  // Hard-cap final turn: tools are disabled but the model still emitted
@@ -48,6 +48,37 @@ import {
48
48
  RECALL_TAIL_TRUNCATION_MARKER,
49
49
  RECALL_TAIL_SHORT_TRUNCATION_MARKER,
50
50
  } from './summary.mjs';
51
+ import { buildPostCompactFileAttachment } from './file-reattach.mjs';
52
+
53
+ // Post-compact file re-attachment (claude-code parity): re-inject fresh reads
54
+ // of files the summarized-away head was working with, when they still fit the
55
+ // budget. Applied identically by the semantic and recall-fasttrack paths.
56
+ // Follows the `Reference files:` + assistant `.` ack convention from
57
+ // session-lifecycle.mjs so provider turn alternation and ingest exclusion
58
+ // both hold. Best-effort: on any failure the plain result stands.
59
+ function withFileReattachment(result, finalTokens, budget, headMessages, tailMessages, cwd) {
60
+ try {
61
+ const attachment = buildPostCompactFileAttachment(
62
+ headMessages,
63
+ tailMessages,
64
+ budget - finalTokens,
65
+ { cwd },
66
+ );
67
+ if (!attachment) return { result, finalTokens, reattached: false };
68
+ const summaryIdx = result.length - tailMessages.length;
69
+ const augmented = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([
70
+ ...result.slice(0, summaryIdx),
71
+ attachment,
72
+ { role: 'assistant', content: '.' },
73
+ ...result.slice(summaryIdx),
74
+ ])));
75
+ const augmentedTokens = estimateMessagesTokens(augmented);
76
+ if (augmentedTokens > budget) return { result, finalTokens, reattached: false };
77
+ return { result: augmented, finalTokens: augmentedTokens, reattached: true };
78
+ } catch {
79
+ return { result, finalTokens, reattached: false };
80
+ }
81
+ }
51
82
 
52
83
  const DEFAULT_TAIL_TURNS = 2;
53
84
  const COMPACTION_PROMPT_HEADROOM = 0.85;
@@ -433,10 +464,15 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
433
464
  // is both measured and emitted in redacted form.
434
465
  let result = sanitizeToolPairs([...selected.system, summaryMessage, ...selected.tail]);
435
466
  result = reconcileDedupStubs(dedupToolResultBodies(result));
436
- const finalTokens = estimateMessagesTokens(result);
467
+ let finalTokens = estimateMessagesTokens(result);
437
468
  if (finalTokens > budget) {
438
469
  throw new Error(`semanticCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
439
470
  }
471
+ // Re-attach fresh reads of head files. Uses the RAW (pre-ingest-filter)
472
+ // head so tool_call read paths are visible even on the /compact path.
473
+ const reattach = withFileReattachment(result, finalTokens, budget, selectedRaw.head, selected.tail, opts.cwd);
474
+ result = reattach.result;
475
+ finalTokens = reattach.finalTokens;
440
476
  const diagnostics = {
441
477
  noOp: false,
442
478
  inputMessages: Array.isArray(messages) ? messages.length : 0,
@@ -471,6 +507,7 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
471
507
  summaryChars: String(summary || '').length,
472
508
  rawSummaryChars: String(rawSummary || '').length,
473
509
  summaryRepaired: enforced.repaired === true,
510
+ fileReattached: reattach.reattached,
474
511
  previousSummary: !!selected.previousSummary,
475
512
  durationMs: Date.now() - startedAt,
476
513
  };
@@ -767,10 +804,13 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
767
804
 
768
805
  let result = sanitizeToolPairs([...safeSystem, summaryMessage, ...recallTail]);
769
806
  result = reconcileDedupStubs(dedupToolResultBodies(result));
770
- const finalTokens = estimateMessagesTokens(result);
807
+ let finalTokens = estimateMessagesTokens(result);
771
808
  if (finalTokens > budget) {
772
809
  throw new Error(`recallFastTrackCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
773
810
  }
811
+ const reattach = withFileReattachment(result, finalTokens, budget, recallHead, recallTail, opts.cwd);
812
+ result = reattach.result;
813
+ finalTokens = reattach.finalTokens;
774
814
  const summaryContent = String(summaryMessage?.content || '');
775
815
  const diagnostics = {
776
816
  noOp: false,
@@ -807,6 +847,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
807
847
  recallTruncatedInSummary: !!recallFit.recall && !summaryContent.includes(recallFit.recall),
808
848
  priorTruncatedInSummary: !!recallFit.prior && !summaryContent.includes(recallFit.prior),
809
849
  tailTruncated: recallTail.some((m) => messageContentHasMarker(m, RECALL_TAIL_TRUNCATION_MARKER) || messageContentHasMarker(m, RECALL_TAIL_SHORT_TRUNCATION_MARKER)),
850
+ fileReattached: reattach.reattached,
810
851
  tailOptions: recallTailOpts,
811
852
  previousSummary: !!previousSummary,
812
853
  durationMs: Date.now() - startedAt,
@@ -0,0 +1,112 @@
1
+ // Post-compact file re-attachment (claude-code createPostCompactFileAttachments
2
+ // parity). Files the summarized-away head had `read` are re-read FRESH from
3
+ // disk and re-injected right after the summary message, so the model does not
4
+ // burn a turn (and tokens) re-reading files it was actively working with.
5
+ // - newest-first, capped at MAX_REATTACH_FILES and per-file/total token caps
6
+ // - files whose `read` tool_call survives in the preserved tail are skipped
7
+ // (the model can already see that content — re-injecting is pure waste)
8
+ // - message shape reuses the existing `Reference files:` user-row convention
9
+ // (session-lifecycle.mjs), which memory ingest already excludes.
10
+ // Best-effort: any fs/parse failure skips the file, never fails the compact.
11
+ import { readFileSync, statSync } from 'node:fs';
12
+ import { isAbsolute, resolve as resolvePath } from 'node:path';
13
+ import { estimateTokens } from '../context-utils.mjs';
14
+
15
+ export const MAX_REATTACH_FILES = 3;
16
+ export const REATTACH_MAX_TOKENS_PER_FILE = 5_000;
17
+ export const REATTACH_MAX_TOTAL_TOKENS = 8_000;
18
+ const REATTACH_MIN_ROOM_TOKENS = 1_024;
19
+ const REATTACH_MAX_FILE_BYTES = 512 * 1024;
20
+
21
+ function reattachDisabled() {
22
+ return String(process.env.MIXDOG_COMPACT_FILE_REATTACH || '').trim() === '0';
23
+ }
24
+
25
+ function pathsFromReadArgs(rawArgs) {
26
+ let args = rawArgs;
27
+ if (typeof args === 'string') {
28
+ try { args = JSON.parse(args); } catch { return []; }
29
+ }
30
+ if (!args || typeof args !== 'object') return [];
31
+ const value = args.path;
32
+ const entries = Array.isArray(value) ? value : [value];
33
+ const out = [];
34
+ for (const entry of entries) {
35
+ if (typeof entry === 'string' && entry.trim()) out.push(entry.trim());
36
+ else if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path.trim()) {
37
+ out.push(entry.path.trim());
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // Ordered (oldest -> newest) file paths read via the `read` tool in `messages`.
44
+ export function collectReadToolPaths(messages) {
45
+ const out = [];
46
+ for (const m of Array.isArray(messages) ? messages : []) {
47
+ if (m?.role !== 'assistant' || !Array.isArray(m.toolCalls)) continue;
48
+ for (const tc of m.toolCalls) {
49
+ const name = String(tc?.name || tc?.function?.name || '').toLowerCase();
50
+ if (name !== 'read') continue;
51
+ out.push(...pathsFromReadArgs(tc?.arguments ?? tc?.function?.arguments));
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function truncateToTokenCap(content, cap) {
58
+ if (estimateTokens(content) <= cap) return content;
59
+ const marker = '\n[truncated after compaction — Read the full file if needed]';
60
+ // ~4 chars/token head-keep; iterate down until the estimate fits.
61
+ let chars = Math.max(0, cap * 4);
62
+ let out = content.slice(0, chars) + marker;
63
+ while (chars > 256 && estimateTokens(out) > cap) {
64
+ chars = Math.floor(chars * 0.8);
65
+ out = content.slice(0, chars) + marker;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Build ONE `Reference files:` user message re-attaching the freshest files the
72
+ * compacted-away head had read, or null when nothing qualifies/fits.
73
+ * `roomTokens` is the remaining post-compact budget the attachment may use.
74
+ */
75
+ export function buildPostCompactFileAttachment(headMessages, tailMessages, roomTokens, { cwd } = {}) {
76
+ if (reattachDisabled()) return null;
77
+ const room = Math.min(Number(roomTokens) || 0, REATTACH_MAX_TOTAL_TOKENS);
78
+ if (room < REATTACH_MIN_ROOM_TOKENS) return null;
79
+ const headPaths = collectReadToolPaths(headMessages);
80
+ if (headPaths.length === 0) return null;
81
+ const tailPaths = new Set(collectReadToolPaths(tailMessages));
82
+ // newest-first unique selection
83
+ const selected = [];
84
+ const seen = new Set();
85
+ for (let i = headPaths.length - 1; i >= 0 && selected.length < MAX_REATTACH_FILES; i -= 1) {
86
+ const p = headPaths[i];
87
+ if (seen.has(p) || tailPaths.has(p)) continue;
88
+ seen.add(p);
89
+ selected.push(p);
90
+ }
91
+ if (selected.length === 0) return null;
92
+ const sections = [];
93
+ const prefix = 'Reference files:\n\nRe-attached after compaction (fresh reads of files the summarized history was working with):\n\n';
94
+ for (const p of selected) {
95
+ try {
96
+ const abs = isAbsolute(p) ? p : (cwd ? resolvePath(cwd, p) : null);
97
+ if (!abs) continue;
98
+ const stat = statSync(abs);
99
+ if (!stat.isFile() || stat.size > REATTACH_MAX_FILE_BYTES) continue;
100
+ const body = truncateToTokenCap(readFileSync(abs, 'utf8'), REATTACH_MAX_TOKENS_PER_FILE);
101
+ const section = `### ${p}\n\`\`\`\n${body}\n\`\`\``;
102
+ const candidate = `${prefix}${[...sections, section].join('\n\n')}`;
103
+ if (estimateTokens(candidate) > room) continue;
104
+ sections.push(section);
105
+ } catch { /* unreadable/missing file — skip, never fail the compact */ }
106
+ }
107
+ if (sections.length === 0) return null;
108
+ return {
109
+ role: 'user',
110
+ content: `${prefix}${sections.join('\n\n')}`,
111
+ };
112
+ }