	const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);

	const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;

	// Messages to summarize (will be discarded after summary)
	const messagesToSummarize: AppMessage[] = [];
	for (let i = boundaryStart; i < historyEnd; i++) {
		const entry = entries[i];
		if (entry.type === "message") {
			messagesToSummarize.push(entry.message);
		}
	}

	// Messages to keep (recent turns, kept after summary)
	const messagesToKeep: AppMessage[] = [];
	for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
		const entry = entries[i];
		if (entry.type === "message") {
			messagesToKeep.push(entry.message);
		}
	}

	return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
}

// ============================================================================
// Main compaction function
// ============================================================================

const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.

Create a handoff summary that captures:
- What the user originally asked for in this turn
- Key decisions and progress made early in this turn
- Important context needed to understand the kept suffix

Be concise. Focus on information needed to understand the retained recent work.`;

/**
 * Calculate compaction and generate summary.
 * Returns the CompactionEntry to append to the session file.
 *
 * @param entries - All session entries
 * @param model - Model to use for summarization
 * @param settings - Compaction settings
 * @param apiKey - API key for LLM
 * @param signal - Optional abort signal
 * @param customInstructions - Optional custom focus for the summary

[127 more lines in file. Use offset=405 to continue]


I see. When `isSplitTurn` is true:
- `turnStartIndex` is the user message that started the turn
- `firstKeptEntryIndex` is where we actually cut (mid-turn, at an assistant message)
- `messagesToSummarize` goes up to `turnStartIndex` (full turns only)
- There's a gap between `turnStartIndex` and `firstKeptEntryIndex` that's the "turn prefix" - summarized separately


**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):

```
Session entries (before compaction):

  index:  0        1               2     3     4     5     6     7
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┘
                        ↑          └─────┬─────┘   └───────┬───────┘
                 previousSummary   messagesToSummarize  messagesToKeep
                                                  ↑
                                   cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:  0        1               2     3     4     5     6     7     8
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┬─────────────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │ NEW compact │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┴─────────────┘
                                  └─────┬─────┘   └───────┬───────┘         ↑
                                     ignored           loaded        firstKeptEntryIndex = 5
                                   on reload         on reload       (stored in this entry)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`.

```
What gets sent to the LLM as context:

        ┌─────────────────────────────────────────────────────────────┐
        │ [system prompt] [summary msg] [msg idx 5] [msg idx 6] [msg idx 7] │
        └─────────────────────────────────────────────────────────────┘
                               ↑              └───────────┬───────────┘
                    from NEW compact's           messages from
                         summary              firstKeptEntryIndex onwards
```

**Event fields:**

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |

[679 more lines in file. Use offset=230 to continue]


edit

```
What gets sent to the LLM as context:

        ┌─────────────────────────────────────────────────────────────┐
        │ [system prompt] [summary msg] [msg idx 5] [msg idx 6] [msg idx 7] │
        └─────────────────────────────────────────────────────────────┘
                               ↑              └───────────┬───────────┘
                    from NEW compact's           messages from
                         summary              firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ asst │ asst │ asst │ asst │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                    ↑
             turnStartIndex = 1    firstKeptEntryIndex = 5
                    │                    │
                    └── turn prefix ─────┘ (idx 1-4, summarized separately)
                                         └── kept messages (idx 5-7)

  messagesToSummarize = []  (no complete turns to summarize)
  messagesToKeep = [msg idx 5, msg idx 6, msg idx 7]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 477 characters to 1580 characters.

     ...
 221                     from NEW compact's           messages from
 222                          summary              firstKeptEntryIndex onwards
 223 ```
 224
+225 **Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:
+226
+227 ```
+228 Split turn example (one huge turn that exceeds keepRecentTokens):
+229
+230   index:  0        1      2      3      4      5      6      7
+231         ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
+232         │ header │ user │ asst │ asst │ asst │ asst │ asst │ asst │
+233         └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
+234                     ↑                    ↑
+235              turnStartIndex = 1    firstKeptEntryIndex = 5
+236                     │                    │
+237                     └── turn prefix ─────┘ (idx 1-4, summarized separately)
+238                                          └── kept messages (idx 5-7)
+239
+240   messagesToSummarize = []  (no complete turns to summarize)
+241   messagesToKeep = [msg idx 5, msg idx 6, msg idx 7]
+242
+243 The default compaction generates TWO summaries that get merged:
+244 1. History summary (previousSummary + messagesToSummarize)
+245 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
+246 ```
+247
 225 **Event fields:**
 226
 227 | Field | Description |
 228 |-------|-------------|
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 156ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

Command aborted


well, it's usually

user asst tool result assist tool result tool result assist


Right, a turn includes the user message, assistant responses, and tool results. Let me fix the diagram:

edit

**Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ asst │ asst │ asst │ asst │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                    ↑
             turnStartIndex = 1    firstKeptEntryIndex = 5
                    │                    │
                    └── turn prefix ─────┘ (idx 1-4, summarized separately)
                                         └── kept messages (idx 5-7)

  messagesToSummarize = []  (no complete turns to summarize)
  messagesToKeep = [msg idx 5, msg idx 6, msg idx 7]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7      8      9
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                           ↑
             turnStartIndex = 1         firstKeptEntryIndex = 7
                    │                           │
                    └────── turn prefix ────────┘ (idx 1-6, summarized separately)
                                                └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 1101 characters to 1193 characters.

     ...
 226
 227 ```
 228 Split turn example (one huge turn that exceeds keepRecentTokens):
 229
-230   index:  0        1      2      3      4      5      6      7
-231         ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
-232         │ header │ user │ asst │ asst │ asst │ asst │ asst │ asst │
-233         └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
-234                     ↑                    ↑
-235              turnStartIndex = 1    firstKeptEntryIndex = 5
-236                     │                    │
-237                     └── turn prefix ─────┘ (idx 1-4, summarized separately)
-238                                          └── kept messages (idx 5-7)
+230   index:  0        1      2      3      4      5      6      7      8      9
+231         ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
+232         │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
+233         └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
+234                     ↑                           ↑
+235              turnStartIndex = 1         firstKeptEntryIndex = 7
+236                     │                           │
+237                     └────── turn prefix ────────┘ (idx 1-6, summarized separately)
+238                                                 └── kept messages (idx 7-9)
 239
