import type { AutoCompactState, DcpState, RetryState, TruncateState, } from "./types"; import type { ExperimentalConfig } from "../../config"; import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"; import { executeDynamicContextPruning } from "./pruning-executor"; import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens, } from "./storage"; import { findEmptyMessages, findEmptyMessageByIndex, injectTextPart, replaceEmptyTextParts, } from "../session-recovery/storage"; import { log } from "../../shared/logger"; const PLACEHOLDER_TEXT = "[user interrupted]"; type Client = { session: { messages: (opts: { path: { id: string }; query?: { directory?: string }; }) => Promise; summarize: (opts: { path: { id: string }; body: { providerID: string; modelID: string }; query: { directory: string }; }) => Promise; revert: (opts: { path: { id: string }; body: { messageID: string; partID?: string }; query: { directory: string }; }) => Promise; prompt_async: (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> }; query: { directory: string }; }) => Promise; }; tui: { showToast: (opts: { body: { title: string; message: string; variant: string; duration: number; }; }) => Promise; }; }; function getOrCreateRetryState( autoCompactState: AutoCompactState, sessionID: string, ): RetryState { let state = autoCompactState.retryStateBySession.get(sessionID); if (!state) { state = { attempt: 0, lastAttemptTime: 0 }; autoCompactState.retryStateBySession.set(sessionID, state); } return state; } function getOrCreateTruncateState( autoCompactState: AutoCompactState, sessionID: string, ): TruncateState { let state = autoCompactState.truncateStateBySession.get(sessionID); if (!state) { state = { truncateAttempt: 0 }; autoCompactState.truncateStateBySession.set(sessionID, state); } return state; } function getOrCreateDcpState( autoCompactState: AutoCompactState, sessionID: string, ): DcpState { let state = autoCompactState.dcpStateBySession.get(sessionID); if (!state) { state = { attempted: false, itemsPruned: 0 }; autoCompactState.dcpStateBySession.set(sessionID, state); } return state; } function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number { const emptyMessageIds = findEmptyMessages(sessionID); if (emptyMessageIds.length === 0) { return 0; } let fixedCount = 0; for (const messageID of emptyMessageIds) { const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT); if (replaced) { fixedCount++; } else { const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT); if (injected) { fixedCount++; } } } if (fixedCount > 0) { log("[auto-compact] pre-summarize sanitization fixed empty messages", { sessionID, fixedCount, totalEmpty: emptyMessageIds.length, }); } return fixedCount; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } export async function getLastAssistant( sessionID: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, directory: string, ): Promise | null> { try { const resp = await (client as Client).session.messages({ path: { id: sessionID }, query: { directory }, }); const data = (resp as { data?: unknown[] }).data; if (!Array.isArray(data)) return null; const reversed = [...data].reverse(); const last = reversed.find((m) => { const msg = m as Record; const info = msg.info as Record | undefined; return info?.role === "assistant"; }); if (!last) return null; return (last as { info?: Record }).info ?? null; } catch { return null; } } function clearSessionState( autoCompactState: AutoCompactState, sessionID: string, ): void { autoCompactState.pendingCompact.delete(sessionID); autoCompactState.errorDataBySession.delete(sessionID); autoCompactState.retryStateBySession.delete(sessionID); autoCompactState.truncateStateBySession.delete(sessionID); autoCompactState.dcpStateBySession.delete(sessionID); autoCompactState.emptyContentAttemptBySession.delete(sessionID); autoCompactState.compactionInProgress.delete(sessionID); } function getOrCreateEmptyContentAttempt( autoCompactState: AutoCompactState, sessionID: string, ): number { return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0; } async function fixEmptyMessages( sessionID: string, autoCompactState: AutoCompactState, client: Client, messageIndex?: number, ): Promise { const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID); autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1); let fixed = false; const fixedMessageIds: string[] = []; if (messageIndex !== undefined) { const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex); if (targetMessageId) { const replaced = replaceEmptyTextParts( targetMessageId, "[user interrupted]", ); if (replaced) { fixed = true; fixedMessageIds.push(targetMessageId); } else { const injected = injectTextPart( sessionID, targetMessageId, "[user interrupted]", ); if (injected) { fixed = true; fixedMessageIds.push(targetMessageId); } } } } if (!fixed) { const emptyMessageIds = findEmptyMessages(sessionID); if (emptyMessageIds.length === 0) { await client.tui .showToast({ body: { title: "Empty Content Error", message: "No empty messages found in storage. Cannot auto-recover.", variant: "error", duration: 5000, }, }) .catch(() => {}); return false; } for (const messageID of emptyMessageIds) { const replaced = replaceEmptyTextParts(messageID, "[user interrupted]"); if (replaced) { fixed = true; fixedMessageIds.push(messageID); } else { const injected = injectTextPart( sessionID, messageID, "[user interrupted]", ); if (injected) { fixed = true; fixedMessageIds.push(messageID); } } } } if (fixed) { await client.tui .showToast({ body: { title: "Session Recovery", message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`, variant: "warning", duration: 3000, }, }) .catch(() => {}); } return fixed; } export async function executeCompact( sessionID: string, msg: Record, autoCompactState: AutoCompactState, // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, directory: string, experimental?: ExperimentalConfig, dcpForCompaction?: boolean, ): Promise { if (autoCompactState.compactionInProgress.has(sessionID)) { await (client as Client).tui .showToast({ body: { title: "Compact In Progress", message: "Recovery already running. Please wait or start new session if stuck.", variant: "warning", duration: 5000, }, }) .catch(() => {}); return; } autoCompactState.compactionInProgress.add(sessionID); try { const errorData = autoCompactState.errorDataBySession.get(sessionID); const truncateState = getOrCreateTruncateState(autoCompactState, sessionID); const isOverLimit = errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens; // PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first const dcpState = getOrCreateDcpState(autoCompactState, sessionID); if (dcpForCompaction !== false && !dcpState.attempted && isOverLimit) { dcpState.attempted = true; log("[auto-compact] PHASE 1: DCP triggered on token limit error", { sessionID, currentTokens: errorData.currentTokens, maxTokens: errorData.maxTokens, }); const dcpConfig = experimental?.dynamic_context_pruning ?? { enabled: true, notification: "detailed" as const, protected_tools: [ "task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve", ], }; try { const pruningResult = await executeDynamicContextPruning( sessionID, dcpConfig, client, ); if (pruningResult.itemsPruned > 0) { dcpState.itemsPruned = pruningResult.itemsPruned; log("[auto-compact] DCP successful, proceeding to truncation", { itemsPruned: pruningResult.itemsPruned, tokensSaved: pruningResult.totalTokensSaved, }); await (client as Client).tui .showToast({ body: { title: "Dynamic Context Pruning", message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Proceeding to truncation...`, variant: "success", duration: 3000, }, }) .catch(() => {}); // Continue to PHASE 2 (truncation) instead of summarizing immediately } else { log("[auto-compact] DCP did not prune any items", { sessionID }); } } catch (error) { log("[auto-compact] DCP failed", { error: String(error) }); } } // PHASE 2: Aggressive Truncation - always try when over limit (not experimental-only) if ( isOverLimit && truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts ) { log("[auto-compact] PHASE 2: aggressive truncation triggered", { currentTokens: errorData.currentTokens, maxTokens: errorData.maxTokens, targetRatio: TRUNCATE_CONFIG.targetTokenRatio, }); const aggressiveResult = truncateUntilTargetTokens( sessionID, errorData.currentTokens, errorData.maxTokens, TRUNCATE_CONFIG.targetTokenRatio, TRUNCATE_CONFIG.charsPerToken, ); if (aggressiveResult.truncatedCount > 0) { truncateState.truncateAttempt += aggressiveResult.truncatedCount; const toolNames = aggressiveResult.truncatedTools .map((t) => t.toolName) .join(", "); const statusMsg = aggressiveResult.sufficient ? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` : `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`; await (client as Client).tui .showToast({ body: { title: aggressiveResult.sufficient ? "Truncation Complete" : "Partial Truncation", message: `${statusMsg}: ${toolNames}`, variant: aggressiveResult.sufficient ? "success" : "warning", duration: 4000, }, }) .catch(() => {}); log("[auto-compact] aggressive truncation completed", aggressiveResult); // Only return early if truncation was sufficient to get under token limit // Otherwise fall through to PHASE 3 (Summarize) if (aggressiveResult.sufficient) { clearSessionState(autoCompactState, sessionID); setTimeout(async () => { try { await (client as Client).session.prompt_async({ path: { id: sessionID }, body: { auto: true } as never, query: { directory }, }); } catch {} }, 500); return; } // Truncation was insufficient - fall through to Summarize log("[auto-compact] truncation insufficient, falling through to summarize", { sessionID, truncatedCount: aggressiveResult.truncatedCount, sufficient: aggressiveResult.sufficient, }); } } // PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs const retryState = getOrCreateRetryState(autoCompactState, sessionID); if (errorData?.errorType?.includes("non-empty content")) { const attempt = getOrCreateEmptyContentAttempt( autoCompactState, sessionID, ); if (attempt < 3) { const fixed = await fixEmptyMessages( sessionID, autoCompactState, client as Client, errorData.messageIndex, ); if (fixed) { setTimeout(() => { executeCompact( sessionID, msg, autoCompactState, client, directory, experimental, dcpForCompaction, ); }, 500); return; } } else { await (client as Client).tui .showToast({ body: { title: "Recovery Failed", message: "Max recovery attempts (3) reached for empty content error. Please start a new session.", variant: "error", duration: 10000, }, }) .catch(() => {}); return; } } if (Date.now() - retryState.lastAttemptTime > 300000) { retryState.attempt = 0; autoCompactState.truncateStateBySession.delete(sessionID); } if (retryState.attempt < RETRY_CONFIG.maxAttempts) { retryState.attempt++; retryState.lastAttemptTime = Date.now(); const providerID = msg.providerID as string | undefined; const modelID = msg.modelID as string | undefined; if (providerID && modelID) { try { sanitizeEmptyMessagesBeforeSummarize(sessionID); await (client as Client).tui .showToast({ body: { title: "Auto Compact", message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, variant: "warning", duration: 3000, }, }) .catch(() => {}); const summarizeBody = { providerID, modelID, auto: true } await (client as Client).session.summarize({ path: { id: sessionID }, body: summarizeBody as never, query: { directory }, }); return; } catch { const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1); const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs); setTimeout(() => { executeCompact( sessionID, msg, autoCompactState, client, directory, experimental, dcpForCompaction, ); }, cappedDelay); return; } } else { await (client as Client).tui .showToast({ body: { title: "Summarize Skipped", message: "Missing providerID or modelID.", variant: "warning", duration: 3000, }, }) .catch(() => {}); } } clearSessionState(autoCompactState, sessionID); await (client as Client).tui .showToast({ body: { title: "Auto Compact Failed", message: "All recovery attempts failed. Please start a new session.", variant: "error", duration: 5000, }, }) .catch(() => {}); } finally { autoCompactState.compactionInProgress.delete(sessionID); } }