/** * Context compaction for long sessions. * * Pure functions for compaction logic. The session manager handles I/O, * and after compaction the session is reloaded. */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; import { buildSessionContext, type CompactionEntry, type SessionEntry, sessionEntryToContextMessages, } from "../session-manager.ts"; import { computeFileLists, createFileOps, extractFileOpsFromMessage, type FileOperations, formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, } from "./utils.ts"; // ============================================================================ // File Operation Tracking // ============================================================================ /** Details stored in CompactionEntry.details for file tracking */ export interface CompactionDetails { readFiles: string[]; modifiedFiles: string[]; } /** * Extract file operations from messages and previous compaction entries. */ function extractFileOperations( messages: AgentMessage[], entries: SessionEntry[], prevCompactionIndex: number, ): FileOperations { const fileOps = createFileOps(); // Collect from previous compaction's details (if pi-generated) if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; if (!prevCompaction.fromHook && prevCompaction.details) { // fromHook field kept for session file compatibility const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); } if (Array.isArray(details.modifiedFiles)) { for (const f of details.modifiedFiles) fileOps.edited.add(f); } } } // Extract from tool calls in messages for (const msg of messages) { extractFileOpsFromMessage(msg, fileOps); } return fileOps; } // ============================================================================ // Message Extraction // ============================================================================ /** * Extract AgentMessage from an entry if it produces one. * Returns undefined for entries that don't contribute to LLM context. */ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | undefined { if (entry.type === "compaction") { return undefined; } return sessionEntryToContextMessages(entry)[0]; } /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { summary: string; firstKeptEntryId: string; tokensBefore: number; estimatedTokensAfter?: number; /** Usage from the LLM call(s) that generated this summary, if available */ usage?: Usage; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; } function combineUsage(first: Usage, second: Usage): Usage { return { input: first.input + second.input, output: first.output + second.output, cacheRead: first.cacheRead + second.cacheRead, cacheWrite: first.cacheWrite + second.cacheWrite, ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) } : {}), ...(first.reasoning !== undefined || second.reasoning !== undefined ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) } : {}), totalTokens: first.totalTokens + second.totalTokens, cost: { input: first.cost.input + second.cost.input, output: first.cost.output + second.cost.output, cacheRead: first.cost.cacheRead + second.cost.cacheRead, cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, total: first.cost.total + second.cost.total, }, }; } // ============================================================================ // Types // ============================================================================ export interface CompactionSettings { enabled: boolean; reserveTokens: number; keepRecentTokens: number; } export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, }; // ============================================================================ // Token calculation // ============================================================================ /** * Calculate total context tokens from usage. * Uses the native totalTokens field when available, falls back to computing from components. */ export function calculateContextTokens(usage: Usage): number { return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; } /** * Get usage from an assistant message if available. * Skips aborted, error, and all-zero usage messages as they don't have valid usage data. */ function getAssistantUsage(msg: AgentMessage): Usage | undefined { if (msg.role === "assistant" && "usage" in msg) { const assistantMsg = msg as AssistantMessage; if ( assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage && calculateContextTokens(assistantMsg.usage) > 0 ) { return assistantMsg.usage; } } return undefined; } /** * Find the last valid assistant message usage from session entries. */ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "message") { const usage = getAssistantUsage(entry.message); if (usage) return usage; } } return undefined; } export interface ContextUsageEstimate { tokens: number; usageTokens: number; trailingTokens: number; lastUsageIndex: number | null; } function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined { for (let i = messages.length - 1; i >= 0; i--) { const usage = getAssistantUsage(messages[i]); if (usage) return { usage, index: i }; } return undefined; } /** * Estimate context tokens from messages, using the last assistant usage when available. * If there are messages after the last usage, estimate their tokens with estimateTokens. */ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { const usageInfo = getLastAssistantUsageInfo(messages); if (!usageInfo) { let estimated = 0; for (const message of messages) { estimated += estimateTokens(message); } return { tokens: estimated, usageTokens: 0, trailingTokens: estimated, lastUsageIndex: null, }; } const usageTokens = calculateContextTokens(usageInfo.usage); let trailingTokens = 0; for (let i = usageInfo.index + 1; i < messages.length; i++) { trailingTokens += estimateTokens(messages[i]); } return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index, }; } /** * Check if compaction should trigger based on context usage. */ export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { if (!settings.enabled) return false; return contextTokens > contextWindow - settings.reserveTokens; } // ============================================================================ // Cut point detection // ============================================================================ const ESTIMATED_IMAGE_CHARS = 4800; function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { if (typeof content === "string") { return content.length; } let chars = 0; for (const block of content) { if (block.type === "text" && block.text) { chars += block.text.length; } else if (block.type === "image") { chars += ESTIMATED_IMAGE_CHARS; } } return chars; } /** * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). */ export function estimateTokens(message: AgentMessage): number { let chars = 0; switch (message.role) { case "user": { chars = estimateTextAndImageContentChars( (message as { content: string | Array<{ type: string; text?: string }> }).content, ); return Math.ceil(chars / 4); } case "assistant": { const assistant = message as AssistantMessage; for (const block of assistant.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; } else if (block.type === "toolCall") { chars += block.name.length + JSON.stringify(block.arguments).length; } } return Math.ceil(chars / 4); } case "custom": case "toolResult": { chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { chars = message.command.length + message.output.length; return Math.ceil(chars / 4); } case "branchSummary": case "compactionSummary": { chars = message.summary.length; return Math.ceil(chars / 4); } } return 0; } function isCutPointMessage(message: AgentMessage): boolean { switch (message.role) { case "user": case "assistant": case "bashExecution": case "custom": case "branchSummary": case "compactionSummary": return true; case "toolResult": return false; } return false; } function isTurnStartMessage(message: AgentMessage): boolean { switch (message.role) { case "user": case "bashExecution": case "custom": case "branchSummary": case "compactionSummary": return true; case "assistant": case "toolResult": return false; } return false; } function isTurnStartEntry(entry: SessionEntry): boolean { if (entry.type === "compaction") { return false; } return sessionEntryToContextMessages(entry).some(isTurnStartMessage); } /** * Find valid cut points: indices of context-visible user-like or assistant messages. * Never cut at tool results (they must follow their tool call). * When we cut at an assistant message with tool calls, its tool results follow it * and will be kept. */ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] { const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; if (entry.type === "compaction") { continue; } if (sessionEntryToContextMessages(entry).some(isCutPointMessage)) { cutPoints.push(i); } } return cutPoints; } /** * Find the context-visible user-role message that starts the turn containing the given entry index. * Returns -1 if no turn start found before the index. */ export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number { for (let i = entryIndex; i >= startIndex; i--) { if (isTurnStartEntry(entries[i])) { return i; } } return -1; } export interface CutPointResult { /** Index of first entry to keep */ firstKeptEntryIndex: number; /** Index of user message that starts the turn being split, or -1 if not splitting */ turnStartIndex: number; /** Whether this cut splits a turn (cut point is not a user message) */ isSplitTurn: boolean; } /** * Find the cut point in session entries that keeps approximately `keepRecentTokens`. * * Algorithm: Walk backwards from newest, accumulating estimated message sizes. * Stop when we've accumulated >= keepRecentTokens. Cut at that point. * * Can cut at user OR assistant messages (never tool results). When cutting at an * assistant message with tool calls, its tool results come after and will be kept. * * Returns CutPointResult with: * - firstKeptEntryIndex: the entry index to start keeping from * - turnStartIndex: if cutting mid-turn, the user message that started that turn * - isSplitTurn: whether we're cutting in the middle of a turn * * Only considers entries between `startIndex` and `endIndex` (exclusive). */ export function findCutPoint( entries: SessionEntry[], startIndex: number, endIndex: number, keepRecentTokens: number, ): CutPointResult { const cutPoints = findValidCutPoints(entries, startIndex, endIndex); if (cutPoints.length === 0) { return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; } // Walk backwards from newest, accumulating estimated message sizes let accumulatedTokens = 0; let cutIndex = cutPoints[0]; // Default: keep from first message (not header) for (let i = endIndex - 1; i >= startIndex; i--) { const entry = entries[i]; const messageTokens = sessionEntryToContextMessages(entry).reduce( (sum, message) => sum + estimateTokens(message), 0, ); if (messageTokens === 0) continue; accumulatedTokens += messageTokens; // Check if we've exceeded the budget if (accumulatedTokens >= keepRecentTokens) { // Find the closest valid cut point at or after this entry for (let c = 0; c < cutPoints.length; c++) { if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; break; } } break; } } // Scan backwards from cutIndex to include adjacent metadata entries that do not affect context. while (cutIndex > startIndex) { const prevEntry = entries[cutIndex - 1]; // Stop at compaction boundaries or context-visible entries. if (prevEntry.type === "compaction" || sessionEntryToContextMessages(prevEntry).length > 0) { break; } cutIndex--; } // Determine if this is a split turn const cutEntry = entries[cutIndex]; const startsTurn = isTurnStartEntry(cutEntry); const turnStartIndex = startsTurn ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); return { firstKeptEntryIndex: cutIndex, turnStartIndex, isSplitTurn: !startsTurn && turnStartIndex !== -1, }; } // ============================================================================ // Summarization // ============================================================================ const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. Use this EXACT format: ## Goal [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] ## Constraints & Preferences - [Any constraints, preferences, or requirements mentioned by user] - [Or "(none)" if none were mentioned] ## Progress ### Done - [x] [Completed tasks/changes] ### In Progress - [ ] [Current work] ### Blocked - [Issues preventing progress, if any] ## Key Decisions - **[Decision]**: [Brief rationale] ## Next Steps 1. [Ordered list of what should happen next] ## Critical Context - [Any data, examples, or references needed to continue] - [Or "(none)" if not applicable] Keep each section concise. Preserve exact file paths, function names, and error messages.`; const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed - UPDATE "Next Steps" based on what was accomplished - PRESERVE exact file paths, function names, and error messages - If something is no longer relevant, you may remove it Use this EXACT format: ## Goal [Preserve existing goals, add new ones if the task expanded] ## Constraints & Preferences - [Preserve existing, add new ones discovered] ## Progress ### Done - [x] [Include previously done items AND newly completed items] ### In Progress - [ ] [Current work - update based on progress] ### Blocked - [Current blockers - remove if resolved] ## Key Decisions - **[Decision]**: [Brief rationale] (preserve all previous, add new) ## Next Steps 1. [Update based on current state] ## Critical Context - [Preserve important context, add new if needed] Keep each section concise. Preserve exact file paths, function names, and error messages.`; const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; /** * Returns an error message when a summarization response cannot safely be persisted. * A length stop contains partial text and must not become a session checkpoint. */ export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined { if (response.stopReason === "error") { return `${label} failed: ${response.errorMessage || "Unknown error"}`; } if (response.stopReason === "length") { return `${label} failed: generation hit the token cap and the summary is incomplete`; } return undefined; } function createSummarizationOptions( model: Model, maxTokens: number, apiKey: string | undefined, headers: Record | undefined, env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, sessionId: string | undefined, ): SimpleStreamOptions { const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } return options; } /** * Shared choke point for every compaction/branch-summary summarization call. Wraps the * single LLM call in {@link retryAssistantCall} so transient stream drops (e.g. * `terminated`, socket close) honor the configured retry policy instead of failing * the whole compaction on the first attempt. Deterministic errors and aborts return * immediately (see {@link retryAssistantCall}). */ export async function completeSummarization( model: Model, context: Context, options: SimpleStreamOptions, streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), }; const produce = async (): Promise => streamFn ? (await streamFn(model, context, requestOptions)).result() : completeSimple(model, context, requestOptions); return retryAssistantCall(produce, retry, requestOptions.signal, callbacks); } /** * Generate a summary of the conversation using the LLM. * If previousSummary is provided, uses the update prompt to merge. */ export async function generateSummary( currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string | undefined, headers?: Record, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( currentMessages, model, reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, env, retry, callbacks, sessionId, ) ).text; } /** Build the provider context for a standalone summary request. */ function buildSummarizationContext(promptText: string): Context { return { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [ { role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now(), }, ], }; } /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string | undefined, headers?: Record, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); // Use update prompt if we have a previous summary, otherwise initial prompt let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } // Serialize conversation to text so model doesn't try to continue it // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) const llmMessages = convertToLlm(currentMessages); const conversationText = serializeConversation(llmMessages); // Build the prompt with conversation wrapped in tags let promptText = `\n${conversationText}\n\n\n`; if (previousSummary) { promptText += `\n${previousSummary}\n\n\n`; } promptText += basePrompt; const completionOptions = createSummarizationOptions( model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId, ); const response = await completeSummarization( model, buildSummarizationContext(promptText), completionOptions, streamFn, retry, callbacks, ); const failure = getSummarizationFailure(response, "Summarization"); if (failure) { throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Summarization attempted to call a tool"); } const textContent = contentText(response.content); return { text: textContent, usage: response.usage }; } // ============================================================================ // Compaction Preparation (for extensions) // ============================================================================ export interface CompactionPreparation { /** UUID of first entry to keep */ firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; /** Summary from previous compaction, for iterative update */ previousSummary?: string; /** File operations extracted from messagesToSummarize */ fileOps: FileOperations; /** Compaction settions from settings.jsonl */ settings: CompactionSettings; } export function prepareCompaction( pathEntries: SessionEntry[], settings: CompactionSettings, ): CompactionPreparation | undefined { if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { return undefined; } let prevCompactionIndex = -1; for (let i = pathEntries.length - 1; i >= 0; i--) { if (pathEntries[i].type === "compaction") { prevCompactionIndex = i; break; } } let previousSummary: string | undefined; let boundaryStart = 0; if (prevCompactionIndex >= 0) { const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; previousSummary = prevCompaction.summary; const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId); boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; } const boundaryEnd = pathEntries.length; const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); // Get UUID of first kept entry const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; if (!firstKeptEntry?.id) { return undefined; // Session needs migration } const firstKeptEntryId = firstKeptEntry.id; const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; // Messages to summarize (will be discarded after summary) const messagesToSummarize: AgentMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { const msg = getMessageFromEntryForCompaction(pathEntries[i]); if (msg) messagesToSummarize.push(msg); } // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { const msg = getMessageFromEntryForCompaction(pathEntries[i]); if (msg) turnPrefixMessages.push(msg); } } if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { return undefined; } // Extract file operations from messages and previous compaction const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); // Also extract file ops from turn prefix if splitting if (cutPoint.isSplitTurn) { for (const msg of turnPrefixMessages) { extractFileOpsFromMessage(msg, fileOps); } } return { firstKeptEntryId, messagesToSummarize, turnPrefixMessages, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, fileOps, settings, }; } // ============================================================================ // Main compaction function // ============================================================================ const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. Summarize the prefix to provide context for the retained suffix: ## Original Request [What did the user ask for in this turn?] ## Early Progress - [Key decisions and work done in the prefix] ## Context for Suffix - [Information needed to understand the retained recent work] Be concise. Focus on what's needed to understand the kept suffix.`; /** * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, model: Model, apiKey: string | undefined, headers?: Record, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, ): Promise { const { firstKeptEntryId, messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, previousSummary, fileOps, settings, } = preparation; // Generate summaries and merge into one let summary: string; let summaryUsage: Usage; if (isSplitTurn && turnPrefixMessages.length > 0) { let historyText = "No prior history."; let historyUsage: Usage | undefined; if (messagesToSummarize.length > 0) { const historyResult = await generateSummaryWithUsage( messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, env, retry, callbacks, sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; } const turnPrefixResult = await generateTurnPrefixSummary( turnPrefixMessages, model, settings.reserveTokens, apiKey, headers, env, signal, thinkingLevel, streamFn, retry, callbacks, sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage; } else { // Just generate history summary const result = await generateSummaryWithUsage( messagesToSummarize, model, settings.reserveTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, env, retry, callbacks, sessionId, ); summary = result.text; summaryUsage = result.usage; } // Compute file lists and append to summary const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); if (!firstKeptEntryId) { throw new Error("First kept entry has no UUID - session may need migration"); } return { summary, firstKeptEntryId, tokensBefore, usage: summaryUsage, details: { readFiles, modifiedFiles } as CompactionDetails, }; } /** * Generate a summary for a turn prefix (when splitting a turn). */ async function generateTurnPrefixSummary( messages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string | undefined, headers?: Record, env?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); // Smaller budget for turn prefix const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; const response = await completeSummarization( model, buildSummarizationContext(promptText), createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, ); const failure = getSummarizationFailure(response, "Turn prefix summarization"); if (failure) { throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Turn prefix summarization attempted to call a tool"); } return { text: contentText(response.content), usage: response.usage, }; }