-240   messagesToSummarize = []  (no complete turns to summarize)
-241   messagesToKeep = [msg idx 5, msg idx 6, msg idx 7]
+240   messagesToSummarize = []  (no complete turns before this one)
+241   messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]
 242
 243 The default compaction generates TWO summaries that get merged:
 244 1. History summary (previousSummary + messagesToSummarize)
 245 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add split turn diagram to compaction docs" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 155ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 18ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 5f41a38] Add split turn diagram to compaction docs
 1 file changed, 23 insertions(+)
To https://github.com/badlogic/pi-mono.git
   699702e..5f41a38  main -> main


Done. The diagram now shows a realistic turn with user, assistant, and tool result messages, illustrating how a split turn works when cutting mid-turn.


The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`.

i doubt that is true? like, this can't point at a tool result message, right? it must point either at an assistant message or a user message no? please read the full @packages/coding-agent/src/core/compaction.ts file to understand


/**
 * 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 { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } from "./messages.js";
import type { CompactionEntry, SessionEntry } from "./session-manager.js";

// ============================================================================
// Types
// ============================================================================

export interface CompactionSettings {
	enabled: boolean;
	reserveTokens: number;
	keepRecentTokens: number;
}

export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
	enabled: true,
	reserveTokens: 16384,
	keepRecentTokens: 20000,
};

// ============================================================================
// Token calculation
// ============================================================================

/**
 * Calculate total context tokens from usage.
 * Uses the native totalTokens field when available, falls back to computing from components.
 */
export function calculateContextTokens(usage: Usage): number {
	return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}

/**
 * Get usage from an assistant message if available.
 * Skips aborted and error messages as they don't have valid usage data.
 */
function getAssistantUsage(msg: AppMessage): Usage | null {
	if (msg.role === "assistant" && "usage" in msg) {
		const assistantMsg = msg as AssistantMessage;
		if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
			return assistantMsg.usage;
		}
	}
	return null;
}

/**
 * Find the last non-aborted assistant message usage from session entries.
 */
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
	for (let i = entries.length - 1; i >= 0; i--) {
		const entry = entries[i];
		if (entry.type === "message") {
			const usage = getAssistantUsage(entry.message);
			if (usage) return usage;
		}
	}
	return null;
}

/**
 * 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: AppMessage): number {
	let chars = 0;

	// Handle bashExecution messages
	if (message.role === "bashExecution") {
		const bash = message as unknown as { command: string; output: string };
		chars = bash.command.length + bash.output.length;
		return Math.ceil(chars / 4);
	}

	// Handle user messages
	if (message.role === "user") {
		const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
		if (typeof content === "string") {
			chars = content.length;
		} else if (Array.isArray(content)) {
			for (const block of content) {
				if (block.type === "text" && block.text) {
					chars += block.text.length;
				}
			}
		}
		return Math.ceil(chars / 4);
	}

	// Handle assistant messages
	if (message.role === "assistant") {
		const assistant = message as AssistantMessage;
		for (const block of assistant.content) {
			if (block.type === "text") {
				chars += block.text.length;
			} else if (block.type === "thinking") {
				chars += block.thinking.length;
			} else if (block.type === "toolCall") {
				chars += block.name.length + JSON.stringify(block.arguments).length;
			}
		}
		return Math.ceil(chars / 4);
	}

	// Handle tool results
	if (message.role === "toolResult") {
		const toolResult = message as { content: Array<{ type: string; text?: string }> };
		for (const block of toolResult.content) {
			if (block.type === "text" && block.text) {
				chars += block.text.length;
			}
		}
		return Math.ceil(chars / 4);
	}

	return 0;
}

/**
 * Find valid cut points: indices of user, assistant, or bashExecution 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.
 * BashExecutionMessage is treated like a user message (user-initiated context).
 */
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 === "message") {
			const role = entry.message.role;
			// user, assistant, and bashExecution are valid cut points
			// toolResult must stay with its preceding tool call
			if (role === "user" || role === "assistant" || role === "bashExecution") {
				cutPoints.push(i);
			}
		}
	}
	return cutPoints;
}

/**
 * Find the user message (or bashExecution) that starts the turn containing the given entry index.
 * Returns -1 if no turn start found before the index.
 * BashExecutionMessage is treated like a user message for turn boundaries.
 */
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
	for (let i = entryIndex; i >= startIndex; i--) {
		const entry = entries[i];
		if (entry.type === "message") {
			const role = entry.message.role;
			if (role === "user" || role === "bashExecution") {
				return i;
			}
		}
	}
	return -1;
}

export interface CutPointResult {
	/** Index of first entry to keep */
	firstKeptEntryIndex: number;
	/** Index of user message that starts the turn being split, or -1 if not splitting */
	turnStartIndex: number;
	/** Whether this cut splits a turn (cut point is not a user message) */
	isSplitTurn: boolean;
}

/**
 * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
 *
 * Algorithm: Walk backwards from newest, accumulating estimated message sizes.
 * Stop when we've accumulated >= keepRecentTokens. Cut at that point.
 *
 * Can cut at user OR assistant messages (never tool results). When cutting at an
 * assistant message with tool calls, its tool results come after and will be kept.
 *
 * Returns CutPointResult with:
 * - firstKeptEntryIndex: the entry index to start keeping from
 * - turnStartIndex: if cutting mid-turn, the user message that started that turn
 * - isSplitTurn: whether we're cutting in the middle of a turn
 *
 * Only considers entries between `startIndex` and `endIndex` (exclusive).
 */
export function findCutPoint(
	entries: SessionEntry[],
	startIndex: number,
	endIndex: number,
	keepRecentTokens: number,
): CutPointResult {
	const cutPoints = findValidCutPoints(entries, startIndex, endIndex);

	if (cutPoints.length === 0) {
		return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
	}

	// Walk backwards from newest, accumulating estimated message sizes
	let accumulatedTokens = 0;
	let cutIndex = startIndex; // Default: keep everything in range

	for (let i = endIndex - 1; i >= startIndex; i--) {
		const entry = entries[i];
		if (entry.type !== "message") continue;

		// Estimate this message's size
		const messageTokens = estimateTokens(entry.message);
		accumulatedTokens += messageTokens;

		// Check if we've exceeded the budget
		if (accumulatedTokens >= keepRecentTokens) {
			// Find the closest valid cut point at or after this entry
			for (let c = 0; c < cutPoints.length; c++) {
				if (cutPoints[c] >= i) {
					cutIndex = cutPoints[c];
					break;
				}
			}
			break;
		}
	}

	// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
	while (cutIndex > startIndex) {
		const prevEntry = entries[cutIndex - 1];
		// Stop at compaction boundaries
		if (prevEntry.type === "compaction") {
			break;
		}
		if (prevEntry.type === "message") {
			// Stop if we hit any message
			break;
		}
		// Include this non-message entry (bash, settings change, etc.)
		cutIndex--;
	}

	// Determine if this is a split turn
	const cutEntry = entries[cutIndex];
	const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
	const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);

	return {
		firstKeptEntryIndex: cutIndex,
		turnStartIndex,
		isSplitTurn: !isUserMessage && turnStartIndex !== -1,
	};
}

// ============================================================================
// Summarization
// ============================================================================

const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.

Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- Absolute file paths of any relevant files that were read or modified
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue

Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;

/**
 * Generate a summary of the conversation using the LLM.
 */
export async function generateSummary(
	currentMessages: AppMessage[],
	model: Model<any>,
	reserveTokens: number,
	apiKey: string,
	signal?: AbortSignal,
	customInstructions?: string,
): Promise<string> {
	const maxTokens = Math.floor(0.8 * reserveTokens);

	const prompt = customInstructions
		? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
		: SUMMARIZATION_PROMPT;

	// Transform custom messages (like bashExecution) to LLM-compatible messages
	const transformedMessages = messageTransformer(currentMessages);

	const summarizationMessages = [
		...transformedMessages,
		{
			role: "user" as const,
			content: [{ type: "text" as const, text: prompt }],
			timestamp: Date.now(),
		},
	];

	const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });

	const textContent = response.content
		.filter((c): c is { type: "text"; text: string } => c.type === "text")
		.map((c) => c.text)
		.join("\n");

	return textContent;
}

// ============================================================================
// Compaction Preparation (for hooks)
// ============================================================================

export interface CompactionPreparation {
	cutPoint: CutPointResult;
	/** Messages that will be summarized and discarded */
	messagesToSummarize: AppMessage[];
	/** Messages that will be kept after the summary (recent turns) */
	messagesToKeep: AppMessage[];
	tokensBefore: number;
	boundaryStart: number;
}

export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
	if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
		return null;
	}

	let prevCompactionIndex = -1;
	for (let i = entries.length - 1; i >= 0; i--) {
		if (entries[i].type === "compaction") {
			prevCompactionIndex = i;
			break;
		}
	}
	const boundaryStart = prevCompactionIndex + 1;
	const boundaryEnd = entries.length;

	const lastUsage = getLastAssistantUsage(entries);
	const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;

	const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);

	const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;

	// Messages to summarize (will be discarded after summary)
	const messagesToSummarize: AppMessage[] = [];
	for (let i = boundaryStart; i < historyEnd; i++) {
		const entry = entries[i];
		if (entry.type === "message") {
			messagesToSummarize.push(entry.message);
		}
	}

	// Messages to keep (recent turns, kept after summary)
	const messagesToKeep: AppMessage[] = [];
	for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
		const entry = entries[i];
		if (entry.type === "message") {
			messagesToKeep.push(entry.message);
		}
	}

	return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
}

// ============================================================================
// Main compaction function
// ============================================================================

const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.

Create a handoff summary that captures:
- What the user originally asked for in this turn
- Key decisions and progress made early in this turn
- Important context needed to understand the kept suffix

Be concise. Focus on information needed to understand the retained recent work.`;

