erosolar-cli 1.7.412 → 1.7.413

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.
@@ -6,6 +6,7 @@ import { join } from 'node:path';
6
6
  import { display } from '../ui/display.js';
7
7
  import { isPlainOutputMode } from '../ui/outputMode.js';
8
8
  import { theme } from '../ui/theme.js';
9
+ import { renderDivider } from '../ui/unified/layout.js';
9
10
  import { StreamingResponseFormatter } from '../ui/streamingFormatter.js';
10
11
  import { getContextWindowTokens } from '../core/contextWindow.js';
11
12
  import { ensureSecretForProvider, getSecretDefinitionForProvider, getSecretValue, listSecretDefinitions, maskSecret, setSecretValue, } from '../core/secretStore.js';
@@ -71,7 +72,7 @@ const BASE_SLASH_COMMANDS = getSlashCommands().map((cmd) => ({
71
72
  // Load PROVIDER_LABELS from centralized schema
72
73
  const PROVIDER_LABELS = Object.fromEntries(getProviders().map((provider) => [provider.id, provider.label]));
73
74
  // Allow enough time for paste detection to kick in before flushing buffered lines
74
- const CONTEXT_USAGE_THRESHOLD = 0.9;
75
+ const CONTEXT_USAGE_THRESHOLD = 0.97;
75
76
  const CONTEXT_AUTOCOMPACT_PERCENT = Math.round(CONTEXT_USAGE_THRESHOLD * 100);
76
77
  const CONTEXT_AUTOCOMPACT_FLOOR = 0.6;
77
78
  const MIN_COMPACTION_TOKEN_SAVINGS = 200;
@@ -79,12 +80,11 @@ const MIN_COMPACTION_PERCENT_SAVINGS = 0.5;
79
80
  const CONTEXT_RECENT_MESSAGE_COUNT = 12;
80
81
  const CONTEXT_CLEANUP_CHARS_PER_CHUNK = 6000;
81
82
  const CONTEXT_CLEANUP_MAX_OUTPUT_TOKENS = 800;
82
- const CONTEXT_CLEANUP_SYSTEM_PROMPT = `You condense earlier IDE collaboration logs so the agent can keep working.
83
- - Merge any prior summary with the new conversation chunk.
84
- - Capture key decisions, TODOs, file edits, tool observations, and open questions.
85
- - Clearly distinguish resolved work from outstanding follow-ups.
86
- - Keep the response under roughly 200 words, prefer short bullet lists.
87
- - Never call tools or run shell commands; respond with plain Markdown text only.`;
83
+ const CONTEXT_CLEANUP_SYSTEM_PROMPT = `Summarize earlier IDE collaboration so the agent can keep working.
84
+ - Merge any prior summary with the new chunk.
85
+ - Capture decisions, file edits/paths, tool findings, and open questions.
86
+ - Separate finished work from follow-ups; keep it under ~180 words with tight bullets.
87
+ - Respond in plain Markdown only (no tool or shell calls).`;
88
88
  export class InteractiveShell {
89
89
  agent = null;
90
90
  profile;
@@ -124,6 +124,7 @@ export class InteractiveShell {
124
124
  latestTokenUsage = { used: null, limit: null };
125
125
  planApprovalBridgeRegistered = false;
126
126
  contextCompactionInFlight = false;
127
+ contextCompactionLog = [];
127
128
  lastContextWarningLevel = null;
128
129
  sessionPreferences;
129
130
  autosaveEnabled;
@@ -163,11 +164,14 @@ export class InteractiveShell {
163
164
  streamingHeartbeatFrame = 0;
164
165
  streamingStatusLabel = null;
165
166
  lastStreamingElapsedSeconds = null; // Preserve final elapsed time
167
+ aiRuntimeStart = null;
168
+ aiRuntimeTotalMs = 0;
166
169
  streamingFormatter = null;
167
170
  statusLineState = null;
168
171
  statusMessageOverride = null;
169
172
  promptRefreshTimer = null;
170
173
  launchPaletteShown = false;
174
+ launchBannerText = null;
171
175
  version;
172
176
  alternateScreenEnabled;
173
177
  constructor(config) {
@@ -373,7 +377,30 @@ export class InteractiveShell {
373
377
  display.showInfo(this.sessionResumeNotice);
374
378
  this.sessionResumeNotice = null;
375
379
  }
380
+ renderWelcomeBanner(force = false) {
381
+ if (isPlainOutputMode() || !output.isTTY) {
382
+ return;
383
+ }
384
+ const header = `${theme.fields.model(this.sessionState.model)} ${theme.ui.muted('@')} ${theme.fields.agent(this.providerLabel(this.sessionState.provider))}`;
385
+ const profileLine = `${theme.fields.profile(this.profileLabel)} ${theme.ui.muted(`(${this.profile})`)}`;
386
+ const workspaceLine = theme.fields.workspace(this.workingDir);
387
+ const versionLine = this.version ? theme.ui.muted(`v${this.version} · support@ero.solar`) : null;
388
+ const hintLine = theme.ui.muted('/help for commands · /model to switch · /secrets for keys');
389
+ const width = output.columns ?? 80;
390
+ const banner = [header, profileLine, workspaceLine, versionLine, hintLine, renderDivider(width)]
391
+ .filter(Boolean)
392
+ .join('\n');
393
+ if (!force && this.launchBannerText) {
394
+ return;
395
+ }
396
+ if (this.launchBannerText === banner) {
397
+ return;
398
+ }
399
+ display.stream(`${banner}\n`);
400
+ this.launchBannerText = banner;
401
+ }
376
402
  async start(initialPrompt) {
403
+ this.renderWelcomeBanner();
377
404
  if (initialPrompt) {
378
405
  await this.processInputBlock(initialPrompt);
379
406
  return;
@@ -583,26 +610,35 @@ export class InteractiveShell {
583
610
  }
584
611
  // Trigger context compaction
585
612
  display.showInfo('Compacting context... This will summarize the conversation and free up space.');
586
- void this.performContextCompaction();
613
+ void this.performContextCompaction('Manual shortcut compaction');
587
614
  }
588
615
  /**
589
616
  * Perform context compaction by summarizing conversation history.
590
617
  */
591
- async performContextCompaction() {
618
+ async performContextCompaction(reason = 'Manual context compaction') {
592
619
  try {
593
- // For now, just clear the history and show a message
594
- // A full implementation would summarize the conversation
595
- const oldLength = this.cachedHistory.length;
596
- if (oldLength === 0) {
597
- display.showInfo('Context is already empty.');
620
+ const agent = this.agent;
621
+ if (!agent) {
622
+ display.showWarning('No active agent to compact context.');
598
623
  return;
599
624
  }
600
- // Keep the last few messages for continuity
601
- const keepCount = Math.min(4, oldLength);
602
- this.cachedHistory = this.cachedHistory.slice(-keepCount);
603
- display.showSuccess(`Context compacted: ${oldLength} messages reduced to ${keepCount}. ` +
604
- `Context usage reset. Continue your conversation.`);
605
- this.refreshControlBar();
625
+ const contextManager = agent.getContextManager();
626
+ if (!contextManager) {
627
+ display.showWarning('Context manager unavailable. Try sending a message first.');
628
+ return;
629
+ }
630
+ const history = agent.getHistory();
631
+ if (history.length <= 1) {
632
+ display.showInfo('Context is already minimal.');
633
+ return;
634
+ }
635
+ const stats = contextManager.getStats(history);
636
+ const windowTokens = this.activeContextWindowTokens ?? this.latestTokenUsage.limit ?? null;
637
+ await this.runContextCleanup(windowTokens, stats.totalTokens, {
638
+ reason,
639
+ forced: true,
640
+ source: 'manual',
641
+ });
606
642
  }
607
643
  catch (error) {
608
644
  display.showError('Failed to compact context', error);
@@ -714,6 +750,7 @@ export class InteractiveShell {
714
750
  // Stop any active spinner to prevent process hang
715
751
  display.stopThinking(false);
716
752
  this.stopStreamingHeartbeat();
753
+ this.endAiRuntime();
717
754
  this.uiUpdates.dispose();
718
755
  this.clearPromptRefreshTimer();
719
756
  this.teardownStatusTracking();
@@ -1159,6 +1196,25 @@ export class InteractiveShell {
1159
1196
  process.stdout.write(content);
1160
1197
  }, 'interactiveShell.stdout');
1161
1198
  }
1199
+ beginAiRuntime() {
1200
+ if (this.aiRuntimeStart === null) {
1201
+ this.aiRuntimeStart = Date.now();
1202
+ }
1203
+ }
1204
+ endAiRuntime() {
1205
+ if (this.aiRuntimeStart !== null) {
1206
+ this.aiRuntimeTotalMs += Date.now() - this.aiRuntimeStart;
1207
+ this.aiRuntimeStart = null;
1208
+ }
1209
+ }
1210
+ getAiRuntimeSeconds() {
1211
+ const runningMs = this.aiRuntimeStart ? Date.now() - this.aiRuntimeStart : 0;
1212
+ const totalMs = this.aiRuntimeTotalMs + runningMs;
1213
+ if (totalMs <= 0 && this.aiRuntimeStart === null) {
1214
+ return null;
1215
+ }
1216
+ return Math.max(0, Math.floor(totalMs / 1000));
1217
+ }
1162
1218
  /**
1163
1219
  * Refresh the status line in the persistent input area.
1164
1220
  * Uses combined status display: streaming label + override + main status.
@@ -1173,20 +1229,7 @@ export class InteractiveShell {
1173
1229
  const statusText = this.formatStatusLine(this.statusLineState);
1174
1230
  this.terminalInput.setStatusMessage(statusText);
1175
1231
  // Surface meta header (elapsed + context usage) above the divider
1176
- // Use streaming elapsed time if available, otherwise fall back to status line state
1177
- let elapsedSeconds = null;
1178
- if (this.streamingHeartbeatStart) {
1179
- // Actively streaming - compute live elapsed
1180
- elapsedSeconds = Math.max(0, Math.floor((Date.now() - this.streamingHeartbeatStart) / 1000));
1181
- }
1182
- else if (this.lastStreamingElapsedSeconds !== null) {
1183
- // Just finished streaming - use preserved final time
1184
- elapsedSeconds = this.lastStreamingElapsedSeconds;
1185
- }
1186
- else if (this.statusLineState) {
1187
- // Fallback to status line state elapsed
1188
- elapsedSeconds = Math.max(0, Math.floor((Date.now() - this.statusLineState.startedAt) / 1000));
1189
- }
1232
+ const elapsedSeconds = this.getAiRuntimeSeconds();
1190
1233
  const thinkingMs = display.isSpinnerActive() ? display.getThinkingElapsedMs() : null;
1191
1234
  const tokensUsed = this.latestTokenUsage.used;
1192
1235
  const tokenLimit = this.latestTokenUsage.limit ?? this.activeContextWindowTokens;
@@ -1365,6 +1408,8 @@ export class InteractiveShell {
1365
1408
  this.terminalInput.streamContent(closing);
1366
1409
  }
1367
1410
  this.streamingFormatter = null;
1411
+ // Force the prompt to re-render after streaming so it stays below the last response
1412
+ this.requestPromptRefresh(true);
1368
1413
  }
1369
1414
  buildStreamingStatus(label, _elapsedSeconds) {
1370
1415
  // Model + elapsed time already live in the pinned meta header; keep the streaming
@@ -1678,7 +1723,7 @@ export class InteractiveShell {
1678
1723
  this.handleCostCommand();
1679
1724
  break;
1680
1725
  case '/usage':
1681
- this.handleUsageCommand();
1726
+ this.handleUsageCommand(input);
1682
1727
  break;
1683
1728
  case '/clear':
1684
1729
  this.handleClearCommand();
@@ -3465,7 +3510,13 @@ export class InteractiveShell {
3465
3510
  lines.push(theme.ui.muted('Token usage is tracked automatically during the session.'));
3466
3511
  display.showSystemMessage(lines.join('\n'));
3467
3512
  }
3468
- handleUsageCommand() {
3513
+ handleUsageCommand(input) {
3514
+ const tokens = input ? input.trim().split(/\s+/) : [];
3515
+ const subcommand = tokens[1]?.toLowerCase();
3516
+ if (subcommand === 'history' || subcommand === 'log') {
3517
+ this.showCompactionHistory();
3518
+ return;
3519
+ }
3469
3520
  const percentage = this.latestTokenUsage.limit && this.latestTokenUsage.used
3470
3521
  ? Math.round((this.latestTokenUsage.used / this.latestTokenUsage.limit) * 100)
3471
3522
  : 0;
@@ -3486,6 +3537,37 @@ export class InteractiveShell {
3486
3537
  else {
3487
3538
  lines.push(theme.success('Plenty of context available.'));
3488
3539
  }
3540
+ if (this.contextCompactionLog.length > 0) {
3541
+ lines.push('');
3542
+ lines.push(theme.secondary('Tip: /usage history shows recent context compactions.'));
3543
+ }
3544
+ display.showSystemMessage(lines.join('\n'));
3545
+ }
3546
+ showCompactionHistory() {
3547
+ if (!this.contextCompactionLog.length) {
3548
+ display.showInfo('No context compactions recorded yet.');
3549
+ return;
3550
+ }
3551
+ const entries = [...this.contextCompactionLog].slice(-10).reverse();
3552
+ const lines = [];
3553
+ lines.push(theme.bold('Context Compaction History'));
3554
+ lines.push('');
3555
+ for (const entry of entries) {
3556
+ const time = new Date(entry.timestamp).toLocaleTimeString();
3557
+ const reason = entry.reason || (entry.source === 'auto' ? 'Auto-compaction' : 'Manual compaction');
3558
+ const before = entry.beforeTokens.toLocaleString('en-US');
3559
+ const after = entry.afterTokens.toLocaleString('en-US');
3560
+ const saved = Math.max(0, entry.beforeTokens - entry.afterTokens);
3561
+ const savedLabel = saved > 0 ? `${saved.toLocaleString('en-US')} saved` : 'no savings';
3562
+ const pctBefore = entry.percentBefore ?? null;
3563
+ const pctAfter = entry.percentAfter ?? null;
3564
+ const pctLabel = pctBefore !== null && pctAfter !== null
3565
+ ? `(${pctBefore}% → ${pctAfter}%)`
3566
+ : '';
3567
+ const summaryNote = entry.summarized ? 'summary applied' : 'prune only';
3568
+ lines.push(` ${time} · ${reason} [${entry.source} · ${entry.model}]`);
3569
+ lines.push(` ${before} → ${after} tokens ${pctLabel} · ${savedLabel} · ${summaryNote}`);
3570
+ }
3489
3571
  display.showSystemMessage(lines.join('\n'));
3490
3572
  }
3491
3573
  handleClearCommand() {
@@ -3622,13 +3704,7 @@ export class InteractiveShell {
3622
3704
  return;
3623
3705
  }
3624
3706
  display.showInfo('Compacting conversation context...');
3625
- // Check if compaction is needed based on tracked usage
3626
- const { used, limit } = this.latestTokenUsage;
3627
- if (used && limit && used / limit < 0.5) {
3628
- display.showInfo('Context usage is low. No compaction needed.');
3629
- return;
3630
- }
3631
- await this.processRequest('Please summarize our conversation so far in a concise way, preserving key decisions, code changes, and next steps. This will help compact the context.');
3707
+ await this.performContextCompaction('Manual /compact request');
3632
3708
  }
3633
3709
  // ==================== End Claude Code Style Commands ====================
3634
3710
  updateActiveSession(summary, remember = false) {
@@ -4570,6 +4646,7 @@ export class InteractiveShell {
4570
4646
  this.currentToolCalls = [];
4571
4647
  this.uiAdapter.startProcessing('Working on your request');
4572
4648
  this.setProcessingStatus();
4649
+ this.beginAiRuntime();
4573
4650
  let responseText = '';
4574
4651
  try {
4575
4652
  // Start streaming - no header needed, the input area already provides context
@@ -4635,6 +4712,7 @@ export class InteractiveShell {
4635
4712
  display.stopThinking(false);
4636
4713
  this.uiUpdates.setMode('processing');
4637
4714
  this.stopStreamingHeartbeat();
4715
+ this.endAiRuntime();
4638
4716
  this.isProcessing = false;
4639
4717
  this.terminalInput.setStreaming(false);
4640
4718
  this.uiAdapter.endProcessing('Ready for prompts');
@@ -4690,6 +4768,7 @@ export class InteractiveShell {
4690
4768
  display.showSystemMessage(`Continuous mode active. Ctrl+C to stop.`);
4691
4769
  this.uiAdapter.startProcessing('Continuous execution mode');
4692
4770
  this.setProcessingStatus();
4771
+ this.beginAiRuntime();
4693
4772
  // No streaming header - just start streaming directly
4694
4773
  this.startStreamingHeartbeat('Streaming');
4695
4774
  let iteration = 0;
@@ -4865,6 +4944,7 @@ What's the next action?`;
4865
4944
  resetTaskCompletionDetector();
4866
4945
  this.uiUpdates.setMode('processing');
4867
4946
  this.stopStreamingHeartbeat();
4947
+ this.endAiRuntime();
4868
4948
  this.isProcessing = false;
4869
4949
  this.terminalInput.setStreaming(false);
4870
4950
  this.uiAdapter.endProcessing('Ready for prompts');
@@ -5421,6 +5501,7 @@ Return ONLY JSON array:
5421
5501
  try {
5422
5502
  ensureSecretForProvider(this.sessionState.provider);
5423
5503
  this.runtimeSession.updateToolContext(this.sessionState);
5504
+ this.ensureActiveSummarizer(this.runtimeSession.contextManager);
5424
5505
  const selection = {
5425
5506
  provider: this.sessionState.provider,
5426
5507
  model: this.sessionState.model,
@@ -5571,6 +5652,7 @@ Return ONLY JSON array:
5571
5652
  * Ensures the input area is visible and ready for input, just like on startup.
5572
5653
  */
5573
5654
  resetChatBoxAfterModelSwap() {
5655
+ this.renderWelcomeBanner(true);
5574
5656
  this.updateStatusMessage(null);
5575
5657
  this.terminalInput.setStreaming(false);
5576
5658
  this.terminalInput.render();
@@ -5686,7 +5768,7 @@ Return ONLY JSON array:
5686
5768
  if (!trigger) {
5687
5769
  return null;
5688
5770
  }
5689
- return this.runContextCleanup(windowTokens, total, trigger);
5771
+ return this.runContextCleanup(windowTokens, total, { ...trigger, source: 'auto' });
5690
5772
  }
5691
5773
  totalTokens(usage) {
5692
5774
  if (!usage) {
@@ -5713,58 +5795,104 @@ Return ONLY JSON array:
5713
5795
  if (history.length <= 1) {
5714
5796
  return;
5715
5797
  }
5798
+ if (!this.areToolTurnsComplete(history)) {
5799
+ display.showInfo('Context compaction postponed until all tool calls have returned results.');
5800
+ return;
5801
+ }
5802
+ this.ensureActiveSummarizer(contextManager);
5716
5803
  this.cleanupInProgress = true;
5717
5804
  this.contextCompactionInFlight = true;
5718
5805
  const cleanupStatusId = 'context-cleanup';
5719
5806
  let cleanupOverlayActive = false;
5720
5807
  try {
5721
- const percentUsed = Math.round((totalTokens / windowTokens) * 100);
5722
- const statusDetailParts = [`${percentUsed}% full`];
5723
- if (trigger?.reason) {
5724
- statusDetailParts.push(trigger.reason);
5725
- }
5808
+ const beforeStats = contextManager.getStats(history);
5809
+ const resolvedWindowTokens = windowTokens ?? this.activeContextWindowTokens ?? this.latestTokenUsage.limit ?? null;
5810
+ const resolvedTotalTokens = totalTokens ?? beforeStats.totalTokens;
5811
+ const percentUsed = resolvedWindowTokens && resolvedWindowTokens > 0
5812
+ ? Math.min(100, Math.round((resolvedTotalTokens / resolvedWindowTokens) * 100))
5813
+ : beforeStats.percentage;
5814
+ const statusDetailParts = [
5815
+ `${percentUsed}% full`,
5816
+ trigger?.reason,
5817
+ ].filter(Boolean);
5726
5818
  this.statusTracker.pushOverride(cleanupStatusId, 'Running context cleanup', {
5727
5819
  detail: statusDetailParts.join(' · '),
5728
5820
  tone: 'warning',
5729
5821
  });
5730
5822
  cleanupOverlayActive = true;
5731
5823
  const triggerReason = trigger?.reason ?? 'Context optimization';
5824
+ const limitLabel = resolvedWindowTokens
5825
+ ? `of ${resolvedWindowTokens.toLocaleString('en-US')} tokens`
5826
+ : 'of estimated context';
5732
5827
  display.showSystemMessage([
5733
- `Context usage: ${totalTokens.toLocaleString('en-US')} of ${windowTokens.toLocaleString('en-US')} tokens`,
5828
+ `Context usage: ${resolvedTotalTokens.toLocaleString('en-US')} ${limitLabel}`,
5734
5829
  `(${percentUsed}% full). Auto-compacting${triggerReason ? `: ${triggerReason}` : '...'}`,
5735
5830
  ].join(' '));
5736
- const beforeStats = contextManager.getStats(history);
5737
5831
  const result = await contextManager.intelligentCompact(history);
5738
- const afterStats = contextManager.getStats(result.compacted);
5739
- const tokenSavings = Math.max(0, beforeStats.totalTokens - afterStats.totalTokens);
5740
- const percentSavings = beforeStats.totalTokens > 0 ? (tokenSavings / beforeStats.totalTokens) * 100 : 0;
5741
- const changed = this.historiesDiffer(history, result.compacted);
5742
- const meaningfulSavings = changed &&
5743
- (tokenSavings >= MIN_COMPACTION_TOKEN_SAVINGS || percentSavings >= MIN_COMPACTION_PERCENT_SAVINGS);
5744
- if (!meaningfulSavings) {
5745
- if (trigger?.forced || percentUsed >= 85) {
5746
- display.showInfo('Auto-compaction completed but did not meaningfully reduce context size. Keeping existing history.');
5832
+ let afterStats = contextManager.getStats(result.compacted);
5833
+ let appliedHistory = result.compacted;
5834
+ let appliedSummarized = result.summarized;
5835
+ const initialTokenSavings = Math.max(0, beforeStats.totalTokens - afterStats.totalTokens);
5836
+ let bestTokenSavings = initialTokenSavings;
5837
+ let bestPercentSavings = beforeStats.totalTokens > 0 ? (initialTokenSavings / beforeStats.totalTokens) * 100 : 0;
5838
+ let changed = this.historiesDiffer(history, appliedHistory);
5839
+ if (!this.hasMeaningfulCompaction(changed, bestTokenSavings, bestPercentSavings, trigger?.forced)) {
5840
+ const fallback = await contextManager.pruneMessagesWithSummary(history, { force: true });
5841
+ const fallbackStats = contextManager.getStats(fallback.pruned);
5842
+ const fallbackTokenSavings = Math.max(0, beforeStats.totalTokens - fallbackStats.totalTokens);
5843
+ const fallbackPercentSavings = beforeStats.totalTokens > 0
5844
+ ? (fallbackTokenSavings / beforeStats.totalTokens) * 100
5845
+ : 0;
5846
+ if (this.historiesDiffer(history, fallback.pruned) &&
5847
+ (fallbackTokenSavings > bestTokenSavings || !changed)) {
5848
+ appliedHistory = fallback.pruned;
5849
+ afterStats = fallbackStats;
5850
+ appliedSummarized = fallback.summarized;
5851
+ bestTokenSavings = fallbackTokenSavings;
5852
+ bestPercentSavings = fallbackPercentSavings;
5853
+ changed = true;
5747
5854
  }
5855
+ }
5856
+ if (!changed) {
5857
+ display.showInfo('Context compaction completed but no changes were applied.');
5858
+ return;
5859
+ }
5860
+ if (!this.hasMeaningfulCompaction(changed, bestTokenSavings, bestPercentSavings, trigger?.forced)) {
5861
+ display.showInfo('Auto-compaction completed but did not meaningfully reduce context size. Keeping existing history.');
5748
5862
  return;
5749
5863
  }
5750
- agent.loadHistory(result.compacted);
5751
- this.cachedHistory = result.compacted;
5752
- const newPercentUsed = windowTokens > 0
5753
- ? Math.round((afterStats.totalTokens / windowTokens) * 100)
5864
+ agent.loadHistory(appliedHistory);
5865
+ this.cachedHistory = appliedHistory;
5866
+ const newPercentUsed = resolvedWindowTokens && resolvedWindowTokens > 0
5867
+ ? Math.min(100, Math.round((afterStats.totalTokens / resolvedWindowTokens) * 100))
5754
5868
  : afterStats.percentage;
5755
5869
  this.latestTokenUsage = {
5756
5870
  used: afterStats.totalTokens,
5757
- limit: windowTokens,
5871
+ limit: resolvedWindowTokens,
5758
5872
  };
5759
5873
  this.updateContextUsage(newPercentUsed, CONTEXT_AUTOCOMPACT_PERCENT);
5760
5874
  this.lastContextWarningLevel = this.getContextWarningLevel(newPercentUsed);
5761
5875
  this.refreshStatusLine(true);
5762
5876
  const primarySignal = result.analysis.signals[0]?.reason ?? triggerReason;
5877
+ const savingsLabel = bestTokenSavings > 0
5878
+ ? `(saved ~${bestTokenSavings.toLocaleString('en-US')} tokens, ~${bestPercentSavings.toFixed(1)}%)`
5879
+ : '(no token savings)';
5763
5880
  display.showSystemMessage([
5764
5881
  `Context compacted: ${beforeStats.percentage}% → ${afterStats.percentage}%`,
5765
- `(saved ~${tokenSavings.toLocaleString('en-US')} tokens).`,
5882
+ savingsLabel,
5766
5883
  primarySignal ? `Reason: ${primarySignal}.` : '',
5767
5884
  ].filter(Boolean).join(' '));
5885
+ this.recordContextCompaction({
5886
+ timestamp: Date.now(),
5887
+ source: trigger?.source ?? 'auto',
5888
+ reason: triggerReason,
5889
+ beforeTokens: beforeStats.totalTokens,
5890
+ afterTokens: afterStats.totalTokens,
5891
+ summarized: appliedSummarized,
5892
+ percentBefore: resolvedWindowTokens ? percentUsed : beforeStats.percentage,
5893
+ percentAfter: newPercentUsed,
5894
+ model: this.sessionState.model,
5895
+ });
5768
5896
  }
5769
5897
  catch (error) {
5770
5898
  display.showError('Context compaction failed.', error);
@@ -5777,7 +5905,35 @@ Return ONLY JSON array:
5777
5905
  this.contextCompactionInFlight = false;
5778
5906
  }
5779
5907
  }
5780
- shouldAutoCompactContext(usageRatio, windowTokens, totalTokens) {
5908
+ recordContextCompaction(event) {
5909
+ this.contextCompactionLog.push(event);
5910
+ if (this.contextCompactionLog.length > 20) {
5911
+ this.contextCompactionLog.shift();
5912
+ }
5913
+ }
5914
+ ensureActiveSummarizer(contextManager) {
5915
+ if (!contextManager?.updateConfig) {
5916
+ return;
5917
+ }
5918
+ const summarizer = async (messages) => {
5919
+ const summary = await this.buildContextSummary(messages);
5920
+ return summary ?? '[No content to summarize]';
5921
+ };
5922
+ contextManager.updateConfig({
5923
+ summarizationCallback: summarizer,
5924
+ useLLMSummarization: true,
5925
+ });
5926
+ }
5927
+ hasMeaningfulCompaction(changed, tokenSavings, percentSavings, forced) {
5928
+ if (!changed) {
5929
+ return false;
5930
+ }
5931
+ if (tokenSavings >= MIN_COMPACTION_TOKEN_SAVINGS || percentSavings >= MIN_COMPACTION_PERCENT_SAVINGS) {
5932
+ return true;
5933
+ }
5934
+ return Boolean(forced && tokenSavings > 0);
5935
+ }
5936
+ shouldAutoCompactContext(usageRatio, _windowTokens, _totalTokens) {
5781
5937
  const featureFlags = loadFeatureFlags();
5782
5938
  if (featureFlags.autoCompact === false) {
5783
5939
  return null;
@@ -5898,6 +6054,25 @@ Return ONLY JSON array:
5898
6054
  }
5899
6055
  return false;
5900
6056
  }
6057
+ areToolTurnsComplete(messages) {
6058
+ const pending = new Set();
6059
+ for (const message of messages) {
6060
+ if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
6061
+ for (const call of message.toolCalls) {
6062
+ if (call.id) {
6063
+ pending.add(call.id);
6064
+ }
6065
+ }
6066
+ }
6067
+ if (message.role === 'tool') {
6068
+ const toolCallId = message.toolCallId;
6069
+ if (toolCallId && pending.has(toolCallId)) {
6070
+ pending.delete(toolCallId);
6071
+ }
6072
+ }
6073
+ }
6074
+ return pending.size === 0;
6075
+ }
5901
6076
  partitionHistory(history) {
5902
6077
  const system = [];
5903
6078
  const conversation = [];
@@ -5989,10 +6164,10 @@ Return ONLY JSON array:
5989
6164
  sections.push(`Conversation chunk:\n${chunk}`);
5990
6165
  sections.push([
5991
6166
  'Instructions:',
5992
- '- Merge the chunk into the running summary.',
5993
- '- Preserve critical TODOs, bugs, test gaps, and file references.',
5994
- '- Call out what is resolved vs. still pending.',
5995
- '- Keep the output concise (<= 200 words) using short headings or bullets.',
6167
+ '- Merge with the running summary.',
6168
+ '- Keep TODOs/bugs/test gaps and file references.',
6169
+ '- Mark what is done vs. pending.',
6170
+ '- Stay concise (<180 words) with short bullets.',
5996
6171
  ].join('\n'));
5997
6172
  return sections.join('\n\n');
5998
6173
  }