19 kB 409 lines
bee1dc82
⋯
packages/coding-agent/docs/compaction.md
download raw history
at main Markdown

Compaction & Branch Summarization #

LLMs have limited context windows. When conversations grow too long, Prime Agent uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.

Source files:

For TypeScript definitions in your project, inspect node_modules/@earendil-works/pi-coding-agent/dist/.

Overview #

Prime Agent has two summarization mechanisms:

Mechanism Trigger Purpose
Compaction Context exceeds threshold, or /compact Summarize old messages to free up context
Branch summarization /tree navigation Preserve context when switching branches

Both use the same structured summary format and track file operations cumulatively.

Compaction #

When It Triggers #

Auto-compaction triggers when:

contextTokens > contextWindow - reserveTokens

By default, reserveTokens is 16384 tokens (configurable in ~/.prime/agent/settings.json or <project-dir>/.prime/agent/settings.json). This leaves room for the LLM's response.

You can also trigger manually with /compact [instructions], where optional instructions focus the summary — for example /compact focus on the auth refactor, remember the exact migration command. The instructions are passed to the summarization prompt with high priority, persisted on the CompactionEntry, and shown on the [compaction] message in the TUI.

How It Works #

  1. Find cut point: Walk backwards from newest message, accumulating token estimates until keepRecentTokens (default 20k, configurable in ~/.prime/agent/settings.json or <project-dir>/.prime/agent/settings.json) is reached
  2. Extract messages: Collect messages from the previous kept boundary (or session start) up to the cut point
  3. Generate summary: Call LLM to summarize with structured format, passing the previous summary as iterative context when present
  4. Append entry: Save CompactionEntry with summary and firstKeptEntryId
  5. Reload: Session reloads, using summary + messages from firstKeptEntryId onwards
Before compaction:

  entry:  0     1     2     3      4     5     6      7      8     9
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
               messagesToSummarize            kept messages
                                   ↑
                          firstKeptEntryId (entry 4)

After compaction (new entry appended):

  entry:  0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
               └──────────┬──────┘ └──────────────────────┬───────────────────┘
                 not sent to LLM                    sent to LLM
                                                         ↑
                                              starts from firstKeptEntryId

What the LLM sees:

  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
       ↑         ↑      └─────────────────┬────────────────┘
    prompt   from cmp          messages from firstKeptEntryId

On repeated compactions, the summarized span starts at the previous compaction's kept boundary (firstKeptEntryId), not at the compaction entry itself, falling back to the entry after the previous compaction if that kept entry cannot be found in the path. This preserves messages that survived the earlier compaction by including them in the next summarization pass as well. Prime Agent also recalculates tokensBefore from the rebuilt session context before writing the new CompactionEntry, so the token count reflects the actual pre-compaction context being replaced.

Split Turns #

A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.

When a single turn exceeds keepRecentTokens, the cut point lands mid-turn at an assistant message. This is a "split turn":

Split turn (one huge turn exceeds budget):

  entry:  0     1     2      3     4      5      6     7      8
        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
                ↑                                     ↑
         turnStartIndex = 1                  firstKeptEntryId = 7
                │                                     │
                └──── turnPrefixMessages (1-6) ───────┘
                                                      └── kept (7-8)

  isSplitTurn = true
  messagesToSummarize = []  (no complete turns before)
  turnPrefixMessages = [usr, ass, tool, ass, tool, tool]

For split turns, Prime Agent generates two summaries and merges them:

  1. History summary: Previous context (if any)
  2. Turn prefix summary: The early part of the split turn

Cut Point Rules #

Valid cut points are:

  • User messages
  • Assistant messages
  • BashExecution messages
  • Custom messages (custom_message, branch_summary)

Never cut at tool results (they must stay with their tool call).

CompactionEntry Structure #

Defined in session-manager.ts:

interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  firstKeptEntryId: string;
  tokensBefore: number;
  fromHook?: boolean;  // true if provided by extension (legacy field name)
  details?: T;         // implementation-specific data
  customInstructions?: string;  // user instructions from /compact <instructions>
}

// Default compaction uses this for details (from compaction.ts):
interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

Extensions can store any JSON-serializable data in details. The default compaction tracks file operations, but custom extension implementations can use their own structure.

See prepareCompaction() and compact() for the implementation.

Branch Summarization #

When It Triggers #

When you use /tree to navigate to a different branch, Prime Agent offers to summarize the work you're leaving. This injects context from the left branch into the new branch.

How It Works #

  1. Find common ancestor: Deepest node shared by old and new positions
  2. Collect entries: Walk from old leaf back to common ancestor
  3. Prepare with budget: Include messages up to token budget (newest first)
  4. Generate summary: Call LLM with structured format
  5. Append entry: Save BranchSummaryEntry at navigation point
Tree before navigation:

         ┌─ B ─ C ─ D (old leaf, being abandoned)
    A ───┤
         └─ E ─ F (target)

Common ancestor: A
Entries to summarize: B, C, D

After navigation with summary:

         ┌─ B ─ C ─ D ─ [summary of B,C,D]
    A ───┤
         └─ E ─ F (new leaf)

Cumulative File Tracking #

Both compaction and branch summarization track files cumulatively. When generating a summary, Prime Agent extracts file operations from:

  • Tool calls in the messages being summarized
  • Previous compaction or branch summary details (if any)

This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.

BranchSummaryEntry Structure #

Defined in session-manager.ts:

interface BranchSummaryEntry<T = unknown> {
  type: "branch_summary";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  fromId: string;      // Entry we navigated from
  fromHook?: boolean;  // true if provided by extension (legacy field name)
  details?: T;         // implementation-specific data
}

// Default branch summarization uses this for details (from branch-summarization.ts):
interface BranchSummaryDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

Same as compaction, extensions can store custom data in details.

See collectEntriesForBranchSummary(), prepareBranchEntries(), and generateBranchSummary() for the implementation.

Summary Format #

Both compaction and branch summarization use the same structured format. The reasoning digest keeps useful conclusions and outcomes rather than copying raw reasoning verbatim. The recent tail selected by keepRecentTokens remains exact and outside this summary.

## Goal
[What the user is trying to accomplish]

## Constraints & Preferences
- [Requirements mentioned by user]

## Progress
### Done
- [x] [Completed tasks]

### In Progress
- [ ] [Current work]

### Blocked
- [Issues, if any]

## Key Decisions
- **[Decision]**: [Rationale]

## Reasoning Digest
- **[Approach or hypothesis]**: [Outcome, evidence, and why it was kept or rejected]

## Next Steps
1. [What should happen next]

## Critical Context
- [Data needed to continue]

<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>

<modified-files>
path/to/changed.ts
</modified-files>

Message Serialization #

Before summarization, messages are serialized to text via serializeConversation():