/**
 * Calculate compaction and generate summary.
 * Returns the CompactionEntry to append to the session file.
 *
 * @param entries - All session entries
 * @param model - Model to use for summarization
 * @param settings - Compaction settings
 * @param apiKey - API key for LLM
 * @param signal - Optional abort signal
 * @param customInstructions - Optional custom focus for the summary
 */
export async function compact(
	entries: SessionEntry[],
	model: Model<any>,
	settings: CompactionSettings,
	apiKey: string,
	signal?: AbortSignal,
	customInstructions?: string,
): Promise<CompactionEntry> {
	// Don't compact if the last entry is already a compaction
	if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
		throw new Error("Already compacted");
	}

	// Find previous compaction boundary
	let prevCompactionIndex = -1;
	for (let i = entries.length - 1; i >= 0; i--) {
		if (entries[i].type === "compaction") {
			prevCompactionIndex = i;
			break;
		}
	}
	const boundaryStart = prevCompactionIndex + 1;
	const boundaryEnd = entries.length;

	// Get token count before compaction
	const lastUsage = getLastAssistantUsage(entries);
	const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;

	// Find cut point (entry index) within the valid range
	const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);

	// Extract messages for history summary (before the turn that contains the cut point)
	const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex;
	const historyMessages: AppMessage[] = [];
	for (let i = boundaryStart; i < historyEnd; i++) {
		const entry = entries[i];
		if (entry.type === "message") {
			historyMessages.push(entry.message);
		}
	}

	// Include previous summary if there was a compaction
	if (prevCompactionIndex >= 0) {
		const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
		historyMessages.unshift({
			role: "user",
			content: `Previous session summary:\n${prevCompaction.summary}`,
			timestamp: Date.now(),
		});
	}

	// Extract messages for turn prefix summary (if splitting a turn)
	const turnPrefixMessages: AppMessage[] = [];
	if (cutResult.isSplitTurn) {
		for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
			const entry = entries[i];
			if (entry.type === "message") {
				turnPrefixMessages.push(entry.message);
			}
		}
	}

	// Generate summaries (can be parallel if both needed) and merge into one
	let summary: string;

	if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
		// Generate both summaries in parallel
		const [historyResult, turnPrefixResult] = await Promise.all([
			historyMessages.length > 0
				? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions)
				: Promise.resolve("No prior history."),
			generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
		]);
		// Merge into single summary
		summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
	} else {
		// Just generate history summary
		summary = await generateSummary(
			historyMessages,
			model,
			settings.reserveTokens,
			apiKey,
			signal,
			customInstructions,
		);
	}

	return {
		type: "compaction",
		timestamp: new Date().toISOString(),
		summary,
		firstKeptEntryIndex: cutResult.firstKeptEntryIndex,
		tokensBefore,
	};
}

/**
 * Generate a summary for a turn prefix (when splitting a turn).
 */
