compaction.ts
29 KB970 lines
compaction.ts
1/**2 * Context compaction for long sessions.3 *4 * Pure functions for compaction logic. The session manager handles I/O,5 * and after compaction the session is reloaded.6 */78import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";9import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai";10import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";11import { completeSimple } from "@earendil-works/pi-ai/compat";12import { convertToLlm } from "../messages.ts";13import {14 buildSessionContext,15 type CompactionEntry,16 type SessionEntry,17 sessionEntryToContextMessages,18} from "../session-manager.ts";19import {20 computeFileLists,21 createFileOps,22 extractFileOpsFromMessage,23 type FileOperations,24 formatFileOperations,25 SUMMARIZATION_SYSTEM_PROMPT,26 serializeConversation,27} from "./utils.ts";2829// ============================================================================30// File Operation Tracking31// ============================================================================3233/** Details stored in CompactionEntry.details for file tracking */34export interface CompactionDetails {35 readFiles: string[];36 modifiedFiles: string[];37}3839/**40 * Extract file operations from messages and previous compaction entries.41 */42function extractFileOperations(43 messages: AgentMessage[],44 entries: SessionEntry[],45 prevCompactionIndex: number,46): FileOperations {47 const fileOps = createFileOps();4849 // Collect from previous compaction's details (if pi-generated)50 if (prevCompactionIndex >= 0) {51 const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;52 if (!prevCompaction.fromHook && prevCompaction.details) {53 // fromHook field kept for session file compatibility54 const details = prevCompaction.details as CompactionDetails;55 if (Array.isArray(details.readFiles)) {56 for (const f of details.readFiles) fileOps.read.add(f);57 }58 if (Array.isArray(details.modifiedFiles)) {59 for (const f of details.modifiedFiles) fileOps.edited.add(f);60 }61 }62 }6364 // Extract from tool calls in messages65 for (const msg of messages) {66 extractFileOpsFromMessage(msg, fileOps);67 }6869 return fileOps;70}7172// ============================================================================73// Message Extraction74// ============================================================================7576/**77 * Extract AgentMessage from an entry if it produces one.78 * Returns undefined for entries that don't contribute to LLM context.79 */80function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | undefined {81 if (entry.type === "compaction") {82 return undefined;83 }84 return sessionEntryToContextMessages(entry)[0];85}8687/** Result from compact() - SessionManager adds uuid/parentUuid when saving */88export interface CompactionResult<T = unknown> {89 summary: string;90 firstKeptEntryId: string;91 tokensBefore: number;92 estimatedTokensAfter?: number;93 /** Usage from the LLM call(s) that generated this summary, if available */94 usage?: Usage;95 /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */96 details?: T;97}9899function combineUsage(first: Usage, second: Usage): Usage {100 return {101 input: first.input + second.input,102 output: first.output + second.output,103 cacheRead: first.cacheRead + second.cacheRead,104 cacheWrite: first.cacheWrite + second.cacheWrite,105 ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined106 ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) }107 : {}),108 ...(first.reasoning !== undefined || second.reasoning !== undefined109 ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) }110 : {}),111 totalTokens: first.totalTokens + second.totalTokens,112 cost: {113 input: first.cost.input + second.cost.input,114 output: first.cost.output + second.cost.output,115 cacheRead: first.cost.cacheRead + second.cost.cacheRead,116 cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,117 total: first.cost.total + second.cost.total,118 },119 };120}121122// ============================================================================123// Types124// ============================================================================125126export interface CompactionSettings {127 enabled: boolean;128 reserveTokens: number;129 keepRecentTokens: number;130}131132export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {133 enabled: true,134 reserveTokens: 16384,135 keepRecentTokens: 20000,136};137138// ============================================================================139// Token calculation140// ============================================================================141142/**143 * Calculate total context tokens from usage.144 * Uses the native totalTokens field when available, falls back to computing from components.145 */146export function calculateContextTokens(usage: Usage): number {147 return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;148}149150/**151 * Get usage from an assistant message if available.152 * Skips aborted, error, and all-zero usage messages as they don't have valid usage data.153 */154function getAssistantUsage(msg: AgentMessage): Usage | undefined {155 if (msg.role === "assistant" && "usage" in msg) {156 const assistantMsg = msg as AssistantMessage;157 if (158 assistantMsg.stopReason !== "aborted" &&159 assistantMsg.stopReason !== "error" &&160 assistantMsg.usage &&161 calculateContextTokens(assistantMsg.usage) > 0162 ) {163 return assistantMsg.usage;164 }165 }166 return undefined;167}168169/**170 * Find the last valid assistant message usage from session entries.171 */172export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {173 for (let i = entries.length - 1; i >= 0; i--) {174 const entry = entries[i];175 if (entry.type === "message") {176 const usage = getAssistantUsage(entry.message);177 if (usage) return usage;178 }179 }180 return undefined;181}182183export interface ContextUsageEstimate {184 tokens: number;185 usageTokens: number;186 trailingTokens: number;187 lastUsageIndex: number | null;188}189190function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined {191 for (let i = messages.length - 1; i >= 0; i--) {192 const usage = getAssistantUsage(messages[i]);193 if (usage) return { usage, index: i };194 }195 return undefined;196}197198/**199 * Estimate context tokens from messages, using the last assistant usage when available.200 * If there are messages after the last usage, estimate their tokens with estimateTokens.201 */202export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {203 const usageInfo = getLastAssistantUsageInfo(messages);204205 if (!usageInfo) {206 let estimated = 0;207 for (const message of messages) {208 estimated += estimateTokens(message);209 }210 return {211 tokens: estimated,212 usageTokens: 0,213 trailingTokens: estimated,214 lastUsageIndex: null,215 };216 }217218 const usageTokens = calculateContextTokens(usageInfo.usage);219 let trailingTokens = 0;220 for (let i = usageInfo.index + 1; i < messages.length; i++) {221 trailingTokens += estimateTokens(messages[i]);222 }223224 return {225 tokens: usageTokens + trailingTokens,226 usageTokens,227 trailingTokens,228 lastUsageIndex: usageInfo.index,229 };230}231232/**233 * Check if compaction should trigger based on context usage.234 */235export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {236 if (!settings.enabled) return false;237 return contextTokens > contextWindow - settings.reserveTokens;238}239240// ============================================================================241// Cut point detection242// ============================================================================243244const ESTIMATED_IMAGE_CHARS = 4800;245246function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {247 if (typeof content === "string") {248 return content.length;249 }250251 let chars = 0;252 for (const block of content) {253 if (block.type === "text" && block.text) {254 chars += block.text.length;255 } else if (block.type === "image") {256 chars += ESTIMATED_IMAGE_CHARS;257 }258 }259 return chars;260}261262/**263 * Estimate token count for a message using chars/4 heuristic.264 * This is conservative (overestimates tokens).265 */266export function estimateTokens(message: AgentMessage): number {267 let chars = 0;268269 switch (message.role) {270 case "user": {271 chars = estimateTextAndImageContentChars(272 (message as { content: string | Array<{ type: string; text?: string }> }).content,273 );274 return Math.ceil(chars / 4);275 }276 case "assistant": {277 const assistant = message as AssistantMessage;278 for (const block of assistant.content) {279 if (block.type === "text") {280 chars += block.text.length;281 } else if (block.type === "thinking") {282 chars += block.thinking.length;283 } else if (block.type === "toolCall") {284 chars += block.name.length + JSON.stringify(block.arguments).length;285 }286 }287 return Math.ceil(chars / 4);288 }289 case "custom":290 case "toolResult": {291 chars = estimateTextAndImageContentChars(message.content);292 return Math.ceil(chars / 4);293 }294 case "bashExecution": {295 chars = message.command.length + message.output.length;296 return Math.ceil(chars / 4);297 }298 case "branchSummary":299 case "compactionSummary": {300 chars = message.summary.length;301 return Math.ceil(chars / 4);302 }303 }304305 return 0;306}307308function isCutPointMessage(message: AgentMessage): boolean {309 switch (message.role) {310 case "user":311 case "assistant":312 case "bashExecution":313 case "custom":314 case "branchSummary":315 case "compactionSummary":316 return true;317 case "toolResult":318 return false;319 }320 return false;321}322323function isTurnStartMessage(message: AgentMessage): boolean {324 switch (message.role) {325 case "user":326 case "bashExecution":327 case "custom":328 case "branchSummary":329 case "compactionSummary":330 return true;331 case "assistant":332 case "toolResult":333 return false;334 }335 return false;336}337338function isTurnStartEntry(entry: SessionEntry): boolean {339 if (entry.type === "compaction") {340 return false;341 }342 return sessionEntryToContextMessages(entry).some(isTurnStartMessage);343}344345/**346 * Find valid cut points: indices of context-visible user-like or assistant messages.347 * Never cut at tool results (they must follow their tool call).348 * When we cut at an assistant message with tool calls, its tool results follow it349 * and will be kept.350 */351function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {352 const cutPoints: number[] = [];353 for (let i = startIndex; i < endIndex; i++) {354 const entry = entries[i];355 if (entry.type === "compaction") {356 continue;357 }358 if (sessionEntryToContextMessages(entry).some(isCutPointMessage)) {359 cutPoints.push(i);360 }361 }362 return cutPoints;363}364365/**366 * Find the context-visible user-role message that starts the turn containing the given entry index.367 * Returns -1 if no turn start found before the index.368 */369export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {370 for (let i = entryIndex; i >= startIndex; i--) {371 if (isTurnStartEntry(entries[i])) {372 return i;373 }374 }375 return -1;376}377378export interface CutPointResult {379 /** Index of first entry to keep */380 firstKeptEntryIndex: number;381 /** Index of user message that starts the turn being split, or -1 if not splitting */382 turnStartIndex: number;383 /** Whether this cut splits a turn (cut point is not a user message) */384 isSplitTurn: boolean;385}386387/**388 * Find the cut point in session entries that keeps approximately `keepRecentTokens`.389 *390 * Algorithm: Walk backwards from newest, accumulating estimated message sizes.391 * Stop when we've accumulated >= keepRecentTokens. Cut at that point.392 *393 * Can cut at user OR assistant messages (never tool results). When cutting at an394 * assistant message with tool calls, its tool results come after and will be kept.395 *396 * Returns CutPointResult with:397 * - firstKeptEntryIndex: the entry index to start keeping from398 * - turnStartIndex: if cutting mid-turn, the user message that started that turn399 * - isSplitTurn: whether we're cutting in the middle of a turn400 *401 * Only considers entries between `startIndex` and `endIndex` (exclusive).402 */403export function findCutPoint(404 entries: SessionEntry[],405 startIndex: number,406 endIndex: number,407 keepRecentTokens: number,408): CutPointResult {409 const cutPoints = findValidCutPoints(entries, startIndex, endIndex);410411 if (cutPoints.length === 0) {412 return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };413 }414415 // Walk backwards from newest, accumulating estimated message sizes416 let accumulatedTokens = 0;417 let cutIndex = cutPoints[0]; // Default: keep from first message (not header)418419 for (let i = endIndex - 1; i >= startIndex; i--) {420 const entry = entries[i];421 const messageTokens = sessionEntryToContextMessages(entry).reduce(422 (sum, message) => sum + estimateTokens(message),423 0,424 );425 if (messageTokens === 0) continue;426 accumulatedTokens += messageTokens;427428 // Check if we've exceeded the budget429 if (accumulatedTokens >= keepRecentTokens) {430 // Find the closest valid cut point at or after this entry431 for (let c = 0; c < cutPoints.length; c++) {432 if (cutPoints[c] >= i) {433 cutIndex = cutPoints[c];434 break;435 }436 }437 break;438 }439 }440441 // Scan backwards from cutIndex to include adjacent metadata entries that do not affect context.442 while (cutIndex > startIndex) {443 const prevEntry = entries[cutIndex - 1];444 // Stop at compaction boundaries or context-visible entries.445 if (prevEntry.type === "compaction" || sessionEntryToContextMessages(prevEntry).length > 0) {446 break;447 }448 cutIndex--;449 }450451 // Determine if this is a split turn452 const cutEntry = entries[cutIndex];453 const startsTurn = isTurnStartEntry(cutEntry);454 const turnStartIndex = startsTurn ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);455456 return {457 firstKeptEntryIndex: cutIndex,458 turnStartIndex,459 isSplitTurn: !startsTurn && turnStartIndex !== -1,460 };461}462463// ============================================================================464// Summarization465// ============================================================================466467const 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.468469Use this EXACT format:470471## Goal472[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]473474## Constraints & Preferences475- [Any constraints, preferences, or requirements mentioned by user]476- [Or "(none)" if none were mentioned]477478## Progress479### Done480- [x] [Completed tasks/changes]481482### In Progress483- [ ] [Current work]484485### Blocked486- [Issues preventing progress, if any]487488## Key Decisions489- **[Decision]**: [Brief rationale]490491## Next Steps4921. [Ordered list of what should happen next]493494## Critical Context495- [Any data, examples, or references needed to continue]496- [Or "(none)" if not applicable]497498Keep each section concise. Preserve exact file paths, function names, and error messages.`;499500const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.501502Update the existing structured summary with new information. RULES:503- PRESERVE all existing information from the previous summary504- ADD new progress, decisions, and context from the new messages505- UPDATE the Progress section: move items from "In Progress" to "Done" when completed506- UPDATE "Next Steps" based on what was accomplished507- PRESERVE exact file paths, function names, and error messages508- If something is no longer relevant, you may remove it509510Use this EXACT format:511512## Goal513[Preserve existing goals, add new ones if the task expanded]514515## Constraints & Preferences516- [Preserve existing, add new ones discovered]517518## Progress519### Done520- [x] [Include previously done items AND newly completed items]521522### In Progress523- [ ] [Current work - update based on progress]524525### Blocked526- [Current blockers - remove if resolved]527528## Key Decisions529- **[Decision]**: [Brief rationale] (preserve all previous, add new)530531## Next Steps5321. [Update based on current state]533534## Critical Context535- [Preserve important context, add new if needed]536537Keep each section concise. Preserve exact file paths, function names, and error messages.`;538539function createSummarizationOptions(540 model: Model<any>,541 maxTokens: number,542 apiKey: string | undefined,543 headers: Record<string, string> | undefined,544 env: Record<string, string> | undefined,545 signal: AbortSignal | undefined,546 thinkingLevel: ThinkingLevel | undefined,547): SimpleStreamOptions {548 const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env };549 if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {550 options.reasoning = thinkingLevel;551 }552 return options;553}554555/**556 * Shared choke point for every compaction/branch-summary summarization call. Wraps the557 * single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.558 * `terminated`, socket close) honor the configured retry policy instead of failing559 * the whole compaction on the first attempt. Deterministic errors and aborts return560 * immediately (see {@link retryAssistantCall}).561 */562export async function completeSummarization(563 model: Model<any>,564 context: Context,565 options: SimpleStreamOptions,566 streamFn?: StreamFn,567 retry?: RetryPolicy,568 callbacks?: RetryCallbacks,569): Promise<AssistantMessage> {570 // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.571 const requestOptions: SimpleStreamOptions = {572 ...options,573 cacheRetention: "none",574 sessionId: uuidv7(),575 };576 const produce = async (): Promise<AssistantMessage> =>577 streamFn578 ? (await streamFn(model, context, requestOptions)).result()579 : completeSimple(model, context, requestOptions);580 return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);581}582583/**584 * Generate a summary of the conversation using the LLM.585 * If previousSummary is provided, uses the update prompt to merge.586 */587export async function generateSummary(588 currentMessages: AgentMessage[],589 model: Model<any>,590 reserveTokens: number,591 apiKey: string | undefined,592 headers?: Record<string, string>,593 signal?: AbortSignal,594 customInstructions?: string,595 previousSummary?: string,596 thinkingLevel?: ThinkingLevel,597 streamFn?: StreamFn,598 env?: Record<string, string>,599 retry?: RetryPolicy,600 callbacks?: RetryCallbacks,601): Promise<string> {602 return (603 await generateSummaryWithUsage(604 currentMessages,605 model,606 reserveTokens,607 apiKey,608 headers,609 signal,610 customInstructions,611 previousSummary,612 thinkingLevel,613 streamFn,614 env,615 retry,616 callbacks,617 )618 ).text;619}620621/** Generate or update a conversation summary and return its provider usage. */622export async function generateSummaryWithUsage(623 currentMessages: AgentMessage[],624 model: Model<any>,625 reserveTokens: number,626 apiKey: string | undefined,627 headers?: Record<string, string>,628 signal?: AbortSignal,629 customInstructions?: string,630 previousSummary?: string,631 thinkingLevel?: ThinkingLevel,632 streamFn?: StreamFn,633 env?: Record<string, string>,634 retry?: RetryPolicy,635 callbacks?: RetryCallbacks,636): Promise<{ text: string; usage: Usage }> {637 const maxTokens = Math.min(638 Math.floor(0.8 * reserveTokens),639 model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,640 );641642 // Use update prompt if we have a previous summary, otherwise initial prompt643 let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;644 if (customInstructions) {645 basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;646 }647648 // Serialize conversation to text so model doesn't try to continue it649 // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)650 const llmMessages = convertToLlm(currentMessages);651 const conversationText = serializeConversation(llmMessages);652653 // Build the prompt with conversation wrapped in tags654 let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;655 if (previousSummary) {656 promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;657 }658 promptText += basePrompt;659660 const summarizationMessages = [661 {662 role: "user" as const,663 content: [{ type: "text" as const, text: promptText }],664 timestamp: Date.now(),665 },666 ];667668 const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);669670 const response = await completeSummarization(671 model,672 { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },673 completionOptions,674 streamFn,675 retry,676 callbacks,677 );678679 if (response.stopReason === "error") {680 throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);681 }682683 const textContent = contentText(response.content);684685 return { text: textContent, usage: response.usage };686}687688// ============================================================================689// Compaction Preparation (for extensions)690// ============================================================================691692export interface CompactionPreparation {693 /** UUID of first entry to keep */694 firstKeptEntryId: string;695 /** Messages that will be summarized and discarded */696 messagesToSummarize: AgentMessage[];697 /** Messages that will be turned into turn prefix summary (if splitting) */698 turnPrefixMessages: AgentMessage[];699 /** Whether this is a split turn (cut point in middle of turn) */700 isSplitTurn: boolean;701 tokensBefore: number;702 /** Summary from previous compaction, for iterative update */703 previousSummary?: string;704 /** File operations extracted from messagesToSummarize */705 fileOps: FileOperations;706 /** Compaction settions from settings.jsonl */707 settings: CompactionSettings;708}709710export function prepareCompaction(711 pathEntries: SessionEntry[],712 settings: CompactionSettings,713): CompactionPreparation | undefined {714 if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {715 return undefined;716 }717718 let prevCompactionIndex = -1;719 for (let i = pathEntries.length - 1; i >= 0; i--) {720 if (pathEntries[i].type === "compaction") {721 prevCompactionIndex = i;722 break;723 }724 }725726 let previousSummary: string | undefined;727 let boundaryStart = 0;728 if (prevCompactionIndex >= 0) {729 const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;730 previousSummary = prevCompaction.summary;731 const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);732 boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;733 }734 const boundaryEnd = pathEntries.length;735736 const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;737738 const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);739740 // Get UUID of first kept entry741 const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];742 if (!firstKeptEntry?.id) {743 return undefined; // Session needs migration744 }745 const firstKeptEntryId = firstKeptEntry.id;746747 const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;748749 // Messages to summarize (will be discarded after summary)750 const messagesToSummarize: AgentMessage[] = [];751 for (let i = boundaryStart; i < historyEnd; i++) {752 const msg = getMessageFromEntryForCompaction(pathEntries[i]);753 if (msg) messagesToSummarize.push(msg);754 }755756 // Messages for turn prefix summary (if splitting a turn)757 const turnPrefixMessages: AgentMessage[] = [];758 if (cutPoint.isSplitTurn) {759 for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {760 const msg = getMessageFromEntryForCompaction(pathEntries[i]);761 if (msg) turnPrefixMessages.push(msg);762 }763 }764765 if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {766 return undefined;767 }768769 // Extract file operations from messages and previous compaction770 const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);771772 // Also extract file ops from turn prefix if splitting773 if (cutPoint.isSplitTurn) {774 for (const msg of turnPrefixMessages) {775 extractFileOpsFromMessage(msg, fileOps);776 }777 }778779 return {780 firstKeptEntryId,781 messagesToSummarize,782 turnPrefixMessages,783 isSplitTurn: cutPoint.isSplitTurn,784 tokensBefore,785 previousSummary,786 fileOps,787 settings,788 };789}790791// ============================================================================792// Main compaction function793// ============================================================================794795const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.796797Summarize the prefix to provide context for the retained suffix:798799## Original Request800[What did the user ask for in this turn?]801802## Early Progress803- [Key decisions and work done in the prefix]804805## Context for Suffix806- [Information needed to understand the retained recent work]807808Be concise. Focus on what's needed to understand the kept suffix.`;809810/**811 * Generate summaries for compaction using prepared data.812 * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.813 *814 * @param preparation - Pre-calculated preparation from prepareCompaction()815 * @param customInstructions - Optional custom focus for the summary816 */817export async function compact(818 preparation: CompactionPreparation,819 model: Model<any>,820 apiKey: string | undefined,821 headers?: Record<string, string>,822 customInstructions?: string,823 signal?: AbortSignal,824 thinkingLevel?: ThinkingLevel,825 streamFn?: StreamFn,826 env?: Record<string, string>,827 retry?: RetryPolicy,828 callbacks?: RetryCallbacks,829): Promise<CompactionResult> {830 const {831 firstKeptEntryId,832 messagesToSummarize,833 turnPrefixMessages,834 isSplitTurn,835 tokensBefore,836 previousSummary,837 fileOps,838 settings,839 } = preparation;840841 // Generate summaries and merge into one842 let summary: string;843 let summaryUsage: Usage;844845 if (isSplitTurn && turnPrefixMessages.length > 0) {846 let historyText = "No prior history.";847 let historyUsage: Usage | undefined;848 if (messagesToSummarize.length > 0) {849 const historyResult = await generateSummaryWithUsage(850 messagesToSummarize,851 model,852 settings.reserveTokens,853 apiKey,854 headers,855 signal,856 customInstructions,857 previousSummary,858 thinkingLevel,859 streamFn,860 env,861 retry,862 callbacks,863 );864 historyText = historyResult.text;865 historyUsage = historyResult.usage;866 }867 const turnPrefixResult = await generateTurnPrefixSummary(868 turnPrefixMessages,869 model,870 settings.reserveTokens,871 apiKey,872 headers,873 env,874 signal,875 thinkingLevel,876 streamFn,877 retry,878 callbacks,879 );880 // Merge into single summary881 summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;882 summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage;883 } else {884 // Just generate history summary885 const result = await generateSummaryWithUsage(886 messagesToSummarize,887 model,888 settings.reserveTokens,889 apiKey,890 headers,891 signal,892 customInstructions,893 previousSummary,894 thinkingLevel,895 streamFn,896 env,897 retry,898 callbacks,899 );900 summary = result.text;901 summaryUsage = result.usage;902 }903904 // Compute file lists and append to summary905 const { readFiles, modifiedFiles } = computeFileLists(fileOps);906 summary += formatFileOperations(readFiles, modifiedFiles);907908 if (!firstKeptEntryId) {909 throw new Error("First kept entry has no UUID - session may need migration");910 }911912 return {913 summary,914 firstKeptEntryId,915 tokensBefore,916 usage: summaryUsage,917 details: { readFiles, modifiedFiles } as CompactionDetails,918 };919}920921/**922 * Generate a summary for a turn prefix (when splitting a turn).923 */924async function generateTurnPrefixSummary(925 messages: AgentMessage[],926 model: Model<any>,927 reserveTokens: number,928 apiKey: string | undefined,929 headers?: Record<string, string>,930 env?: Record<string, string>,931 signal?: AbortSignal,932 thinkingLevel?: ThinkingLevel,933 streamFn?: StreamFn,934 retry?: RetryPolicy,935 callbacks?: RetryCallbacks,936): Promise<{ text: string; usage: Usage }> {937 const maxTokens = Math.min(938 Math.floor(0.5 * reserveTokens),939 model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,940 ); // Smaller budget for turn prefix941 const llmMessages = convertToLlm(messages);942 const conversationText = serializeConversation(llmMessages);943 const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;944 const summarizationMessages = [945 {946 role: "user" as const,947 content: [{ type: "text" as const, text: promptText }],948 timestamp: Date.now(),949 },950 ];951952 const response = await completeSummarization(953 model,954 { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },955 createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),956 streamFn,957 retry,958 callbacks,959 );960961 if (response.stopReason === "error") {962 throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);963 }964965 return {966 text: contentText(response.content),967 usage: response.usage,968 };969}970