Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/core/compaction.ts

typescript1033 行查看原文
// Adapted from Pi cd34e17ff039502f3664d8363b3c0a23f93a2ca3.
// Copyright (c) 2025 Mario Zechner. MIT: licenses/third-party-notices.txt.
import { randomUUID } from "node:crypto";
/**
 * 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 "@zpi-dev/agent";
import type { AssistantMessage, Model, SimpleStreamOptions, TranscriptContext, Usage } from "@zpi-dev/ai";
import {
  calculateContextTokens,
  contentText,
  estimateMessageTokens,
  isModelContextExceededError,
  isModelMediaTooLargeError,
  normalizeContext,
  projectCompactMediaForRetry,
  projectContextForModelMediaPolicy,
  type RetryCallbacks,
  type RetryPolicy,
  retryAssistantCall,
  streamSimple,
} from "@zpi-dev/ai";

const convertToLlm = (messages: AgentMessage[]) => messages;

import {
  computeFileLists,
  createFileOps,
  extractFileOpsFromMessage,
  type FileOperations,
  formatFileOperations,
  SUMMARIZATION_SYSTEM_PROMPT,
  serializeConversation,
} from "./compaction-utils.ts";
import {
  buildSessionContext,
  type CompactionEntry,
  type SessionEntry,
  sessionEntryToContextMessages,
} from "./session-manager.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.details) {
      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;
  }
  // System messages are prompt state, not conversation; the compaction entry carries their replay.
  const message = sessionEntryToContextMessages(entry)[0];
  return message?.role === "system" ? undefined : message;
}

/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
export interface CompactionResult<T = unknown> {
  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.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
// ============================================================================

export { calculateContextTokens };

/**
 * 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;
}

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
// ============================================================================

/**
 * Estimate token count for a message using chars/4 heuristic.
 * This is conservative (overestimates tokens).
 */
export function estimateTokens(message: AgentMessage): number {
  switch (message.role) {
    case "user":
    case "assistant":
    case "toolResult":
      return estimateMessageTokens(message);
  }
  return 0;
}

function isCutPointMessage(message: AgentMessage): boolean {
  switch (message.role) {
    case "user":
    case "assistant":
      return true;
    case "toolResult":
      return false;
  }
  return false;
}

function isTurnStartMessage(message: AgentMessage): boolean {
  switch (message.role) {
    case "user":
      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,
  forceProgress = false,
): 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) {
      // Prefer the closest valid cut point at or after this entry. If trailing
      // tool results exceed the budget by themselves, keep their preceding
      // assistant tool call instead of falling back to the first message.
      cutIndex = cutPoints.find((candidate) => candidate >= i) ?? cutPoints[cutPoints.length - 1];
      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--;
  }

  if (forceProgress && cutPoints.length > 1 && cutIndex <= cutPoints[0]) {
    // Server usage can exceed local estimates. Summarize a complete turn when
    // possible; a single long turn still splits only at a valid message boundary.
    cutIndex =
      cutPoints.find((index) => index > cutPoints[0] && isTurnStartEntry(entries[index])) ??
      cutPoints[cutPoints.length - 1];
  }

  // 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 <previous-summary> 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 === "aborted") {
    return `${label} failed: generation was aborted`;
  }
  if (response.stopReason === "length") {
    return `${label} failed: generation hit the token cap and the summary is incomplete`;
  }
  if (response.content.some((block) => block.type === "toolCall")) {
    return `${label} attempted to call a tool`;
  }
  if (!contentText(response.content).trim()) {
    return `${label} failed: model returned an empty summary`;
  }
  return undefined;
}

function createSummarizationOptions(
  model: Model,
  maxTokens: number,
  apiKey: string | undefined,
  headers: Record<string, string> | undefined,
  signal: AbortSignal | undefined,
  thinkingLevel: ThinkingLevel | undefined,
  sessionId: string | undefined,
  onMediaProjection?: SimpleStreamOptions["onMediaProjection"],
): SimpleStreamOptions {
  const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, sessionId, onMediaProjection };
  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: TranscriptContext,
  options: SimpleStreamOptions,
  streamFn?: StreamFn,
  retry?: RetryPolicy,
  callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
  // 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 ?? randomUUID(),
  };
  const produce = async (deadlineMs?: number): Promise<AssistantMessage> => {
    const attemptOptions = {
      ...requestOptions,
      streamDeadlineMs: deadlineMs ?? requestOptions.streamDeadlineMs,
    };
    let requestContext = context;
    let strippedMedia = false;
    let contextTrims = 0;
    const recover = (error: unknown): boolean => {
      if (isModelMediaTooLargeError(error) && !strippedMedia) {
        strippedMedia = true;
        requestContext = {
          ...requestContext,
          messages: projectCompactMediaForRetry(requestContext.messages).messages,
        };
        return true;
      }
      if (isModelContextExceededError(error) && contextTrims < 3) {
        const truncated = truncateSummaryRequest(requestContext);
        if (truncated === requestContext) return false;
        contextTrims++;
        requestContext = truncated;
        return true;
      }
      return false;
    };
    while (true) {
      requestOptions.signal?.throwIfAborted();
      try {
        // Injected streams may bypass native adapters. Keep this defensive projection;
        // an adapter reached through the injected stream sees no new omissions and reports nothing twice.
        const projected = streamFn
          ? projectContextForModelMediaPolicy(requestContext, model, {
              onProjection: requestOptions.onMediaProjection,
            })
          : requestContext;
        const result = await (streamFn
          ? await streamFn(model, projected, attemptOptions)
          : streamSimple(model, projected, attemptOptions)
        ).result();
        if (
          result.stopReason === "error" &&
          recover({
            message: result.errorMessage,
            code: result.errorDetails?.code,
            reason: result.rawStopReason,
          })
        )
          continue;
        return result;
      } catch (error) {
        if (!requestOptions.signal?.aborted && recover(error)) continue;
        throw error;
      }
    }
  };
  return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
}