async function generateTurnPrefixSummary(
	messages: AppMessage[],
	model: Model<any>,
	reserveTokens: number,
	apiKey: string,
	signal?: AbortSignal,
): Promise<string> {
	const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix

	const transformedMessages = messageTransformer(messages);
	const summarizationMessages = [
		...transformedMessages,
		{
			role: "user" as const,
			content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
			timestamp: Date.now(),
		},
	];

	const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });

	return response.content
		.filter((c): c is { type: "text"; text: string } => c.type === "text")
		.map((c) => c.text)
		.join("\n");
}


You're right. Looking at `findValidCutPoints`:

```typescript
if (role === "user" || role === "assistant" || role === "bashExecution") {
    cutPoints.push(i);
}
```

So `firstKeptEntryIndex` can only point to a user, assistant, or bashExecution message, never a tool result. The docs are correct about that. But I should clarify this in the diagram and text:

edit

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 175 characters to 302 characters.

     ...
 208                                      ignored           loaded        firstKeptEntryIndex = 5
 209                                    on reload         on reload       (stored in this entry)
 210 ```
 211
-212 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`.
+212 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
 213
 214 ```
 215 What gets sent to the LLM as context:
 216
     ...


Now let me also fix the split turn diagram to be more accurate. Looking at the code, when cutting at an assistant message, the tool results that follow it are kept:

**Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7      8      9
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                           ↑
             turnStartIndex = 1         firstKeptEntryIndex = 7
                    │                           │
                    └────── turn prefix ────────┘ (idx 1-6, summarized separately)
                                                └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7      8      9
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                                  ↑
             turnStartIndex = 1              firstKeptEntryIndex = 7
                    │                                  │ (must be user/asst/bash, not tool)
                    └────────── turn prefix ───────────┘ (idx 1-6, summarized separately)
                                                       └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 1193 characters to 1259 characters.

     ...
 221                     from NEW compact's           messages from
 222                          summary              firstKeptEntryIndex onwards
 223 ```
 224
-225 **Split turns:** When a single turn is too large, the cut point may land mid-turn (at an assistant message). In this case `cutPoint.isSplitTurn = true`:
+225 **Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
 226
 227 ```
 228 Split turn example (one huge turn that exceeds keepRecentTokens):
 229
 230   index:  0        1      2      3      4      5      6      7      8      9
 231         ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
 232         │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
 233         └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
-234                     ↑                           ↑
-235              turnStartIndex = 1         firstKeptEntryIndex = 7
-236                     │                           │
-237                     └────── turn prefix ────────┘ (idx 1-6, summarized separately)
-238                                                 └── kept messages (idx 7-9)
+234                     ↑                                  ↑
+235              turnStartIndex = 1              firstKeptEntryIndex = 7
+236                     │                                  │ (must be user/asst/bash, not tool)
+237                     └────────── turn prefix ───────────┘ (idx 1-6, summarized separately)
+238                                                        └── kept messages (idx 7-9)
 239
 240   messagesToSummarize = []  (no complete turns before this one)
 241   messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]
 242
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Clarify that cut point is always user/assistant/bash, never tool result" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 157ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 35a40b2] Clarify that cut point is always user/assistant/bash, never tool result
 1 file changed, 7 insertions(+), 7 deletions(-)
To https://github.com/badlogic/pi-mono.git
   5f41a38..35a40b2  main -> main


Done. Clarified that:
1. The cut point is always a user, assistant, or bashExecution message (never a tool result)
2. Tool results must stay with their preceding tool call


should reference the default compaction source file


edit

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)

See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 203 characters to 289 characters.

     ...
 242
 243 The default compaction generates TWO summaries that get merged:
 244 1. History summary (previousSummary + messagesToSummarize)
 245 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
+246
+247 See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
 246 ```
 247
 248 **Event fields:**
 249
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add reference to compaction.ts source file" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 156ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 41ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 26ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 27250c8] Add reference to compaction.ts source file
 1 file changed, 2 insertions(+)
To https://github.com/badlogic/pi-mono.git
   35a40b2..27250c8  main -> main


Done.


Session entries (before compaction):

  index:  0        1               2     3     4     5     6     7
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┘
                        ↑          └─────┬─────┘   └───────┬───────┘
                 previousSummary   messagesToSummarize  messagesToKeep
                                                  ↑
                                   cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:  0        1               2     3     4     5     6     7     8
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┬─────────────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │ NEW compact │

can we please make those two also use usr assistant, tool, bash? and can we have a legend before the diagrams so we can use abreviated message types int he diagrams


#### Custom Compaction

The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):

```
Session entries (before compaction):

  index:  0        1               2     3     4     5     6     7
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┘
                        ↑          └─────┬─────┘   └───────┬───────┘
                 previousSummary   messagesToSummarize  messagesToKeep
                                                  ↑
                                   cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:  0        1               2     3     4     5     6     7     8
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┬─────────────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │ NEW compact │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┴─────────────┘
                                  └─────┬─────┘   └───────┬───────┘         ↑
                                     ignored           loaded        firstKeptEntryIndex = 5
                                   on reload         on reload       (stored in this entry)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

        ┌─────────────────────────────────────────────────────────────┐
        │ [system prompt] [summary msg] [msg idx 5] [msg idx 6] [msg idx 7] │
        └─────────────────────────────────────────────────────────────┘
                               ↑              └───────────┬───────────┘
                    from NEW compact's           messages from
                         summary              firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7      8      9
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                                  ↑
             turnStartIndex = 1              firstKeptEntryIndex = 7
                    │                                  │ (must be user/asst/bash, not tool)
                    └────────── turn prefix ───────────┘ (idx 1-6, summarized separately)
                                                       └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)

[693 more lines in file. Use offset=241 to continue]


edit

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):

```
Session entries (before compaction):

  index:  0        1               2     3     4     5     6     7
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┘
                        ↑          └─────┬─────┘   └───────┬───────┘
                 previousSummary   messagesToSummarize  messagesToKeep
                                                  ↑
                                   cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:  0        1               2     3     4     5     6     7     8
        ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┬─────────────┐
        │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │ NEW compact │
        └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┴─────────────┘
                                  └─────┬─────┘   └───────┬───────┘         ↑
                                     ignored           loaded        firstKeptEntryIndex = 5
                                   on reload         on reload       (stored in this entry)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

        ┌─────────────────────────────────────────────────────────────┐
        │ [system prompt] [summary msg] [msg idx 5] [msg idx 6] [msg idx 7] │
        └─────────────────────────────────────────────────────────────┘
                               ↑              └───────────┬───────────┘
                    from NEW compact's           messages from
                         summary              firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0        1      2      3      4      5      6      7      8      9
        ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
        │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
        └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                    ↑                                  ↑
             turnStartIndex = 1              firstKeptEntryIndex = 7
                    │                                  │ (must be user/asst/bash, not tool)
                    └────────── turn prefix ───────────┘ (idx 1-6, summarized separately)
                                                       └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)