[User]: What they said
[Assistant reasoning]: Readable reasoning for compatible summary models
[Assistant]: Response text
[Assistant tool calls]: ipython#call-id(code="open('foo.ts').read()")
[Tool result ipython#call-id error=false]: Output from tool

This prevents the model from treating the serialized data as a conversation to continue. Serialization builds a clone and does not modify the session entries.

The summary projection is loss-bounded and applies only to the model-facing summarization request. It is built from cloned messages: the raw session JSONL, the retained recent tail, and the original tool results remain unchanged. The projection is intentionally destructive for that request: image bytes are replaced with [image], and empty/useless or orphaned tool results are dropped. It keeps the newest three valid tool-result bodies and replaces older bodies with [tool result]; tool names, call IDs, and error states remain visible.

The projection keeps readable assistant reasoning for compatible models so decisions, attempted approaches, evidence, and failure causes can enter the reasoning digest. Claude-family summarizers omit it because Anthropic rejects prior assistant reasoning replayed as transcript text. If the provider still reports context overflow, compaction makes at most two smaller retries. Each retry removes one oldest complete user round when another round remains and reduces the exact tool-result bodies from three to one, then zero. Retries also apply a conservative head-and-tail bound to oversized single-round summary input so one long reasoning trace cannot block compaction. The raw session JSON remains unchanged; successful compaction only appends its summary entry.

Custom Summarization via Extensions #

Extensions can intercept and customize both compaction and branch summarization. See extensions/types.ts for event type definitions.

session_before_compact #

Fired before auto-compaction or /compact. Can cancel or provide custom summary. See SessionBeforeCompactEvent and CompactionPreparation in the types file.

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, signal } = event;

  // preparation.messagesToSummarize - messages to summarize
  // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
  // preparation.previousSummary - previous compaction summary
  // preparation.fileOps - extracted file operations
  // preparation.tokensBefore - context tokens before compaction
  // preparation.firstKeptEntryId - where kept messages start
  // preparation.settings - compaction settings

  // branchEntries - all entries on current branch (for custom state)
  // signal - AbortSignal (pass to LLM calls)

  // Transform discarded history for later handlers and native fallback:
  return {
    preparation: {
      messagesToSummarize: project(preparation.messagesToSummarize),
      turnPrefixMessages: project(preparation.turnPrefixMessages),
    },
  };

  // Cancel:
  return { cancel: true };

  // Custom summary:
  return {
    compaction: {
      summary: "Your summary...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      details: { /* custom data */ },
    }
  };
});

Preparation transforms run serially in extension load order. Each later session_before_compact handler sees the latest transformed groups; returning preparation replaces only the two discard-side message arrays and preserves the other preparation metadata. Return new arrays; do not mutate event.preparation or session messages. A handler may return both a preparation transform and a compaction; a valid custom result is persisted instead of invoking native compaction. If no handler supplies a custom result, native compaction receives the final groups. Returning { cancel: true }, or aborting through signal, stops compaction before a compaction entry is written. The preparation result is an additive API requirement: hosts that do not support it ignore the transform, so an extension that needs projection must require a compatible host or project again in its own compactor.

Converting Messages to Text #

To generate a summary with your own model, convert messages to text using serializeConversation:

import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation } = event;
  
  // Convert AgentMessage[] to Message[], then serialize to text
  const conversationText = serializeConversation(
    convertToLlm(preparation.messagesToSummarize)
  );
  // Returns:
  // [User]: message text
  // [Assistant]: response text
  // [Assistant tool calls]: ipython#call-id(code="open('...').read()")
  // [Tool result ipython#call-id error=false]: output text

  // Now send to your model for summarization
  const summary = await myModel.summarize(conversationText);
  
  return {
    compaction: {
      summary,
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
    }
  };
});

See custom-compaction.ts for a complete example using a different model.

session_before_tree #

Fired before /tree navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.

pi.on("session_before_tree", async (event, ctx) => {
  const { preparation, signal } = event;

  // preparation.targetId - where we're navigating to
  // preparation.oldLeafId - current position (being abandoned)
  // preparation.commonAncestorId - shared ancestor
  // preparation.entriesToSummarize - entries that would be summarized
  // preparation.userWantsSummary - whether user chose to summarize

  // Cancel navigation entirely:
  return { cancel: true };

  // Provide custom summary (only used if userWantsSummary is true):
  if (preparation.userWantsSummary) {
    return {
      summary: {
        summary: "Your summary...",
        details: { /* custom data */ },
      }
    };
  }
});

See SessionBeforeTreeEvent and TreePreparation in the types file.

Settings #

Configure compaction in ~/.prime/agent/settings.json or <project-dir>/.prime/agent/settings.json:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
Setting Default Description
enabled true Enable auto-compaction
reserveTokens 16384 Tokens to reserve for LLM response
keepRecentTokens 20000 Recent tokens to keep (not summarized)

Disable auto-compaction with "enabled": false. You can still compact manually with /compact.