/** Only recognized context overflow reaches this bounded request-local truncation. */
function truncateSummaryRequest(context: TranscriptContext): TranscriptContext {
  let changed = false;
  const truncate = (text: string) => {
    if (text.length < 256) return text;
    changed = true;
    // Retain the summary instructions and the newest conversation text after dropping older text.
    const open = text.indexOf("<conversation>\n");
    const close = text.indexOf("\n</conversation>");
    if (open >= 0 && close > open) {
      const start = open + "<conversation>\n".length;
      return (
        text.slice(0, start) +
        "[Earlier conversation truncated]\n" +
        text.slice(start + Math.floor((close - start) / 2))
      );
    }
    return `[Earlier content truncated]\n${text.slice(Math.floor(text.length / 2))}`;
  };
  const messages = context.messages.map((message) =>
    message.role !== "user"
      ? message
      : {
          ...message,
          content:
            typeof message.content === "string"
              ? truncate(message.content)
              : message.content.map((block) =>
                  block.type === "text" ? { ...block, text: truncate(block.text) } : block,
                ),
        },
  );
  return changed ? { ...context, messages } : context;
}

/** Build the provider context for a standalone summary request. */
function buildSummarizationContext(promptText: string): TranscriptContext {
  return normalizeContext({
    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<string, string>,
  signal?: AbortSignal,
  customInstructions?: string,
  previousSummary?: string,
  thinkingLevel?: ThinkingLevel,
  streamFn?: StreamFn,
  retry?: RetryPolicy,
  callbacks?: RetryCallbacks,
  sessionId?: string,
  onMediaProjection?: SimpleStreamOptions["onMediaProjection"],
): Promise<{ text: string; usage: Usage }> {
  const maxTokens = Math.min(
    Math.max(1, 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 = `<conversation>\n${conversationText}\n</conversation>\n\n`;
  if (previousSummary) {
    promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
  }
  promptText += basePrompt;

  const completionOptions = createSummarizationOptions(
    model,
    maxTokens,
    apiKey,
    headers,
    signal,
    thinkingLevel,
    sessionId,
    onMediaProjection,
  );

  const response = await completeSummarization(
    model,
    buildSummarizationContext(promptText),
    completionOptions,
    streamFn,
    retry,
    callbacks,
  );

  const failure = getSummarizationFailure(response, "Summarization");
  if (failure) {
    throw new Error(failure);
  }
  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,
    tokensBefore > 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<string, string>,
  customInstructions?: string,
  signal?: AbortSignal,
  thinkingLevel?: ThinkingLevel,
  streamFn?: StreamFn,
  retry?: RetryPolicy,
  callbacks?: RetryCallbacks,
  sessionId?: string,
  onMediaProjection?: SimpleStreamOptions["onMediaProjection"],
): Promise<CompactionResult> {
  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 = previousSummary ?? "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,
        retry,
        callbacks,
        sessionId,
        onMediaProjection,
      );
      historyText = historyResult.text;
      historyUsage = historyResult.usage;
    }
    const turnPrefixResult = await generateTurnPrefixSummary(
      turnPrefixMessages,
      model,
      settings.reserveTokens,
      apiKey,
      headers,
      signal,
      thinkingLevel,
      streamFn,
      retry,
      callbacks,
      sessionId,
      onMediaProjection,
    );
    // 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,
      retry,
      callbacks,
      sessionId,
      onMediaProjection,
    );
    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<string, string>,
  signal?: AbortSignal,
  thinkingLevel?: ThinkingLevel,
  streamFn?: StreamFn,
  retry?: RetryPolicy,
  callbacks?: RetryCallbacks,
  sessionId?: string,
  onMediaProjection?: SimpleStreamOptions["onMediaProjection"],
): 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 = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;

  const response = await completeSummarization(
    model,
    buildSummarizationContext(promptText),
    createSummarizationOptions(
      model,
      maxTokens,
      apiKey,
      headers,
      signal,
      thinkingLevel,
      sessionId,
      onMediaProjection,
    ),
    streamFn,
    retry,
    callbacks,
  );

  const failure = getSummarizationFailure(response, "Turn prefix summarization");
  if (failure) {
    throw new Error(failure);
  }
  return {
    text: contentText(response.content),
    usage: response.usage,
  };
}

export interface CompactionOptions {
  microcompact?: import("./microcompact.ts").MicrocompactConfig;
  enabled?: boolean;
  reserveTokens?: number;
  keepRecentTokens?: number;
  modelOverrides?: Record<string, { reserveTokens?: number; keepRecentTokens?: number }>;
}