See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
```

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):

```
Legend:
  H = header        U = user message      A = assistant message
  T = tool result   C = compaction entry  B = bashExecution
```

```
Session entries (before compaction):

  index:  0   1   2   3   4   5   6   7   8   9   10  11
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
              ↑   └───────┬───────┘   └─────────┬─────────┘
       previousSummary  messagesToSummarize   messagesToKeep
                                 ↑
                  cutPoint.firstKeptEntryIndex = 6

After compaction (new entry appended):

  index:  0   1   2   3   4   5   6   7   8   9   10  11  12
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │ C │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                └───────┬───────┘   └─────────┬─────────┘   ↑
                     ignored              loaded      firstKeptEntryIndex = 6
                   on reload            on reload     (stored in new C)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

  ┌────────────────────────────────────────────────────────────┐
  │ [system] [summary] [U idx 6] [A idx 7] [T idx 8] [T idx 9] [A idx 10] [T idx 11] │
  └────────────────────────────────────────────────────────────┘
                 ↑           └──────────────────┬──────────────────┘
       from new C's summary         messages from firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0   1   2   3   4   5   6   7   8   9
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ U │ A │ T │ A │ T │ T │ A │ T │ A │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
            ↑                       ↑
     turnStartIndex = 1    firstKeptEntryIndex = 7
            │                       │ (must be U/A/B, not T)
            └───── turn prefix ─────┘ (idx 1-6, summarized separately)
                                    └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [A idx 7, T idx 8, A idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 3545 characters to 3148 characters.

     ...
 186
 187 When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):
 188
 189 ```
+190 Legend:
+191   H = header        U = user message      A = assistant message
+192   T = tool result   C = compaction entry  B = bashExecution
+193 ```
+194
+195 ```
 190 Session entries (before compaction):
 191
-192   index:  0        1               2     3     4     5     6     7
-193         ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┐
-194         │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │
-195         └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┘
-196                         ↑          └─────┬─────┘   └───────┬───────┘
-197                  previousSummary   messagesToSummarize  messagesToKeep
-198                                                   ↑
-199                                    cutPoint.firstKeptEntryIndex = 5
+198   index:  0   1   2   3   4   5   6   7   8   9   10  11
+199         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
+200         │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │
+201         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
+202               ↑   └───────┬───────┘   └─────────┬─────────┘
+203        previousSummary  messagesToSummarize   messagesToKeep
+204                                  ↑
+205                   cutPoint.firstKeptEntryIndex = 6
 200
 201 After compaction (new entry appended):
 202
-203   index:  0        1               2     3     4     5     6     7     8
-204         ┌────────┬───────────────┬─────┬─────┬─────┬─────┬─────┬─────┬─────────────┐
-205         │ header │ prev compact  │ msg │ msg │ msg │ msg │ msg │ msg │ NEW compact │
-206         └────────┴───────────────┴─────┴─────┴─────┴─────┴─────┴─────┴─────────────┘
-207                                   └─────┬─────┘   └───────┬───────┘         ↑
-208                                      ignored           loaded        firstKeptEntryIndex = 5
-209                                    on reload         on reload       (stored in this entry)
+209   index:  0   1   2   3   4   5   6   7   8   9   10  11  12
+210         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
+211         │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │ C │
+212         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
+213                 └───────┬───────┘   └─────────┬─────────┘   ↑
+214                      ignored              loaded      firstKeptEntryIndex = 6
+215                    on reload            on reload     (stored in new C)
 210 ```
 211
 212 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
 213
 214 ```
 215 What gets sent to the LLM as context:
 216
-217         ┌─────────────────────────────────────────────────────────────┐
-218         │ [system prompt] [summary msg] [msg idx 5] [msg idx 6] [msg idx 7] │
-219         └─────────────────────────────────────────────────────────────┘
-220                                ↑              └───────────┬───────────┘
-221                     from NEW compact's           messages from
-222                          summary              firstKeptEntryIndex onwards
+223   ┌────────────────────────────────────────────────────────────┐
+224   │ [system] [summary] [U idx 6] [A idx 7] [T idx 8] [T idx 9] [A idx 10] [T idx 11] │
+225   └────────────────────────────────────────────────────────────┘
+226                  ↑           └──────────────────┬──────────────────┘
+227        from new C's summary         messages from firstKeptEntryIndex onwards
 223 ```
 224
 225 **Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
 226
 227 ```
 228 Split turn example (one huge turn that exceeds keepRecentTokens):
 229
-230   index:  0        1      2      3      4      5      6      7      8      9
-231         ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
-232         │ header │ user │ asst │ tool │ asst │ tool │ tool │ asst │ tool │ asst │
-233         └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
-234                     ↑                                  ↑
-235              turnStartIndex = 1              firstKeptEntryIndex = 7
-236                     │                                  │ (must be user/asst/bash, not tool)
-237                     └────────── turn prefix ───────────┘ (idx 1-6, summarized separately)
-238                                                        └── kept messages (idx 7-9)
+235   index:  0   1   2   3   4   5   6   7   8   9
+236         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
+237         │ H │ U │ A │ T │ A │ T │ T │ A │ T │ A │
+238         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
+239             ↑                       ↑
+240      turnStartIndex = 1    firstKeptEntryIndex = 7
+241             │                       │ (must be U/A/B, not T)
+242             └───── turn prefix ─────┘ (idx 1-6, summarized separately)
+243                                     └── kept messages (idx 7-9)
 239
 240   messagesToSummarize = []  (no complete turns before this one)
-241   messagesToKeep = [msg idx 7, msg idx 8, msg idx 9]
+246   messagesToKeep = [A idx 7, T idx 8, A idx 9]
 242
 243 The default compaction generates TWO summaries that get merged:
 244 1. History summary (previousSummary + messagesToSummarize)
 245 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
+251 ```
 246
 247 See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.
