ππ Agent

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 */
7
8import 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";
28
29// ============================================================================
30// File Operation Tracking
31// ============================================================================
32
33/** Details stored in CompactionEntry.details for file tracking */
34export interface CompactionDetails {
35 readFiles: string[];
36 modifiedFiles: string[];
37}
38
39/**
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();
48
49 // 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 compatibility
54 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 }
63
64 // Extract from tool calls in messages
65 for (const msg of messages) {
66 extractFileOpsFromMessage(msg, fileOps);
67 }
68
69 return fileOps;
70}
71
72// ============================================================================
73// Message Extraction
74// ============================================================================
75
76/**
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}
86
87/** 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}
98
99function 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 !== undefined
106 ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) }
107 : {}),
108 ...(first.reasoning !== undefined || second.reasoning !== undefined
109 ? { 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}
121
122// ============================================================================
123// Types
124// ============================================================================
125
126export interface CompactionSettings {
127 enabled: boolean;
128 reserveTokens: number;
129 keepRecentTokens: number;
130}
131
132export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
133 enabled: true,
134 reserveTokens: 16384,
135 keepRecentTokens: 20000,
136};
137
138// ============================================================================
139// Token calculation
140// ============================================================================
141
142/**
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}
149
150/**
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) > 0
162 ) {
163 return assistantMsg.usage;
164 }
165 }
166 return undefined;
167}
168
169/**
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}
182
183export interface ContextUsageEstimate {
184 tokens: number;
185 usageTokens: number;
186 trailingTokens: number;
187 lastUsageIndex: number | null;
188}
189
190function 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}
197
198/**
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);
204
205 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 }
217
218 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 }
223
224 return {
225 tokens: usageTokens + trailingTokens,
226 usageTokens,
227 trailingTokens,
228 lastUsageIndex: usageInfo.index,
229 };
230}
231
232/**
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}
239
240// ============================================================================
241// Cut point detection
242// ============================================================================
243
244const ESTIMATED_IMAGE_CHARS = 4800;
245
246function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
247 if (typeof content === "string") {
248 return content.length;
249 }
250
251 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}
261
262/**
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;
268
269 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 }
304
305 return 0;
306}
307
308function 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}
322
323function 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}
337
338function isTurnStartEntry(entry: SessionEntry): boolean {
339 if (entry.type === "compaction") {
340 return false;
341 }
342 return sessionEntryToContextMessages(entry).some(isTurnStartMessage);
343}
344
345/**
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 it
349 * 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}
364
365/**
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}
377
378export 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}
386
387/**
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 an
394 * 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 from
398 * - turnStartIndex: if cutting mid-turn, the user message that started that turn
399 * - isSplitTurn: whether we're cutting in the middle of a turn
400 *
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);
410
411 if (cutPoints.length === 0) {
412 return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
413 }
414
415 // Walk backwards from newest, accumulating estimated message sizes
416 let accumulatedTokens = 0;
417 let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
418
419 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;
427
428 // Check if we've exceeded the budget
429 if (accumulatedTokens >= keepRecentTokens) {
430 // Find the closest valid cut point at or after this entry
431 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 }
440
441 // 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 }
450
451 // Determine if this is a split turn
452 const cutEntry = entries[cutIndex];
453 const startsTurn = isTurnStartEntry(cutEntry);
454 const turnStartIndex = startsTurn ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
455
456 return {
457 firstKeptEntryIndex: cutIndex,
458 turnStartIndex,
459 isSplitTurn: !startsTurn && turnStartIndex !== -1,
460 };
461}
462
463// ============================================================================
464// Summarization
465// ============================================================================
466
467const 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.
468
469Use this EXACT format:
470
471## Goal
472[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
473
474## Constraints & Preferences
475- [Any constraints, preferences, or requirements mentioned by user]
476- [Or "(none)" if none were mentioned]
477
478## Progress
479### Done
480- [x] [Completed tasks/changes]
481
482### In Progress
483- [ ] [Current work]
484
485### Blocked
486- [Issues preventing progress, if any]
487
488## Key Decisions
489- **[Decision]**: [Brief rationale]
490
491## Next Steps
4921. [Ordered list of what should happen next]
493
494## Critical Context
495- [Any data, examples, or references needed to continue]
496- [Or "(none)" if not applicable]
497
498Keep each section concise. Preserve exact file paths, function names, and error messages.`;
499
500const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
501
502Update the existing structured summary with new information. RULES:
503- PRESERVE all existing information from the previous summary
504- ADD new progress, decisions, and context from the new messages
505- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
506- UPDATE "Next Steps" based on what was accomplished
507- PRESERVE exact file paths, function names, and error messages
508- If something is no longer relevant, you may remove it
509
510Use this EXACT format:
511
512## Goal
513[Preserve existing goals, add new ones if the task expanded]
514
515## Constraints & Preferences
516- [Preserve existing, add new ones discovered]
517
518## Progress
519### Done
520- [x] [Include previously done items AND newly completed items]
521
522### In Progress
523- [ ] [Current work - update based on progress]
524
525### Blocked
526- [Current blockers - remove if resolved]
527
528## Key Decisions
529- **[Decision]**: [Brief rationale] (preserve all previous, add new)
530
531## Next Steps
5321. [Update based on current state]
533
534## Critical Context
535- [Preserve important context, add new if needed]
536
537Keep each section concise. Preserve exact file paths, function names, and error messages.`;
538
539function 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}
554
555/**
556 * Shared choke point for every compaction/branch-summary summarization call. Wraps the
557 * single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.
558 * `terminated`, socket close) honor the configured retry policy instead of failing
559 * the whole compaction on the first attempt. Deterministic errors and aborts return
560 * 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 streamFn
578 ? (await streamFn(model, context, requestOptions)).result()
579 : completeSimple(model, context, requestOptions);
580 return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
581}
582
583/**
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}
620
621/** 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 );
641
642 // Use update prompt if we have a previous summary, otherwise initial prompt
643 let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
644 if (customInstructions) {
645 basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
646 }
647
648 // Serialize conversation to text so model doesn't try to continue it
649 // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
650 const llmMessages = convertToLlm(currentMessages);
651 const conversationText = serializeConversation(llmMessages);
652
653 // Build the prompt with conversation wrapped in tags
654 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;
659
660 const summarizationMessages = [
661 {
662 role: "user" as const,
663 content: [{ type: "text" as const, text: promptText }],
664 timestamp: Date.now(),
665 },
666 ];
667
668 const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);
669
670 const response = await completeSummarization(
671 model,
672 { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
673 completionOptions,
674 streamFn,
675 retry,
676 callbacks,
677 );
678
679 if (response.stopReason === "error") {
680 throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
681 }
682
683 const textContent = contentText(response.content);
684
685 return { text: textContent, usage: response.usage };
686}
687
688// ============================================================================
689// Compaction Preparation (for extensions)
690// ============================================================================
691
692export 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}
709
710export 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 }
717
718 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 }
725
726 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;
735
736 const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
737
738 const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
739
740 // Get UUID of first kept entry
741 const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
742 if (!firstKeptEntry?.id) {
743 return undefined; // Session needs migration
744 }
745 const firstKeptEntryId = firstKeptEntry.id;
746
747 const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
748
749 // 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 }
755
756 // 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 }
764
765 if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
766 return undefined;
767 }
768
769 // Extract file operations from messages and previous compaction
770 const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
771
772 // Also extract file ops from turn prefix if splitting
773 if (cutPoint.isSplitTurn) {
774 for (const msg of turnPrefixMessages) {
775 extractFileOpsFromMessage(msg, fileOps);
776 }
777 }
778
779 return {
780 firstKeptEntryId,
781 messagesToSummarize,
782 turnPrefixMessages,
783 isSplitTurn: cutPoint.isSplitTurn,
784 tokensBefore,
785 previousSummary,
786 fileOps,
787 settings,
788 };
789}
790
791// ============================================================================
792// Main compaction function
793// ============================================================================
794
795const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
796
797Summarize the prefix to provide context for the retained suffix:
798
799## Original Request
800[What did the user ask for in this turn?]
801
802## Early Progress
803- [Key decisions and work done in the prefix]
804
805## Context for Suffix
806- [Information needed to understand the retained recent work]
807
808Be concise. Focus on what's needed to understand the kept suffix.`;
809
810/**
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 summary
816 */
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;
840
841 // Generate summaries and merge into one
842 let summary: string;
843 let summaryUsage: Usage;
844
845 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 summary
881 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 summary
885 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 }
903
904 // Compute file lists and append to summary
905 const { readFiles, modifiedFiles } = computeFileLists(fileOps);
906 summary += formatFileOperations(readFiles, modifiedFiles);
907
908 if (!firstKeptEntryId) {
909 throw new Error("First kept entry has no UUID - session may need migration");
910 }
911
912 return {
913 summary,
914 firstKeptEntryId,
915 tokensBefore,
916 usage: summaryUsage,
917 details: { readFiles, modifiedFiles } as CompactionDetails,
918 };
919}
920
921/**
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 prefix
941 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 ];
951
952 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 );
960
961 if (response.stopReason === "error") {
962 throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
963 }
964
965 return {
966 text: contentText(response.content),
967 usage: response.usage,
968 };
969}
970