@axplusb/kepler 2.3.0 → 2.3.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axplusb/kepler",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Kepler — AI coding agent with operating brief, preflight planning, and sub-agents. SWE-bench Lite evaluated.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,127 @@
1
+ const SUMMARY_PREFIX = 'Session continuity summary after /compact:';
2
+
3
+ export function parseCompactTailCount(rest = '', fallback = 8) {
4
+ const text = String(rest || '').trim();
5
+ const match = text.match(/(?:^|\s)(?:--tail=|--tail\s+)?(\d+)(?:\s|$)/);
6
+ const n = match ? Number(match[1]) : Number(fallback);
7
+ if (!Number.isFinite(n)) return fallback;
8
+ return Math.max(2, Math.min(50, Math.floor(n)));
9
+ }
10
+
11
+ export function isCompactHistory(history = []) {
12
+ return typeof history?.[2]?.content === 'string'
13
+ && history[2].content.startsWith(SUMMARY_PREFIX);
14
+ }
15
+
16
+ export function extractCompactSummary(history = []) {
17
+ const content = String(history?.[2]?.content || '');
18
+ if (!content.startsWith(SUMMARY_PREFIX)) return '';
19
+ return content.slice(SUMMARY_PREFIX.length).trim();
20
+ }
21
+
22
+ export function prepareCompactHistory({
23
+ agentHistory = [],
24
+ tailCount = 8,
25
+ minSourceMessages = 2,
26
+ } = {}) {
27
+ const history = Array.isArray(agentHistory)
28
+ ? agentHistory.filter(msg => msg && typeof msg.content === 'string' && msg.content.trim())
29
+ : [];
30
+ const beforeCount = history.length;
31
+ const retainedCount = Math.min(Math.max(2, Number(tailCount) || 8), Math.max(0, beforeCount));
32
+ const prefixCount = isCompactHistory(history) ? 3 : 0;
33
+ const sourceEnd = Math.max(prefixCount, beforeCount - retainedCount);
34
+ const sourceMessages = history.slice(prefixCount, sourceEnd);
35
+ const tail = history.slice(sourceEnd);
36
+
37
+ if (sourceMessages.length < minSourceMessages) {
38
+ return {
39
+ ok: false,
40
+ reason: beforeCount <= prefixCount + retainedCount
41
+ ? 'not enough history beyond the retained tail'
42
+ : 'not enough compactable messages',
43
+ beforeCount,
44
+ prefixCount,
45
+ sourceMessages,
46
+ tail,
47
+ retainedCount,
48
+ };
49
+ }
50
+
51
+ return {
52
+ ok: true,
53
+ beforeCount,
54
+ prefixCount,
55
+ sourceMessages,
56
+ tail,
57
+ retainedCount: tail.length,
58
+ previousSummary: extractCompactSummary(history),
59
+ };
60
+ }
61
+
62
+ export function applyCompactSummary({
63
+ prepared,
64
+ summary,
65
+ sessionId = '',
66
+ cwd = '',
67
+ originalRequest = '',
68
+ previousSourceMessageCount = 0,
69
+ now = new Date(),
70
+ } = {}) {
71
+ if (!prepared?.ok) throw new Error(prepared?.reason || 'history is not compactable');
72
+ const compactSummary = String(summary || '').trim();
73
+ if (!compactSummary) throw new Error('summary is required');
74
+ const priorCount = Math.max(0, Number(previousSourceMessageCount) || 0);
75
+ const sourceMessageCount = priorCount + prepared.sourceMessages.length;
76
+ const timestamp = now instanceof Date ? now.toISOString() : String(now || new Date().toISOString());
77
+ const firstUser = originalRequest || firstUserMessage(prepared.sourceMessages) || firstUserMessage(prepared.tail) || '(unknown)';
78
+
79
+ const metadata = [
80
+ 'Compact metadata.',
81
+ sessionId ? `Session: ${sessionId}` : '',
82
+ cwd ? `Project: ${cwd}` : '',
83
+ `Compacted at: ${timestamp}`,
84
+ `Covered live messages: ${sourceMessageCount}`,
85
+ `Retained tail messages: ${prepared.tail.length}`,
86
+ ].filter(Boolean).join('\n');
87
+
88
+ const agentHistory = [
89
+ { role: 'user', content: metadata },
90
+ { role: 'user', content: `Original user request from this compacted session:\n${firstUser}` },
91
+ { role: 'user', content: `${SUMMARY_PREFIX}\n${compactSummary}` },
92
+ ...prepared.tail,
93
+ ];
94
+
95
+ return {
96
+ agentHistory,
97
+ summary: compactSummary,
98
+ sourceMessageCount,
99
+ previousSourceMessageCount: priorCount,
100
+ beforeCount: prepared.beforeCount,
101
+ afterCount: agentHistory.length,
102
+ retainedCount: prepared.tail.length,
103
+ compactedCount: prepared.sourceMessages.length,
104
+ };
105
+ }
106
+
107
+ export function localCompactSummary(messages = []) {
108
+ const list = Array.isArray(messages) ? messages : [];
109
+ const userMessages = list.filter(m => m.role === 'user').map(m => String(m.content || '').trim()).filter(Boolean);
110
+ const assistantMessages = list.filter(m => m.role === 'assistant').map(m => String(m.content || '').trim()).filter(Boolean);
111
+ return [
112
+ 'Local compact summary from live conversation context.',
113
+ userMessages.length ? `User requests (${userMessages.length}):` : '',
114
+ ...userMessages.slice(-8).map(text => `- ${truncate(text, 500)}`),
115
+ assistantMessages.length ? `Assistant progress (${assistantMessages.length}):` : '',
116
+ ...assistantMessages.slice(-8).map(text => `- ${truncate(text, 700)}`),
117
+ ].filter(Boolean).join('\n');
118
+ }
119
+
120
+ function firstUserMessage(messages = []) {
121
+ return messages.find(m => m?.role === 'user' && typeof m.content === 'string')?.content || '';
122
+ }
123
+
124
+ function truncate(text, max) {
125
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
126
+ return value.length > max ? value.slice(0, max - 3) + '...' : value;
127
+ }
@@ -317,6 +317,15 @@ export class JsonlWriter {
317
317
  }