-248 ```
 249
 250 **Event fields:**
 251
 252 | Field | Description |
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Improve compaction diagrams with legend and realistic message types" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 158ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 42ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 26ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 3c5f492] Improve compaction diagrams with legend and realistic message types
 1 file changed, 37 insertions(+), 32 deletions(-)
To https://github.com/badlogic/pi-mono.git
   27250c8..3c5f492  main -> main


Done. Added a legend and updated all diagrams to use abbreviated message types (H, U, A, T, C, B) with realistic turn structures showing user, assistant, and tool result messages.


ass usr tool bash


edit

```
Legend:
  H = header        U = user message      A = assistant message
  T = tool result   C = compaction entry  B = bashExecution
```

```
Legend:
  hdr  = header           usr  = user message       ass = assistant message
  tool = tool result      cmp  = compaction entry   bash = bashExecution
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 139 characters to 164 characters.

     ...
 187 When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):
 188
 189 ```
 190 Legend:
-191   H = header        U = user message      A = assistant message
-192   T = tool result   C = compaction entry  B = bashExecution
+191   hdr  = header           usr  = user message       ass = assistant message
+192   tool = tool result      cmp  = compaction entry   bash = bashExecution
 193 ```
 194
 195 ```
 196 Session entries (before compaction):
     ...


```
Session entries (before compaction):

  index:  0   1   2   3   4   5   6   7   8   9   10  11
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
              ↑   └───────┬───────┘   └─────────┬─────────┘
       previousSummary  messagesToSummarize   messagesToKeep
                                 ↑
                  cutPoint.firstKeptEntryIndex = 6

After compaction (new entry appended):

  index:  0   1   2   3   4   5   6   7   8   9   10  11  12
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │ C │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                └───────┬───────┘   └─────────┬─────────┘   ↑
                     ignored              loaded      firstKeptEntryIndex = 6
                   on reload            on reload     (stored in new C)
```

```
Session entries (before compaction):

  index:   0     1     2     3      4     5     6     7      8      9     10     11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┘
                ↑     └────────┬────────┘     └────────────┬────────────┘
         previousSummary    messagesToSummarize         messagesToKeep
                                       ↑
                        cutPoint.firstKeptEntryIndex = 6

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6     7      8      9     10     11    12
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └────────┬────────┘     └────────────┬────────────┘       ↑
                           ignored                      loaded         firstKeptEntryIndex = 6
                         on reload                    on reload        (stored in new cmp)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 983 characters to 1305 characters.

     ...
 194
 195 ```
 196 Session entries (before compaction):
 197
-198   index:  0   1   2   3   4   5   6   7   8   9   10  11
-199         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
-200         │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │
-201         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
-202               ↑   └───────┬───────┘   └─────────┬─────────┘
-203        previousSummary  messagesToSummarize   messagesToKeep
-204                                  ↑
-205                   cutPoint.firstKeptEntryIndex = 6
+198   index:   0     1     2     3      4     5     6     7      8      9     10     11
+199         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┐
+200         │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │
+201         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┘
+202                 ↑     └────────┬────────┘     └────────────┬────────────┘
+203          previousSummary    messagesToSummarize         messagesToKeep
+204                                        ↑
+205                         cutPoint.firstKeptEntryIndex = 6
 206
 207 After compaction (new entry appended):
 208
-209   index:  0   1   2   3   4   5   6   7   8   9   10  11  12
-210         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
-211         │ H │ C │ U │ A │ T │ A │ U │ A │ T │ T │ A │ T │ C │
-212         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
-213                 └───────┬───────┘   └─────────┬─────────┘   ↑
-214                      ignored              loaded      firstKeptEntryIndex = 6
-215                    on reload            on reload     (stored in new C)
+209   index:   0     1     2     3      4     5     6     7      8      9     10     11    12
+210         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
+211         │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
+212         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
+213                       └────────┬────────┘     └────────────┬────────────┘       ↑
+214                            ignored                      loaded         firstKeptEntryIndex = 6
+215                          on reload                    on reload        (stored in new cmp)
 216 ```
 217
 218 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
 219
     ...


```
What gets sent to the LLM as context:

  ┌────────────────────────────────────────────────────────────┐
  │ [system] [summary] [U idx 6] [A idx 7] [T idx 8] [T idx 9] [A idx 10] [T idx 11] │
  └────────────────────────────────────────────────────────────┘
                 ↑           └──────────────────┬──────────────────┘
       from new C's summary         messages from firstKeptEntryIndex onwards
```

```
What gets sent to the LLM as context:

  ┌──────────────────────────────────────────────────────────────────────────────────────┐
  │ [system] [summary] [usr idx 6] [ass idx 7] [tool idx 8] [tool idx 9] [ass idx 10] [tool idx 11] │
  └──────────────────────────────────────────────────────────────────────────────────────┘
                 ↑              └──────────────────────┬──────────────────────┘
       from new cmp's summary              messages from firstKeptEntryIndex onwards
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 410 characters to 495 characters.

     ...
 219
 220 ```
 221 What gets sent to the LLM as context:
 222