318
318
  }
319
319
 
320
+ async flush() {
321
+ await this._flush();
322
+ if (this._flushPromise) await this._flushPromise;
323
+ }
324
+
325
+ get transcriptPath() {
326
+ return this._transcriptPath;
327
+ }
328
+
320
329
  // ── Internal ──
321
330
 
322
331
  _resetTurn() {
@@ -531,10 +531,10 @@ export function buildResumeHistory(detail, mode = 'compact') {
531
531
  );
532
532
 
533
533
  // PRD-068 §5.14.4: resume mode picker.
534
- // 'full' — every turn sent verbatim (unchanged)
535
- // 'tail-N' recap block as system prime + last N conversation messages so the
536
- // agent has real recent conversation to reference.
537
- // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
534
+ // 'full' — every turn sent verbatim (unchanged)
535
+ // 'checkpoint-full' latest summary checkpoint + every message after it
536
+ // 'tail-N' — recap block + last N conversation messages
537
+ // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
538
538
  let agentHistory;
539
539
  let sourceMessages = fullAgentHistory;
540
540
  let summaryMessageIndex = -1;
@@ -543,6 +543,22 @@ export function buildResumeHistory(detail, mode = 'compact') {
543
543
  if (mode === 'full') {
544
544
  agentHistory = fullAgentHistory;
545
545
  summaryCoveredMessageCount = fullAgentHistory.length;
546
+ } else if (mode === 'checkpoint-full') {
547
+ sourceMessages = [];
548
+ const tail = fullAgentHistory.slice(summaryCheckpointMessageCount);
549
+ activeSummary = priorSummary
550
+ || summaryForMessages(fullAgentHistory.slice(0, summaryCheckpointMessageCount), {
551
+ fallback: summary,
552
+ label: 'Summary checkpoint',
553
+ });
554
+ summaryCoveredMessageCount = summaryCheckpointMessageCount;
555
+ agentHistory = [
556
+ { role: 'user', content: metadata },
557
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
558
+ { role: 'user', content: activeSummary || summary },
559
+ ...tail,
560
+ ];
561
+ summaryMessageIndex = 2;
546
562
  } else if (mode === 'recap+tail' || /^tail-\d+$/.test(String(mode || ''))) {
547
563
  const tailTurns = tailTurnsForMode(mode, detail);
548
564
  const { tail, startIndex } = tailHistorySliceByRecentMessages(fullAgentHistory, tailTurns);
@@ -104,7 +104,7 @@ export function decideResumeMode({
104
104
  * Estimate the context tokens for a candidate mode. Used by the tri-choice
105
105
  * overlay to render "62k / 14k / 2k" per option before the user commits.
106
106
  *
107
- * @param {'full' | 'summary' | 'tail-10' | 'tail-20' | 'recap+tail'} choice
107
+ * @param {'full' | 'checkpoint-full' | 'summary' | 'tail-10' | 'tail-20' | 'recap+tail'} choice
108
108
  * @param {number} fullTokens — projected transcript size in full mode
109
109
  * @param {object} [opts]
110
110
  * @returns {number} projected tokens for the chosen mode
@@ -116,11 +116,21 @@ export function projectedTokensForChoice(choice, fullTokens, opts = {}) {
116
116
  tailBaseTokens = 2000,
117
117
  perTailTurnTokens = 500,
118
118
  summaryBaseTokens = 2000,
119
+ resumeSummary = null,
119
120
  } = opts;
120
121
 
121
122
  switch (choice) {
122
123
  case 'full':
123
124
  return Math.max(0, Number(fullTokens) || 0);
125
+ case 'checkpoint-full': {
126
+ const full = Math.max(0, Number(fullTokens) || 0);
127
+ const fullMessages = Math.max(0, Number(resumeSummary?.fullMessageCount) || 0);
128
+ const covered = Math.max(0, Number(resumeSummary?.sourceMessageCount) || 0);
129
+ if (!fullMessages || covered <= 0) return full;
130
+ const remainingMessages = Math.max(0, fullMessages - covered);
131
+ if (remainingMessages === 0) return summaryBaseTokens;
132
+ return summaryBaseTokens + (perTailTurnTokens * remainingMessages);
133
+ }
124
134
  case 'tail-10':
125
135
  return tailBaseTokens + (10 * perTailTurnTokens);
126
136
  case 'tail-20':
@@ -49,6 +49,7 @@ import { buildContextEnvelope } from '../core/context-envelope.mjs';
49
49
  import { buildResumeHistory, combineResumeSummaries, getRecentSessions, getSessionDetail, getTranscriptProjectRoots } from '../core/local-store.mjs';
50
50
  import { decideResumeMode, projectedTokensForChoice, formatTokens as formatCtxTokens } from '../core/resume-mode.mjs';
51
51
  import { appendTask, ensureTaskFiles, loadTaskBoard, moveTask, removeTask, taskCounts, TASK_FILES, updateTask } from '../core/tasks.mjs';
52
+ import { applyCompactSummary, localCompactSummary, parseCompactTailCount, prepareCompactHistory } from '../core/compact-history.mjs';
52
53
  import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
53
54
  import { createOrbit } from '../state/orbit.mjs';
54
55
  import { attachOrbit, unmount as unmountStatusBar } from '../ui/status-bar.mjs';
@@ -196,6 +197,18 @@ function formatResumeCheckpointStatus(session) {
196
197
  return c.dim(` · summarized${pct}`);
197
198
  }
198
199
 
200
+ function formatResumeContextStatus(session) {
201
+ const full = formatCtxTokens(session?.contextTokens || 0);
202
+ const marker = session?.resumeSummary;
203
+ if (!marker || !Number(marker.sourceMessageCount)) {
204
+ return c.dim(`${full.padStart(5, ' ')} ctx`);
205
+ }
206
+ const resumable = formatCtxTokens(projectedTokensForChoice('checkpoint-full', session.contextTokens || 0, {
207
+ resumeSummary: marker,
208
+ }));
209
+ return c.dim(`${resumable.padStart(5, ' ')} resumable · ${full} full`) + formatResumeCheckpointStatus(session);
210
+ }
211
+
199
212
  function formatRelativeTime(iso) {
200
213
  if (!iso) return '';
201
214
  const t = Date.parse(iso);
@@ -250,7 +263,7 @@ async function pickResumableSession(resumable, ctx) {
250
263
  const ago = formatRelativeTime(s.updatedAt || s.startedAt).padEnd(9, ' ').slice(0, 9);
251
264
  const status = endStatusMarker(s.endStatus);
252
265
  const msgs = String(s.messageCount).padStart(3, ' ') + ' msgs';
253
- const ctx = c.dim(`${formatCtxTokens(s.contextTokens).padStart(5, ' ')} ctx`) + formatResumeCheckpointStatus(s);
266
+ const ctx = formatResumeContextStatus(s);
254
267
  const cost = formatSessionCost(s.costUsd);
255
268
  const partial = s.partial ? c.yellow(' ⚠partial') : '';
256
269
  const instr = oneLineInstruction(s.instruction, 48);
@@ -308,8 +321,14 @@ async function chooseThresholdMode(ctx, decision) {
308
321
  if (rl) rl.pause();
309
322
 
310
323
  const canFull = decision.mode !== 'no-full-allowed';
324
+ const hasCheckpoint = Boolean(decision.resumeSummary?.sourceMessageCount);
325
+ const firstOption = canFull
326
+ ? { key: 'f', value: 'full', label: 'full transcript', enabled: true }
327
+ : hasCheckpoint
328
+ ? { key: 'f', value: 'checkpoint-full', label: 'checkpointed transcript', enabled: true }
329
+ : { key: 'f', value: 'full', label: 'full transcript', enabled: false };
311
330
  const options = [
312
- { key: 'f', value: 'full', label: 'full transcript', enabled: canFull },
331
+ firstOption,
313
332
  { key: 's', value: 'summary', label: 'summary only', enabled: true },
314
333
  { key: '1', value: 'tail-10', label: 'summary + last 10 turns', enabled: true },
315
334
  { key: '2', value: 'tail-20', label: 'summary + last 20 turns', enabled: true },
@@ -328,7 +347,8 @@ async function chooseThresholdMode(ctx, decision) {
328
347
  const projected = formatCtxTokens(decision.projected);
329
348
  const win = formatCtxTokens(decision.windowSize);
330
349
  const lines = [];
331
- lines.push(` This session would use ${c.brand(`${projected} / ${win}`)} tokens (${pct}%)`);
350
+ const rawLabel = hasCheckpoint ? 'Raw full transcript would use' : 'This session would use';
351
+ lines.push(` ${rawLabel} ${c.brand(`${projected} / ${win}`)} tokens (${pct}%)`);
332
352
  if (decision.resumeSummary?.sourceMessageCount) {
333
353
  const covered = Number(decision.resumeSummary.sourceMessageCount) || 0;
334
354
  const full = Number(decision.resumeSummary.fullMessageCount) || 0;
@@ -337,14 +357,18 @@ async function chooseThresholdMode(ctx, decision) {
337
357
  }
338
358
  lines.push(canFull
339
359
  ? ` ${c.yellow('⚠')} ${c.dim('close to the highWatermark — consider a leaner mode:')}`
340
- : ` ${c.red('⛔')} ${c.dim('over hardCap — full mode disabled:')}`);
360
+ : hasCheckpoint
361
+ ? ` ${c.yellow('⚠')} ${c.dim('raw full is over hardCap — checkpoint/tail modes are available:')}`
362
+ : ` ${c.red('⛔')} ${c.dim('over hardCap — full mode disabled:')}`);
341
363
  lines.push('');
342
364
  for (let i = 0; i < options.length; i++) {
343
365
  const o = options[i];
344
366
  const disabled = !o.enabled;
345
367
  const marker = i === selected && !disabled ? c.brand('▸') : ' ';
346
368
  const keyTag = c.dim('[') + (disabled ? c.dim(o.key) : c.brand(o.key)) + c.dim(']');
347
- const proj = formatCtxTokens(projectedTokensForChoice(o.value, decision.projected));
369
+ const proj = formatCtxTokens(projectedTokensForChoice(o.value, decision.projected, {
370
+ resumeSummary: decision.resumeSummary,
371
+ }));
348
372
  const label = disabled ? c.dim(o.label) : (i === selected ? c.brand(o.label) : o.label);
349
373
  const projCol = c.dim(`${proj.padStart(5, ' ')} ctx`);
350
374
  const suffix = disabled ? c.dim(' (over hardCap)') : '';
@@ -389,6 +413,7 @@ async function chooseThresholdMode(ctx, decision) {
389
413
  }
390
414
 
391
415
  function resumeModeLabel(mode = 'full') {
416
+ if (mode === 'checkpoint-full') return 'checkpointed transcript';
392
417
  if (mode === 'summary') return 'summary only';
393
418
  const tailTurns = resumeTailTurnCount(mode);
394
419
  if (tailTurns) return `summary + last ${tailTurns} turns`;
@@ -434,7 +459,7 @@ async function previewResumeSession(session, ctx) {
434
459
  if (scrollOffset > maxOffset) scrollOffset = maxOffset;
435
460
 
436
461
  const lines = [];
437
- lines.push(` ${c.bold('Preview:')} ${c.brand(session.project || '(unknown)')} ${c.dim('Mode:')} ${c.brand(resumeModeLabel(mode))} ${c.dim(formatCtxTokens(projectedTokensForChoice(mode, session.contextTokens)) + ' ctx')}`);
462
+ lines.push(` ${c.bold('Preview:')} ${c.brand(session.project || '(unknown)')} ${c.dim('Mode:')} ${c.brand(resumeModeLabel(mode))} ${c.dim(formatCtxTokens(projectedTokensForChoice(mode, session.contextTokens, { resumeSummary: session.resumeSummary })) + ' ctx')}`);
438
463
  lines.push(` ${c.dim('─'.repeat(60))}`);
439
464
  for (let i = scrollOffset; i < Math.min(scrollOffset + rows, totalLines); i++) {
440
465
  lines.push(fitAnsiLine(` ${c.dim(contentLines[i] || '')}`, cols - 1));
@@ -460,7 +485,7 @@ async function previewResumeSession(session, ctx) {
460
485
  if (key === '') { scrollOffset += 1; render(); return; }
461
486
  if (key === '[5~') { scrollOffset = Math.max(0, scrollOffset - 10); render(); return; }
462
487
  if (key === '[6~') { scrollOffset += 10; render(); return; }
463
- if (low === 'f') { mode = 'full'; history = rich(); scrollOffset = 0; render(); return; }
488
+ if (low === 'f') { mode = session.resumeSummary?.sourceMessageCount ? 'checkpoint-full' : 'full'; history = rich(); scrollOffset = 0; render(); return; }
464
489
  if (low === 's') { mode = 'summary'; history = rich(); scrollOffset = 0; render(); return; }
465
490
  if (low === '1') { mode = 'tail-10'; history = rich(); scrollOffset = 0; render(); return; }
466
491
  if (low === '2') { mode = 'tail-20'; history = rich(); scrollOffset = 0; render(); return; }
@@ -659,6 +684,120 @@ async function summarizeResumeTranscript({
659
684
  }
660
685
  }
661
686
 
687
+ async function compactCurrentSession(ctx, rest = '') {
688
+ const tailCount = parseCompactTailCount(rest, 8);
689
+ const preparedLive = prepareCompactHistory({
690
+ agentHistory: session.agentHistory,
691
+ tailCount,
692
+ });
693
+ if (!preparedLive.ok) {
694
+ process.stderr.write(` ${c.gray(`Nothing to compact — ${preparedLive.reason}.`)}\n`);
695
+ return;
696
+ }
697
+
698
+ const progress = startResumeProgress('compact');
699
+ progress.update('preparing compact summary', 18);
700
+ let sourceMessages = preparedLive.sourceMessages;
701
+ let priorSummary = preparedLive.previousSummary || '';
702
+ let previousSourceMessageCount = 0;
703
+ let fullMessageCount = preparedLive.beforeCount;
704
+ let projectPath = safeCwd();
705
+ let sourceFrom = 'live';
706
+ let summaryWarning = '';
707
+
708
+ try {
709
+ if (session.id && ctx.jsonlWriter?.flush) {
710
+ progress.update('reading transcript checkpoint', 28);
711
+ await ctx.jsonlWriter.flush();
712
+ const detail = await getSessionDetail(session.id, { filePath: ctx.jsonlWriter.transcriptPath });
713
+ if (detail) {
714
+ const richHistory = buildResumeHistory({ ...detail, recapTailTurns: 8 }, `tail-${tailCount}`);
715
+ if (richHistory.sourceMessages?.length) {
716
+ sourceMessages = richHistory.sourceMessages;
717
+ priorSummary = richHistory.priorSummary || '';
718
+ previousSourceMessageCount = richHistory.summaryCheckpointMessageCount || 0;
719
+ fullMessageCount = richHistory.fullMessageCount || sourceMessages.length;
720
+ projectPath = detail.meta?.project || safeCwd();
721
+ sourceFrom = 'transcript';
722
+ } else if (richHistory.priorSummary) {
723
+ priorSummary = richHistory.priorSummary;
724
+ previousSourceMessageCount = richHistory.summaryCheckpointMessageCount || 0;
725
+ fullMessageCount = richHistory.fullMessageCount || preparedLive.beforeCount;
726
+ }
727
+ }
728
+ }
729
+
730
+ if (!sourceMessages.length) {
731
+ progress.stop();
732
+ process.stderr.write(` ${c.gray('Nothing new to compact.')}\n`);
733
+ return;
734
+ }
735
+
736
+ progress.update('summarizing compacted history', 46);
737
+ const backendSummary = await summarizeResumeTranscript({
738
+ auth: ctx.auth,
739
+ toolExecutor: ctx.toolExecutor,
740
+ sessionId: session.id,
741
+ projectPath,
742
+ messages: sourceMessages,
743
+ });
744
+ let summarySource = backendSummary?.summary ? (backendSummary.source || 'backend') : 'local fallback';
745
+ let deltaSummary = backendSummary?.summary || '';
746
+ if (!deltaSummary) {
747
+ summaryWarning = backendSummary?.reason || 'backend summary unavailable';
748
+ deltaSummary = localCompactSummary(sourceMessages);
749
+ }
750
+ const summary = combineResumeSummaries(priorSummary, deltaSummary);
751
+
752
+ progress.update('rewriting live context', 74);
753
+ const applied = applyCompactSummary({
754
+ prepared: { ...preparedLive, sourceMessages },
755
+ summary,
756
+ sessionId: session.id,
757
+ cwd: projectPath,
758
+ originalRequest: session.history.find(m => m.role === 'user')?.content || session.lastTask || '',
759
+ previousSourceMessageCount,
760
+ });
761
+ session.agentHistory = applied.agentHistory;
762
+ session.compactSummary = summary;
763
+ session.compactSourceMessageCount = applied.sourceMessageCount;
764
+
765
+ if (ctx.jsonlWriter) {
766
+ progress.update('writing summary checkpoint', 88);
767
+ ctx.jsonlWriter.writeKeplerEvent({
768
+ type: 'resume_summary',
769
+ data: {
770
+ session_id: session.id || null,
771
+ mode: 'compact',
772
+ mode_label: '/compact',
773
+ summary,
774
+ summary_source: summarySource,
775
+ summary_warning: summaryWarning || null,
776
+ source: sourceFrom,
777
+ source_message_count: applied.sourceMessageCount,
778
+ previous_source_message_count: previousSourceMessageCount,
779
+ full_message_count: fullMessageCount,
780
+ retained_tail_messages: applied.retainedCount,
781
+ live_before_messages: applied.beforeCount,
782
+ live_after_messages: applied.afterCount,
783
+ },
784
+ });
785
+ await ctx.jsonlWriter.flush?.();
786
+ }
787
+
788
+ progress.stop();
789
+ process.stderr.write(
790
+ ` ${c.green('✓')} ${c.dim(`Compacted context: ${applied.beforeCount} → ${applied.afterCount} live messages · retained ${applied.retainedCount} · summary ${summarySource}`)}\n`
791
+ );
792
+ if (summaryWarning) {
793
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`backend summary unavailable — used local summary (${summaryWarning})`)}\n`);
794
+ }
795
+ } catch (err) {
796
+ progress.stop();
797
+ process.stderr.write(` ${c.red(`Compact failed: ${err?.message || String(err)}`)}\n`);
798
+ }
799
+ }
800
+
662
801
  function resumeTailTurnCount(mode = '') {
663
802
  const match = String(mode || '').match(/^tail-(\d+)$/);
664
803
  if (!match) return null;
@@ -1224,10 +1363,12 @@ function updateStatusBar() {
1224
1363
  // buffered head as a regular two-line shape first so the interleaving
1225
1364
  // content lands below it.
1226
1365
  let _pendingHead = null; // { callId, head, indent }
1366
+ let _lastRenderedBlock = null; // 'tool' | 'content' | null
1227
1367
 
1228
1368
  function flushPendingHead() {
1229
1369
  if (!_pendingHead) return;
1230
- process.stderr.write(`\n${_pendingHead.head}\n`);
1370
+ process.stderr.write(`${_pendingHead.head}\n`);
1371
+ _lastRenderedBlock = 'tool';
1231
1372
  _pendingHead = null;
1232
1373
  }
1233
1374
 
@@ -1312,7 +1453,8 @@ function renderToolResult(data, eventType = 'tool_result') {
1312
1453
  const cols = process.stderr.columns || 120;
1313
1454
  const combined = `${_pendingHead.head} ${outcome}`;
1314
1455
  if (stripAnsi(combined).length <= cols) {
1315
- process.stderr.write(`\n${combined}\n`);
1456
+ process.stderr.write(`${combined}\n`);
1457
+ _lastRenderedBlock = 'tool';
1316
1458
  _pendingHead = null;
1317
1459
  return;
1318
1460
  }
@@ -1326,6 +1468,7 @@ function renderToolResult(data, eventType = 'tool_result') {
1326
1468
 
1327
1469
  // Two-line shape: gutter under the (already-printed or just-flushed) head.
1328
1470
  process.stderr.write(`${gutter}${outcome}\n`);
1471
+ _lastRenderedBlock = 'tool';
1329
1472
 
1330
1473
  // Lint warnings stay visible alongside writes.
1331
1474
  if (hasLint) {
@@ -1448,6 +1591,7 @@ function startContentStream() {
1448
1591
  _streamedPartialText = '';
1449
1592
  _renderedToolResults.clear();
1450
1593
  _renderedContentThisTurn = false;
1594
+ _lastRenderedBlock = null;
1451
1595
  stopSpinner();
1452
1596
  }
1453
1597
 
@@ -1472,12 +1616,14 @@ function flushContent() {
1472
1616
  // Any buffered tool head needs to land BEFORE this content so the order
1473
1617
  // is preserved on screen.
1474
1618
  flushPendingHead();
1619
+ if (_lastRenderedBlock === 'tool') process.stderr.write('\n');
1475
1620
  const rendered = renderMarkdown(_streamBuffer);
1476
1621
  for (const line of rendered.split('\n')) {
1477
1622
  process.stdout.write(` ${line}\n`);
1478
1623
  }
1479
1624
  _streamBuffer = '';
1480
1625
  _renderedContentThisTurn = true;
1626
+ _lastRenderedBlock = 'content';
1481
1627
  }
1482
1628
 
1483
1629
  // ── Event Renderer ──
@@ -1529,11 +1675,13 @@ function renderEvent(event) {
1529
1675
  }
1530
1676
  }
1531
1677
  if (text) {
1678
+ if (_lastRenderedBlock === 'tool') process.stderr.write('\n');
1532
1679
  const rendered = renderMarkdown(text);
1533
1680
  for (const line of rendered.split('\n')) {
1534
1681
  process.stdout.write(` ${line}\n`);
1535
1682
  }
1536
1683
  _renderedContentThisTurn = true;
1684
+ _lastRenderedBlock = 'content';
1537
1685
  }
1538
1686
  break;
1539
1687
  }
@@ -2576,10 +2724,7 @@ async function handleCommand(input, ctx) {
2576
2724
  }
2577
2725
 
2578
2726
  case '/compact': {
2579
- const before = session.agentHistory.length || session.history.length;
2580
- if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
2581
- session.agentHistory.splice(2, session.agentHistory.length - 6);
2582
- process.stderr.write(` ${c.gray(`Compacted agent context: ${before} → ${session.agentHistory.length} messages`)}\n`);
2727
+ await compactCurrentSession(ctx, rest);
2583
2728
  return;
2584
2729
  }
2585
2730