-223   ┌────────────────────────────────────────────────────────────┐
-224   │ [system] [summary] [U idx 6] [A idx 7] [T idx 8] [T idx 9] [A idx 10] [T idx 11] │
-225   └────────────────────────────────────────────────────────────┘
-226                  ↑           └──────────────────┬──────────────────┘
-227        from new C's summary         messages from firstKeptEntryIndex onwards
+223   ┌──────────────────────────────────────────────────────────────────────────────────────┐
+224   │ [system] [summary] [usr idx 6] [ass idx 7] [tool idx 8] [tool idx 9] [ass idx 10] [tool idx 11] │
+225   └──────────────────────────────────────────────────────────────────────────────────────┘
+226                  ↑              └──────────────────────┬──────────────────────┘
+227        from new cmp's summary              messages from firstKeptEntryIndex onwards
 228 ```
 229
 230 **Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
 231
     ...


```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:  0   1   2   3   4   5   6   7   8   9
        ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
        │ H │ U │ A │ T │ A │ T │ T │ A │ T │ A │
        └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
            ↑                       ↑
     turnStartIndex = 1    firstKeptEntryIndex = 7
            │                       │ (must be U/A/B, not T)
            └───── turn prefix ─────┘ (idx 1-6, summarized separately)
                                    └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [A idx 7, T idx 8, A idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:   0     1     2      3     4      5      6     7      8     9
        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │
        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘
                ↑                                       ↑
         turnStartIndex = 1                   firstKeptEntryIndex = 7
                │                                       │ (must be usr/ass/bash, not tool)
                └─────────── turn prefix ───────────────┘ (idx 1-6, summarized separately)
                                                        └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [ass idx 7, tool idx 8, ass idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 870 characters to 1081 characters.

     ...
 231
 232 ```
 233 Split turn example (one huge turn that exceeds keepRecentTokens):
 234
-235   index:  0   1   2   3   4   5   6   7   8   9
-236         ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
-237         │ H │ U │ A │ T │ A │ T │ T │ A │ T │ A │
-238         └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
-239             ↑                       ↑
-240      turnStartIndex = 1    firstKeptEntryIndex = 7
-241             │                       │ (must be U/A/B, not T)
-242             └───── turn prefix ─────┘ (idx 1-6, summarized separately)
-243                                     └── kept messages (idx 7-9)
+235   index:   0     1     2      3     4      5      6     7      8     9
+236         ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐
+237         │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │
+238         └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘
+239                 ↑                                       ↑
+240          turnStartIndex = 1                   firstKeptEntryIndex = 7
+241                 │                                       │ (must be usr/ass/bash, not tool)
+242                 └─────────── turn prefix ───────────────┘ (idx 1-6, summarized separately)
+243                                                         └── kept messages (idx 7-9)
 244
 245   messagesToSummarize = []  (no complete turns before this one)
-246   messagesToKeep = [A idx 7, T idx 8, A idx 9]
+246   messagesToKeep = [ass idx 7, tool idx 8, ass idx 9]
 247
 248 The default compaction generates TWO summaries that get merged:
 249 1. History summary (previousSummary + messagesToSummarize)
 250 2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Use clearer abbreviations in compaction diagrams" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 157ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 26ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main a2664ba] Use clearer abbreviations in compaction diagrams
 1 file changed, 32 insertions(+), 32 deletions(-)
To https://github.com/badlogic/pi-mono.git
   3c5f492..a2664ba  main -> main


│ [system] [summary] [usr idx 6] [ass idx 7] [tool idx 8] [tool idx 9] [ass idx 10] [tool idx 11] │

please use the format we use for the diagrams above, with indices above the row


stop commiting everything


edit

```
What gets sent to the LLM as context:

  ┌──────────────────────────────────────────────────────────────────────────────────────┐
  │ [system] [summary] [usr idx 6] [ass idx 7] [tool idx 8] [tool idx 9] [ass idx 10] [tool idx 11] │
  └──────────────────────────────────────────────────────────────────────────────────────┘
                 ↑              └──────────────────────┬──────────────────────┘
       from new cmp's summary              messages from firstKeptEntryIndex onwards
```

```
What gets sent to the LLM as context:

                 6     7      8      9     10     11
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
                 ↑      └──────────────┬──────────────┘
       from new cmp's        messages from
           summary       firstKeptEntryIndex onwards
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 495 characters to 437 characters.

     ...
 219
 220 ```
 221 What gets sent to the LLM as context:
 222
-223   ┌──────────────────────────────────────────────────────────────────────────────────────┐
-224   │ [system] [summary] [usr idx 6] [ass idx 7] [tool idx 8] [tool idx 9] [ass idx 10] [tool idx 11] │
-225   └──────────────────────────────────────────────────────────────────────────────────────┘
-226                  ↑              └──────────────────────┬──────────────────────┘
-227        from new cmp's summary              messages from firstKeptEntryIndex onwards
+223                  6     7      8      9     10     11
+224   ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
+225   │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
+226   └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
+227                  ↑      └──────────────┬──────────────┘
+228        from new cmp's        messages from
+229            summary       firstKeptEntryIndex onwards
 228 ```
 229
 230 **Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:
 231
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 158ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 22ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit


Fixed. Ready for you to review the full section before committing.


firstKeptEntryIndex = 6
                         on reload                    on reload        (stored in new cmp)

dude, this points to a tool result message, is that how compaction works?


You're right, that's wrong. Looking back at the code, `firstKeptEntryIndex` must point to a user, assistant, or bashExecution message, never a tool result. Let me fix the diagram so the cut point is at a valid position:


```
Legend:
  hdr  = header           usr  = user message       ass = assistant message
  tool = tool result      cmp  = compaction entry   bash = bashExecution
```

```
Session entries (before compaction):

  index:   0     1     2     3      4     5     6     7      8      9     10     11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┘
                ↑     └────────┬────────┘     └────────────┬────────────┘
         previousSummary    messagesToSummarize         messagesToKeep
                                       ↑
                        cutPoint.firstKeptEntryIndex = 6

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6     7      8      9     10     11    12
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └────────┬────────┘     └────────────┬────────────┘       ↑
                           ignored                      loaded         firstKeptEntryIndex = 6
                         on reload                    on reload        (stored in new cmp)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

[718 more lines in file. Use offset=223 to continue]


I see, index 6 is `usr` which is valid. But looking more carefully at the diagram, the arrows are confusing. Let me trace through:
- Index 6 is `usr` (valid cut point)
- `messagesToSummarize` should be indices 2-5 (usr, ass, tool, ass)
- `messagesToKeep` should be indices 6-11 (usr, ass, tool, tool, ass, tool)

That looks correct actually. Index 6 is `usr`, not `tool`. Let me double-check the "What gets sent to LLM" diagram:


The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

                 6     7      8      9     10     11
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
                 ↑      └──────────────┬──────────────┘
       from new cmp's        messages from
           summary       firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:   0     1     2      3     4      5      6     7      8     9

[703 more lines in file. Use offset=238 to continue]


The diagram shows index 6 as `usr` which is correct. But wait, looking at your message again:

> firstKeptEntryIndex = 6
> on reload                    on reload        (stored in new cmp)

Looking at the original entries diagram, index 6 is `usr`. But I think you're pointing out that I need to check if this is truly correct. Let me re-verify my entry layout:

Index 0: hdr
Index 1: cmp
Index 2: usr
Index 3: ass
Index 4: tool
Index 5: ass
Index 6: usr ← firstKeptEntryIndex (valid, it's a user message)
Index 7: ass
Index 8: tool
Index 9: tool
Index 10: ass
Index 11: tool

So index 6 = `usr` which is valid. Unless I'm misreading the diagram alignment? Let me check the spacing more carefully...

Actually wait, I see it now. In the "after compaction" diagram, the indices go 0-12, and looking at position 6 it's still `usr`. The diagram looks correct to me.

Are you seeing something different? Or is there another issue with the diagram?


/var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/TemporaryItems/NSIRD_screencaptureui_tWvIdd/Screenshot\ 2025-12-24\ at\ 13.01.23.png

it points at tool, not usr


Read image file [image/png]


Ah I see! The "after compaction" diagram has an extra `ass` at index 5 that wasn't in the "before" diagram. Let me fix both diagrams to be consistent and make sure `firstKeptEntryIndex` points to a `usr` message:

```
Session entries (before compaction):

  index:   0     1     2     3      4     5     6     7      8      9     10     11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┘
                ↑     └────────┬────────┘     └────────────┬────────────┘
         previousSummary    messagesToSummarize         messagesToKeep
                                       ↑
                        cutPoint.firstKeptEntryIndex = 6

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6     7      8      9     10     11    12
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └────────┬────────┘     └────────────┬────────────┘       ↑
                           ignored                      loaded         firstKeptEntryIndex = 6
                         on reload                    on reload        (stored in new cmp)
```

```
Session entries (before compaction):

  index:   0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
                ↑     └───────┬───────┘ └────────────┬────────────┘
         previousSummary  messagesToSummarize     messagesToKeep
                                   ↑
                    cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6      7      8     9     10    11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └───────┬───────┘ └────────────┬────────────┘       ↑
                          ignored                 loaded         firstKeptEntryIndex = 5
                        on reload               on reload        (stored in new cmp)
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 1305 characters to 1217 characters.

     ...
 194
 195 ```
 196 Session entries (before compaction):
 197
-198   index:   0     1     2     3      4     5     6     7      8      9     10     11
-199         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┐
-200         │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │
-201         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┘
-202                 ↑     └────────┬────────┘     └────────────┬────────────┘
-203          previousSummary    messagesToSummarize         messagesToKeep
-204                                        ↑
-205                         cutPoint.firstKeptEntryIndex = 6
+198   index:   0     1     2     3      4     5     6      7      8     9     10
+199         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
+200         │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
+201         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
+202                 ↑     └───────┬───────┘ └────────────┬────────────┘
+203          previousSummary  messagesToSummarize     messagesToKeep
+204                                    ↑
+205                     cutPoint.firstKeptEntryIndex = 5
 206
 207 After compaction (new entry appended):
 208
-209   index:   0     1     2     3      4     5     6     7      8      9     10     11    12
-210         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
-211         │ hdr │ cmp │ usr │ ass │ tool │ ass │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
-212         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
-213                       └────────┬────────┘     └────────────┬────────────┘       ↑
-214                            ignored                      loaded         firstKeptEntryIndex = 6
-215                          on reload                    on reload        (stored in new cmp)
+209   index:   0     1     2     3      4     5     6      7      8     9     10    11
+210         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
+211         │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
+212         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
+213                       └───────┬───────┘ └────────────┬────────────┘       ↑
+214                           ignored                 loaded         firstKeptEntryIndex = 5
+215                         on reload               on reload        (stored in new cmp)
 216 ```
 217
 218 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
 219
     ...


Now update the "What gets sent to LLM" diagram:

```
What gets sent to the LLM as context:

                 6     7      8      9     10     11
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
                 ↑      └──────────────┬──────────────┘
       from new cmp's        messages from
           summary       firstKeptEntryIndex onwards
```

```
What gets sent to the LLM as context:

                       5     6      7      8     9     10
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
                 ↑      └──────────────┬──────────────┘
       from new cmp's        messages from
           summary       firstKeptEntryIndex onwards
```


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 437 characters to 442 characters.

     ...
 219
 220 ```
 221 What gets sent to the LLM as context:
 222
-223                  6     7      8      9     10     11
+223                        5     6      7      8     9     10
 224   ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
 225   │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
 226   └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
 227                  ↑      └──────────────┬──────────────┘
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 160ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 43ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 16ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit


Fixed. Now `firstKeptEntryIndex = 5` points to `usr` (a user message), which is a valid cut point. The diagrams are consistent and show:
- Before: entries 0-10, with index 5 being `usr`
- After: entries 0-11, with the new `cmp` appended at index 11
- LLM context: loads from index 5 (`usr`) onwards


/var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/TemporaryItems/NSIRD_screencaptureui_BqAqdc/Screenshot\ 2025-12-24\ at\ 13.03.08.png

i claened up this one/var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/TemporaryItems/NSIRD_screencaptureui_673HEx/Screenshot\ 2025-12-24\ at\ 13.03.17.png

this one is still foobar


I see. The "loaded on reload" bracket doesn't start at index 5 (`usr`), it starts at index 6 (`ass`). Let me fix the bracket alignment:

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6      7      8     9     10    11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └───────┬───────┘ └────────────┬────────────┘       ↑
                          ignored                 loaded         firstKeptEntryIndex = 5
                        on reload               on reload        (stored in new cmp)

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6      7      8     9     10    11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └───────┬───────┘ └──────────────┬──────────────┘       ↑
                          ignored                   loaded           firstKeptEntryIndex = 5
                        on reload                 on reload          (stored in new cmp)


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 630 characters to 642 characters.

     ...
 209   index:   0     1     2     3      4     5     6      7      8     9     10    11
 210         ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
 211         │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
 212         └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
-213                       └───────┬───────┘ └────────────┬────────────┘       ↑
-214                           ignored                 loaded         firstKeptEntryIndex = 5
-215                         on reload               on reload        (stored in new cmp)
+213                       └───────┬───────┘ └──────────────┬──────────────┘       ↑
+214                           ignored                   loaded           firstKeptEntryIndex = 5
+215                         on reload                 on reload          (stored in new cmp)
 216 ```
 217
 218 The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).
 219
     ...


Wait, looking at the screenshot again, the issue is that the "loaded on reload" bracket starts at `ass` (index 6) instead of `usr` (index 5). The bracket positions in my edit still look like they start at index 5. Let me check the current state:


         previousSummary  messagesToSummarize     messagesToKeep
                                   ↑
                    cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6      7      8     9     10    11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
                      └───────┬───────┘ └──────────────┬──────────────┘       ↑
                          ignored                   loaded           firstKeptEntryIndex = 5
                        on reload                 on reload          (stored in new cmp)
```

[723 more lines in file. Use offset=218 to continue]


Actually looking at it now, the `└` for "loaded" starts right after the `┘` for "ignored", which should be at index 5. The problem might be that there's an extra space or the boxes aren't aligned. Let me count the characters more carefully and see what you cleaned up in the first screenshot so I can match that style.

Looking at your cleaned first screenshot, the brackets look cleaner. Can you share the actual text you have in the file now, or should I try to match the style from the first screenshot?


i fixed it pu, read lines 185-273