		if (beforeEvent.reason === "before_compact") {
			expect(beforeEvent.cutPoint).toBeDefined();
			expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
			expect(beforeEvent.messagesToSummarize).toBeDefined();
			expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0);
			expect(beforeEvent.model).toBeDefined();
			expect(beforeEvent.apiKey).toBeDefined();
		}

		if (beforeEvent.reason === "before_compact") {
			expect(beforeEvent.cutPoint).toBeDefined();
			expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
			expect(beforeEvent.messagesToSummarize).toBeDefined();
			expect(beforeEvent.messagesToKeep).toBeDefined();
			expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0);
			expect(beforeEvent.model).toBeDefined();
			expect(beforeEvent.resolveApiKey).toBeDefined();
		}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/test/compaction-hooks.test.ts. Changed 388 characters to 448 characters.

     ...
 128 		if (beforeEvent.reason === "before_compact") {
 129 			expect(beforeEvent.cutPoint).toBeDefined();
 130 			expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
 131 			expect(beforeEvent.messagesToSummarize).toBeDefined();
+132 			expect(beforeEvent.messagesToKeep).toBeDefined();
 132 			expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0);
 133 			expect(beforeEvent.model).toBeDefined();
-134 			expect(beforeEvent.apiKey).toBeDefined();
+135 			expect(beforeEvent.resolveApiKey).toBeDefined();
 135 		}
 136
 137 		const afterEvent = compactEvents[0];
 138 		if (afterEvent.reason === "compact") {
     ...


		expect(Array.isArray(event.messagesToSummarize)).toBe(true);

		expect(typeof event.tokensBefore).toBe("number");

		expect(event.model).toHaveProperty("provider");
		expect(event.model).toHaveProperty("id");

		expect(typeof event.apiKey).toBe("string");
		expect(event.apiKey.length).toBeGreaterThan(0);

		expect(Array.isArray(event.entries)).toBe(true);
		expect(event.entries.length).toBeGreaterThan(0);
	}, 120000);

	it("should use hook compactionEntry even with different firstKeptEntryIndex", async () => {
		const customSummary = "Custom summary with modified index";

		const hook = createHook((event) => {
			if (event.reason === "before_compact") {

[24 more lines in file. Use offset=358 to continue]


edit

		expect(Array.isArray(event.messagesToSummarize)).toBe(true);

		expect(typeof event.tokensBefore).toBe("number");

		expect(event.model).toHaveProperty("provider");
		expect(event.model).toHaveProperty("id");

		expect(typeof event.apiKey).toBe("string");
		expect(event.apiKey.length).toBeGreaterThan(0);

		expect(Array.isArray(event.entries)).toBe(true);
		expect(event.entries.length).toBeGreaterThan(0);

		expect(Array.isArray(event.messagesToSummarize)).toBe(true);
		expect(Array.isArray(event.messagesToKeep)).toBe(true);

		expect(typeof event.tokensBefore).toBe("number");

		expect(event.model).toHaveProperty("provider");
		expect(event.model).toHaveProperty("id");

		expect(typeof event.resolveApiKey).toBe("function");

		expect(Array.isArray(event.entries)).toBe(true);
		expect(event.entries.length).toBeGreaterThan(0);


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/test/compaction-hooks.test.ts. Changed 410 characters to 427 characters.

     ...
 336 		expect(event.cutPoint).toHaveProperty("isSplitTurn");
 337 		expect(event.cutPoint).toHaveProperty("turnStartIndex");
 338
 339 		expect(Array.isArray(event.messagesToSummarize)).toBe(true);
+340 		expect(Array.isArray(event.messagesToKeep)).toBe(true);
 340
 341 		expect(typeof event.tokensBefore).toBe("number");
 342
 343 		expect(event.model).toHaveProperty("provider");
 344 		expect(event.model).toHaveProperty("id");
 345
-346 		expect(typeof event.apiKey).toBe("string");
-347 		expect(event.apiKey.length).toBeGreaterThan(0);
+347 		expect(typeof event.resolveApiKey).toBe("function");
 348
 349 		expect(Array.isArray(event.entries)).toBe(true);
 350 		expect(event.entries.length).toBeGreaterThan(0);
 351 	}, 120000);
     ...


run the compaction hook tests after you fixed them up


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 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 40ms. 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 18ms. 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


cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npm test -- test/compaction-hooks.test.ts 2>&1


> @mariozechner/pi-coding-agent@0.27.5 test
> vitest --run test/compaction-hooks.test.ts

 RUN  v3.2.4 /Users/badlogic/workspaces/pi-mono/packages/coding-agent

 ✓ test/compaction-hooks.test.ts (8 tests) 62182ms
   ✓ Compaction hooks > should emit before_compact and compact events  11681ms
   ✓ Compaction hooks > should allow hooks to cancel compaction  2306ms
   ✓ Compaction hooks > should allow hooks to provide custom compactionEntry  4367ms
   ✓ Compaction hooks > should include entries in compact event after compaction is saved  8642ms
   ✓ Compaction hooks > should continue with default compaction if hook throws error  10109ms
   ✓ Compaction hooks > should call multiple hooks in order  10618ms
   ✓ Compaction hooks > should pass correct data in before_compact event  12089ms
   ✓ Compaction hooks > should use hook compactionEntry even with different firstKeptEntryIndex  2369ms

 Test Files  1 passed (1)
      Tests  8 passed (8)
   Start at  12:40:04
   Duration  62.96s (transform 254ms, setup 0ms, collect 575ms, tests 62.18s, environment 0ms, prepare 52ms)


All tests pass. Now update the changelog:

edit

## [Unreleased]

### Added

- **Compaction hook `resolveApiKey`**: The `before_compact` session event now includes `resolveApiKey` function to resolve API keys for any model (checks settings, OAuth, env vars). Useful for hooks that need to call different models during custom compaction.

## [Unreleased]

### Added

- **Compaction hook improvements**: The `before_compact` session event now includes:
  - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
  - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
  - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md. Changed 287 characters to 408 characters.

     ...
   3 ## [Unreleased]
   4
   5 ### Added
   6
-  7 - **Compaction hook `resolveApiKey`**: The `before_compact` session event now includes `resolveApiKey` function to resolve API keys for any model (checks settings, OAuth, env vars). Useful for hooks that need to call different models during custom compaction.
+  7 - **Compaction hook improvements**: The `before_compact` session event now includes:
+  8   - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
+  9   - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
+ 10   - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility
   8
   9 ## [0.27.5] - 2025-12-24
  10
  11 ### Added
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Improve before_compact hook: add messagesToKeep, replace apiKey with resolveApiKey" && 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 40ms. 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 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 d9a5427] Improve before_compact hook: add messagesToKeep, replace apiKey with resolveApiKey
 8 files changed, 51 insertions(+), 24 deletions(-)
To https://github.com/badlogic/pi-mono.git
   e428329..d9a5427  main -> main


Done. The changes:

1. **Removed `apiKey`** from `before_compact` event, replaced with `resolveApiKey` function
2. **Added `messagesToKeep`** - messages that will be kept after the summary (recent turns)
3. **Updated `full-compaction.ts` example** to:
   - Combine `messagesToSummarize` and `messagesToKeep` for full context summary
   - Use `entries.length` as `firstKeptEntryIndex` to discard everything
   - Use `resolveApiKey(model)` to get the API key
4. **Updated docs** with the new fields and usage pattern
5. **Fixed tests** to use new API


we need to improve the custom compaction docs in hooks.md, we need to explain exactly what all the fields mean and how they'd be used in the defautl compaction. we also need to point out that the full set of session entries is also availbale, if you want to implement a compaction scheme that takes the entire session into account.


- `before_branch` / `branch`: User branched the session (`/branch`)
- `before_compact` / `compact`: Context compaction (auto or `/compact`)
- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)

For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.

For `before_compact` events, additional fields are available:
- `event.cutPoint`: Where the context will be cut (`firstKeptEntryIndex`, `isSplitTurn`)
- `event.messagesToSummarize`: Messages that will be summarized and discarded
- `event.messagesToKeep`: Messages that will be kept after the summary (recent turns)
- `event.tokensBefore`: Current context token count
- `event.model`: Model to use for summarization
- `event.resolveApiKey`: Function to resolve API key for any model (checks settings, OAuth, env vars)
- `event.customInstructions`: Optional custom focus for summary (from `/compact` command)

To get all messages since the last compaction: `[...event.messagesToSummarize, ...event.messagesToKeep]`

Return `{ compactionEntry }` to provide a custom summary instead of the default. The `compactionEntry` must have: `type: "compaction"`, `timestamp`, `summary`, `firstKeptEntryIndex` (from `cutPoint`), `tokensBefore`.

For `compact` events (after compaction):
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook

### agent_start / agent_end

Fired once per user prompt.

```typescript
pi.on("agent_start", async (event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages: AppMessage[] - new messages from this prompt
});
```

### turn_start / turn_end

Fired for each turn within an agent loop.

```typescript
pi.on("turn_start", async (event, ctx) => {
  // event.turnIndex: number
  // event.timestamp: number
});

pi.on("turn_end", async (event, ctx) => {
  // event.turnIndex: number
  // event.message: AppMessage - assistant's response
  // event.toolResults: ToolResultMessage[] - tool results from this turn

[608 more lines in file. Use offset=225 to continue]


edit

For `before_compact` events, additional fields are available:
- `event.cutPoint`: Where the context will be cut (`firstKeptEntryIndex`, `isSplitTurn`)
- `event.messagesToSummarize`: Messages that will be summarized and discarded
- `event.messagesToKeep`: Messages that will be kept after the summary (recent turns)
- `event.tokensBefore`: Current context token count
- `event.model`: Model to use for summarization
- `event.resolveApiKey`: Function to resolve API key for any model (checks settings, OAuth, env vars)
- `event.customInstructions`: Optional custom focus for summary (from `/compact` command)

To get all messages since the last compaction: `[...event.messagesToSummarize, ...event.messagesToKeep]`

Return `{ compactionEntry }` to provide a custom summary instead of the default. The `compactionEntry` must have: `type: "compaction"`, `timestamp`, `summary`, `firstKeptEntryIndex` (from `cutPoint`), `tokensBefore`.

For `compact` events (after compaction):
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook

#### Custom Compaction

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

**How default compaction works:**
1. When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns
2. Messages before the cut point are summarized and discarded
3. Messages after the cut point are kept verbatim
4. The summary + kept messages become the new context

**Event fields:**

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `messagesToSummarize` | Messages that will be summarized and discarded (before cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (after cut point). |
| `tokensBefore` | Current context token count (why compaction triggered). |
| `model` | Model to use for summarization. |
| `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
| `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |

**Common patterns:**

```typescript
// Get all messages since last compaction
const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];

// Default behavior: summarize old, keep recent
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Your custom summary...",
    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
    tokensBefore: event.tokensBefore,
  }
};

// Full compaction: summarize everything, keep nothing
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Complete summary of all work...",
    firstKeptEntryIndex: event.entries.length, // discard all messages
    tokensBefore: event.tokensBefore,
  }
};
```

See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.

**After compaction (`compact` event):**
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook


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

     ...
 177 - `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)
 178
 179 For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.
 180
-181 For `before_compact` events, additional fields are available:
-182 - `event.cutPoint`: Where the context will be cut (`firstKeptEntryIndex`, `isSplitTurn`)
-183 - `event.messagesToSummarize`: Messages that will be summarized and discarded
-184 - `event.messagesToKeep`: Messages that will be kept after the summary (recent turns)
-185 - `event.tokensBefore`: Current context token count
-186 - `event.model`: Model to use for summarization
-187 - `event.resolveApiKey`: Function to resolve API key for any model (checks settings, OAuth, env vars)
-188 - `event.customInstructions`: Optional custom focus for summary (from `/compact` command)
+181 #### Custom Compaction
 189
-190 To get all messages since the last compaction: `[...event.messagesToSummarize, ...event.messagesToKeep]`
+183 The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:
 191
-192 Return `{ compactionEntry }` to provide a custom summary instead of the default. The `compactionEntry` must have: `type: "compaction"`, `timestamp`, `summary`, `firstKeptEntryIndex` (from `cutPoint`), `tokensBefore`.
+185 **How default compaction works:**
+186 1. When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns
+187 2. Messages before the cut point are summarized and discarded
+188 3. Messages after the cut point are kept verbatim
+189 4. The summary + kept messages become the new context
 193
-194 For `compact` events (after compaction):
+191 **Event fields:**
+192
+193 | Field | Description |
+194 |-------|-------------|
+195 | `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
+196 | `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
+197 | `messagesToSummarize` | Messages that will be summarized and discarded (before cut point). |
+198 | `messagesToKeep` | Messages that will be kept verbatim after the summary (after cut point). |
+199 | `tokensBefore` | Current context token count (why compaction triggered). |
+200 | `model` | Model to use for summarization. |
+201 | `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
+202 | `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |
+203
+204 **Common patterns:**
+205
+206 ```typescript
+207 // Get all messages since last compaction
+208 const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];
+209
+210 // Default behavior: summarize old, keep recent
+211 return {
+212   compactionEntry: {
+213     type: "compaction",
+214     timestamp: new Date().toISOString(),
+215     summary: "Your custom summary...",
+216     firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
+217     tokensBefore: event.tokensBefore,
+218   }
+219 };
+220
+221 // Full compaction: summarize everything, keep nothing
+222 return {
+223   compactionEntry: {
+224     type: "compaction",
+225     timestamp: new Date().toISOString(),
+226     summary: "Complete summary of all work...",
+227     firstKeptEntryIndex: event.entries.length, // discard all messages
+228     tokensBefore: event.tokensBefore,
+229   }
+230 };
+231 ```
+232
+233 See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.
+234
+235 **After compaction (`compact` event):**
 195 - `event.compactionEntry`: The saved compaction entry
 196 - `event.tokensBefore`: Token count before compaction
 197 - `event.fromHook`: Whether the compaction entry was provided by a hook
 198
     ...


> 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 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 17ms. 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 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


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Improve custom compaction docs in hooks.md" && 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 153ms. 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 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 ee0befd] Improve custom compaction docs in hooks.md
 1 file changed, 52 insertions(+), 11 deletions(-)
To https://github.com/badlogic/pi-mono.git
   d9a5427..ee0befd  main -> main


Done. The docs now explain:

1. How default compaction works (cut point, summarize old, keep recent)
2. Each field and what it's for, including `entries` for full session access
3. Common patterns with code examples (default behavior vs full compaction)
4. Link to the full-compaction example


| `messagesToSummarize` | Messages that will be summarized and discarded (before cut point). |

does this include the last compaction event?


Good catch. Let me check:


	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);

[157 more lines in file. Use offset=375 to continue]


No, `messagesToSummarize` does NOT include the last compaction event.

Looking at the code:
- `boundaryStart = prevCompactionIndex + 1` - starts AFTER the previous compaction
- The loop only includes entries of `type === "message"`, not compaction entries

So `messagesToSummarize` only contains messages since the last compaction (or from the beginning if no prior compaction). The docs are correct but could be clearer. Let me update:

edit

| `messagesToSummarize` | Messages that will be summarized and discarded (before cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (after cut point). |

| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |


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

     ...
 193 | Field | Description |
 194 |-------|-------------|
 195 | `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
 196 | `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
-197 | `messagesToSummarize` | Messages that will be summarized and discarded (before cut point). |
-198 | `messagesToKeep` | Messages that will be kept verbatim after the summary (after cut point). |
+197 | `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
+198 | `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
 199 | `tokensBefore` | Current context token count (why compaction triggered). |
 200 | `model` | Model to use for summarization. |
 201 | `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
 202 | `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Clarify messagesToSummarize starts after last compaction" && 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 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 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 97bbd7a] Clarify messagesToSummarize starts after last compaction
 1 file changed, 2 insertions(+), 2 deletions(-)
To https://github.com/badlogic/pi-mono.git
   ee0befd..97bbd7a  main -> main


Done. The docs now clarify that `messagesToSummarize` contains messages "from after last compaction to cut point", making it clear the previous compaction entry itself is not included.


Summary:
# Context Checkpoint Summary

## Project Overview
Working on `pi-mono` - a monorepo for pi coding agent at `/Users/badlogic/workspaces/pi-mono`. The main package is `@mariozechner/pi-coding-agent`.

## Session Progress

### Completed Tasks

1. **Issue #288 - Allow startup without API keys**
   - Modified `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts` to not throw when no models available
   - Users can now start the agent and use `/login` to authenticate

2. **Issue #295 - API keys in settings.json**
   - Added `apiKeys?: Record<string, string>` to Settings interface in `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/settings-manager.ts`
   - Modified `defaultGetApiKey()` in sdk.ts to check settings first
   - Added `resolveApiKey` to `AgentSessionConfig` in `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts`
   - Injected resolver avoids global state issues for multi-session SDK usage
   - Updated README and sdk.md documentation

3. **Issue #296 - Created issue documenting OAuth/API key mess**
   - Documented current scattered state across ai package and coding-agent
   - Proposed refactor to consolidate in coding-agent with no global state in ai package

4. **HTML Export Syntax Highlighting**
   - Added highlight.js to `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts`
   - Theme-aware colors matching TUI (uses `colors.syntaxKeyword`, `colors.syntaxString`, etc.)
   - Highlights code blocks in markdown and tool outputs (read, write)

5. **PR #299 Changelog - Ghostty tmux fix**
   - Added changelog entry for Ghostty inline images in tmux fix

6. **Full Compaction Hook Example**
   - Created `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/full-compaction.ts`
   - Uses `before_compact` hook event to intercept compaction
   - Calls `complete()` from `@mariozechner/pi-ai` with provided model/apiKey
   - Generates comprehensive summary instead of keeping recent turns

### Releases
- v0.27.3 - API keys in settings.json, startup without keys
- v0.27.4 - Symlinked skills fix
- v0.27.5 - HTML export improvements, Ghostty tmux fix

## Key Architecture Decisions

### API Key Resolution Priority
1. `settings.json` apiKeys
2. Custom providers (models.json)
3. OAuth credentials (oauth.json)
4. Environment variables

### Avoiding Global State
- `AgentSession` now takes `resolveApiKey?: (model) => Promise<string | undefined>` in config
- `createAgentSession` constructs resolver that checks settings first
- This allows multiple sessions with different key resolvers in same process

## Important Files
- `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts` - Main SDK, session creation
- `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts` - AgentSession class
- `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/settings-manager.ts` - Settings with apiKeys
- `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/export-html.ts` - HTML export with syntax highlighting
- `/Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md` - Track changes here

## User Preferences
- Prefers surgical edits to CHANGELOG.md, not full rewrites
- Uses `npm run check` to validate changes
- Release process: `npm run version:patch`, update CHANGELOG date, commit with "Release vX.Y.Z", tag, push, `npm run publish`
- Comment on issues with concise "Thanks for reporting, fixed in next release"

## Current State
- On `main` branch, up to date
- Latest release: v0.27.5
- No pending work items mentioned

---

**Turn Context (split turn):**

## Context Checkpoint Summary

### Original Request
User requested adding `resolveApiKey` to `before_compact` hook events, so hooks can easily invoke models via `complete()` for different models.

### Progress Made
1. **Found the two emission locations** in `agent-session.ts`:
   - Line ~768: Manual compaction (from `/compact` command)
   - Line ~918: Auto-compaction during agent loop

2. **Updated the types** in `hooks/types.ts`:
   - Added `resolveApiKey: (model: Model<any>) => Promise<string | undefined>` to the `before_compact` event type

3. **Updated both emission sites** in `agent-session.ts`:
   - Added `resolveApiKey: this._resolveApiKey` to both `before_compact` event emissions

4. **All checks pass** - TypeScript compilation successful

### Current State
Need to update the documentation in `packages/coding-agent/docs/hooks.md` to document the new `resolveApiKey` field for `before_compact` events. The doc section is around line 181-189, which lists the available fields for `before_compact` events.


does the default compaction take into account the last compaction summary? it would be bad if it didn't


Let me check how the default compaction builds its messages:


- 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;

[62 more lines in file. Use offset=470 to continue]


Yes, the default compaction does include the previous summary:

```typescript
// 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(),
    });
}
```

It prepends the previous compaction summary as a user message before the messages to summarize, so the LLM sees the full context when generating the new summary.

However, the `before_compact` hook only passes `messagesToSummarize` which does NOT include this. A hook implementing custom compaction would need to manually check for a previous compaction in `entries` and include it. We should either:

1. Document this in hooks.md so users know to check for it
2. Or add the previous summary to the event data

What do you prefer?


add the previous summary to the event data, can be undefined


			targetTurnIndex: number;
	  })
	| (SessionEventBase & {
			reason: "before_compact";
			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;
			customInstructions?: string;
			model: Model<any>;
			/** Resolve API key for any model (checks settings, OAuth, env vars) */
			resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
	  })
	| (SessionEventBase & {
			reason: "compact";
			compactionEntry: CompactionEntry;
			tokensBefore: number;
			/** Whether the compaction entry was provided by a hook */

[256 more lines in file. Use offset=148 to continue]


edit

	| (SessionEventBase & {
			reason: "before_compact";
			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;
			customInstructions?: string;
			model: Model<any>;
			/** Resolve API key for any model (checks settings, OAuth, env vars) */
			resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
	  })

	| (SessionEventBase & {
			reason: "before_compact";
			cutPoint: CutPointResult;
			/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
			previousSummary?: string;
			/** Messages that will be summarized and discarded */
			messagesToSummarize: AppMessage[];
			/** Messages that will be kept after the summary (recent turns) */
			messagesToKeep: AppMessage[];
			tokensBefore: number;
			customInstructions?: string;
			model: Model<any>;
			/** Resolve API key for any model (checks settings, OAuth, env vars) */
			resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
	  })


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/hooks/types.ts. Changed 510 characters to 641 characters.

     ...
 129 	  })
 130 	| (SessionEventBase & {
 131 			reason: "before_compact";
 132 			cutPoint: CutPointResult;
+133 			/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
+134 			previousSummary?: string;
 133 			/** Messages that will be summarized and discarded */
 134 			messagesToSummarize: AppMessage[];
 135 			/** Messages that will be kept after the summary (recent turns) */
 136 			messagesToKeep: AppMessage[];
     ...


Now update both emission sites in agent-session.ts. First, I need to add logic to extract the previous summary:


			if (!preparation) {
				throw new Error("Already compacted");
			}

			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const result = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;

				if (result?.cancel) {
					throw new Error("Compaction cancelled");
				}

				if (result?.compactionEntry) {
					compactionEntry = result.compactionEntry;
					fromHook = true;
				}
			}

			if (!compactionEntry) {
				compactionEntry = await compact(
					entries,
					this.model,
					settings,
					apiKey,
					this._compactionAbortController.signal,
					customInstructions,
				);
			}

			if (this._compactionAbortController.signal.aborted) {
				throw new Error("Compaction cancelled");
			}

			this.sessionManager.saveCompaction(compactionEntry);
			const newEntries = this.sessionManager.loadEntries();

[765 more lines in file. Use offset=805 to continue]


I need to find the previous compaction and extract its summary. Let me add a helper or do it inline:

			if (!preparation) {
				throw new Error("Already compacted");
			}

			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const result = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;

			if (!preparation) {
				throw new Error("Already compacted");
			}

			// Find previous compaction summary if any
			let previousSummary: string | undefined;
			for (let i = entries.length - 1; i >= 0; i--) {
				if (entries[i].type === "compaction") {
					previousSummary = (entries[i] as CompactionEntry).summary;
					break;
				}
			}

			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const result = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					previousSummary,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts. Changed 718 characters to 1013 characters.

      ...
  755 			if (!preparation) {
  756 				throw new Error("Already compacted");
  757 			}
  758
+ 759 			// Find previous compaction summary if any
+ 760 			let previousSummary: string | undefined;
+ 761 			for (let i = entries.length - 1; i >= 0; i--) {
+ 762 				if (entries[i].type === "compaction") {
+ 763 					previousSummary = (entries[i] as CompactionEntry).summary;
+ 764 					break;
+ 765 				}
+ 766 			}
+ 767
  759 			let compactionEntry: CompactionEntry | undefined;
  760 			let fromHook = false;
  761
  762 			if (this._hookRunner?.hasHandlers("session")) {
  763 				const result = (await this._hookRunner.emit({
  764 					type: "session",
  765 					entries,
  766 					sessionFile: this.sessionFile,
  767 					previousSessionFile: null,
  768 					reason: "before_compact",
  769 					cutPoint: preparation.cutPoint,
+ 779 					previousSummary,
  770 					messagesToSummarize: [...preparation.messagesToSummarize],
  771 					messagesToKeep: [...preparation.messagesToKeep],
  772 					tokensBefore: preparation.tokensBefore,
  773 					customInstructions,
      ...


Now the second location (auto-compaction):


			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const hookResult = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions: undefined,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;

				if (hookResult?.cancel) {
					this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
					return;
				}

				if (hookResult?.compactionEntry) {
					compactionEntry = hookResult.compactionEntry;
					fromHook = true;
				}
			}

			if (!compactionEntry) {
				compactionEntry = await compact(
					entries,
					this.model,
					settings,
					apiKey,
					this._autoCompactionAbortController.signal,
				);
			}

			if (this._autoCompactionAbortController.signal.aborted) {
				this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
				return;
			}

			this.sessionManager.saveCompaction(compactionEntry);
			const newEntries = this.sessionManager.loadEntries();
			const loaded = loadSessionFromEntries(newEntries);
			this.agent.replaceMessages(loaded.messages);

[610 more lines in file. Use offset=970 to continue]


			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const hookResult = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions: undefined,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;

			// Find previous compaction summary if any
			let previousSummary: string | undefined;
			for (let i = entries.length - 1; i >= 0; i--) {
				if (entries[i].type === "compaction") {
					previousSummary = (entries[i] as CompactionEntry).summary;
					break;
				}
			}

			let compactionEntry: CompactionEntry | undefined;
			let fromHook = false;

			if (this._hookRunner?.hasHandlers("session")) {
				const hookResult = (await this._hookRunner.emit({
					type: "session",
					entries,
					sessionFile: this.sessionFile,
					previousSessionFile: null,
					reason: "before_compact",
					cutPoint: preparation.cutPoint,
					previousSummary,
					messagesToSummarize: [...preparation.messagesToSummarize],
					messagesToKeep: [...preparation.messagesToKeep],
					tokensBefore: preparation.tokensBefore,
					customInstructions: undefined,
					model: this.model,
					resolveApiKey: this._resolveApiKey,
				})) as SessionEventResult | undefined;


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts. Changed 662 characters to 957 characters.

      ...
  916 				this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
  917 				return;
  918 			}
  919
+ 920 			// Find previous compaction summary if any
+ 921 			let previousSummary: string | undefined;
+ 922 			for (let i = entries.length - 1; i >= 0; i--) {
+ 923 				if (entries[i].type === "compaction") {
+ 924 					previousSummary = (entries[i] as CompactionEntry).summary;
+ 925 					break;
+ 926 				}
+ 927 			}
+ 928
  920 			let compactionEntry: CompactionEntry | undefined;
  921 			let fromHook = false;
  922
  923 			if (this._hookRunner?.hasHandlers("session")) {
  924 				const hookResult = (await this._hookRunner.emit({
  925 					type: "session",
  926 					entries,
  927 					sessionFile: this.sessionFile,
  928 					previousSessionFile: null,
  929 					reason: "before_compact",
  930 					cutPoint: preparation.cutPoint,
+ 940 					previousSummary,
  931 					messagesToSummarize: [...preparation.messagesToSummarize],
  932 					messagesToKeep: [...preparation.messagesToKeep],
  933 					tokensBefore: preparation.tokensBefore,
  934 					customInstructions: undefined,
      ...


Now update the docs:

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |


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

     ...
 193 | Field | Description |
 194 |-------|-------------|
 195 | `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
 196 | `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
+197 | `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
 197 | `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
 198 | `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
 199 | `tokensBefore` | Current context token count (why compaction triggered). |
 200 | `model` | Model to use for summarization. |
     ...


Now update the full-compaction example to use `previousSummary`:


export default function (pi: HookAPI) {
	pi.on("session", async (event, ctx) => {
		if (event.reason !== "before_compact") return;

		const { messagesToSummarize, messagesToKeep, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to LLM-compatible format
		const transformedMessages = messageTransformer(allMessages);

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:

1. The main goals and objectives discussed
2. Key decisions made and their rationale
3. Important code changes, file modifications, or technical details
4. Current state of any ongoing work
5. Any blockers, issues, or open questions
6. Next steps that were planned or suggested

Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.

Format the summary as structured markdown with clear sections.`,
					},
				],
				timestamp: Date.now(),
			},
		];

		try {
			// Use the same model with resolved API key
			const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192 });

[31 more lines in file. Use offset=70 to continue]


		const { messagesToSummarize, messagesToKeep, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to LLM-compatible format
		const transformedMessages = messageTransformer(allMessages);

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:

		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to LLM-compatible format
		const transformedMessages = messageTransformer(allMessages);

		// Include previous summary context if available
		const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/full-compaction.ts. Changed 985 characters to 1186 characters.

     ...
  20 export default function (pi: HookAPI) {
  21 	pi.on("session", async (event, ctx) => {
  22 		if (event.reason !== "before_compact") return;
  23
- 24 		const { messagesToSummarize, messagesToKeep, tokensBefore, model, resolveApiKey, entries } = event;
+ 24 		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;
  25
  26 		// Combine all messages for full summary
  27 		const allMessages = [...messagesToSummarize, ...messagesToKeep];
  28
  29 		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");
  30
  31 		// Resolve API key for the model
  32 		const apiKey = await resolveApiKey(model);
  33 		if (!apiKey) {
  34 			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
  35 			return;
  36 		}
  37
  38 		// Transform app messages to LLM-compatible format
  39 		const transformedMessages = messageTransformer(allMessages);
  40
+ 41 		// Include previous summary context if available
+ 42 		const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
+ 43
  41 		// Build messages that ask for a comprehensive summary
  42 		const summaryMessages = [
  43 			...transformedMessages,
  44 			{
  45 				role: "user" as const,
  46 				content: [
  47 					{
  48 						type: "text" as const,
- 49 						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:
+ 52 						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}
  50
  51 1. The main goals and objectives discussed
  52 2. Key decisions made and their rationale
  53 3. Important code changes, file modifications, or technical details
     ...


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

Checked 286 files in 162ms. 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 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 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


- **Compaction hook improvements**: The `before_compact` session event now includes:
  - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
  - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
  - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility

- **Compaction hook improvements**: The `before_compact` session event now includes:
  - `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context
  - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
  - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
  - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md. Changed 380 characters to 488 characters.

     ...
   4
   5 ### Added
   6
   7 - **Compaction hook improvements**: The `before_compact` session event now includes:
+  8   - `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context
   8   - `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
   9   - `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
  10   - Removed `apiKey` string in favor of `resolveApiKey` for more flexibility
  11
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add previousSummary to before_compact hook event" && 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 40ms. 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 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 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 403faaf] Add previousSummary to before_compact hook event
 5 files changed, 29 insertions(+), 2 deletions(-)
To https://github.com/badlogic/pi-mono.git
   97bbd7a..403faaf  main -> main


Done. Added `previousSummary` to the `before_compact` hook event so custom compaction hooks can preserve accumulated context from prior compactions. The full-compaction example now includes it in the prompt.


**How default compaction works:** can we add one of those "asccii diagrams" i did to illustrate the way we select entries etc.?


Let me look at an example of those ASCII diagrams you've done:

cd /Users/badlogic/workspaces/pi-mono && grep -r "─\|│\|├\|└" --include="*.md" | head -50


./node_modules/@anthropic-ai/sandbox-runtime/README.md:├── index.ts                  # Library exports
./node_modules/@anthropic-ai/sandbox-runtime/README.md:├── cli.ts                    # CLI entrypoint (srt command)
./node_modules/@anthropic-ai/sandbox-runtime/README.md:├── utils/                    # Shared utilities
./node_modules/@anthropic-ai/sandbox-runtime/README.md:│   ├── debug.ts             # Debug logging
./node_modules/@anthropic-ai/sandbox-runtime/README.md:│   ├── settings.ts          # Settings reader (permissions + sandbox config)
./node_modules/@anthropic-ai/sandbox-runtime/README.md:│   ├── platform.ts          # Platform detection
./node_modules/@anthropic-ai/sandbox-runtime/README.md:│   └── exec.ts              # Command execution utilities
./node_modules/@anthropic-ai/sandbox-runtime/README.md:└── sandbox/                  # Sandbox implementation
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── sandbox-manager.ts    # Main sandbox manager
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── sandbox-schemas.ts    # Zod schemas for validation
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── sandbox-violation-store.ts # Violation tracking
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── sandbox-utils.ts      # Shared sandbox utilities
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── http-proxy.ts         # HTTP/HTTPS proxy for network filtering
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── socks-proxy.ts        # SOCKS5 proxy for network filtering
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    ├── linux-sandbox-utils.ts # Linux bubblewrap sandboxing
./node_modules/@anthropic-ai/sandbox-runtime/README.md:    └── macos-sandbox-utils.ts # macOS sandbox-exec sandboxing
./node_modules/escalade/readme.md:  └── oss
./node_modules/escalade/readme.md:    ├── license
./node_modules/escalade/readme.md:    └── escalade
./node_modules/escalade/readme.md:      ├── package.json
./node_modules/escalade/readme.md:      └── test
./node_modules/escalade/readme.md:        └── fixtures
./node_modules/escalade/readme.md:          ├── index.js
./node_modules/escalade/readme.md:          └── foobar
./node_modules/escalade/readme.md:            └── demo.js
./node_modules/@mariozechner/mini-lit/README.md:├── src/              # mini-lit component library source
./node_modules/@mariozechner/mini-lit/README.md:├── dist/             # Compiled library output
./node_modules/@mariozechner/mini-lit/README.md:├── styles/           # Theme CSS files
./node_modules/@mariozechner/mini-lit/README.md:├── example/          # Interactive component gallery
./node_modules/@mariozechner/mini-lit/README.md:│   └── src/
./node_modules/@mariozechner/mini-lit/README.md:│       └── pages/    # Individual component demos
./node_modules/@mariozechner/mini-lit/README.md:└── package.json      # Library package configuration
./node_modules/tinybench/README.md:// ┌─────────┬───────────────┬──────────┬────────────────────┬───────────┬─────────┐
./node_modules/tinybench/README.md:// │ (index) │   Task Name   │ ops/sec  │ Average Time (ns)  │  Margin   │ Samples │
./node_modules/tinybench/README.md:// ├─────────┼───────────────┼──────────┼────────────────────┼───────────┼─────────┤
./node_modules/tinybench/README.md:// │    0    │ 'faster task' │ '41,621' │ 24025.791819761525 │ '±20.50%' │  4257   │
./node_modules/tinybench/README.md:// │    1    │ 'slower task' │  '828'   │ 1207382.7838323202 │ '±7.07%'  │   83    │
./node_modules/tinybench/README.md:// └─────────┴───────────────┴──────────┴────────────────────┴───────────┴─────────┘
./node_modules/tinybench/README.md:// ┌─────────┬───────────────────────┐
./node_modules/tinybench/README.md:// │ (index) │       Task name       │
./node_modules/tinybench/README.md:// ├─────────┼───────────────────────┤
./node_modules/tinybench/README.md:// │    0    │ 'unimplemented bench' │
./node_modules/tinybench/README.md:// └─────────┴───────────────────────┘
./node_modules/@napi-rs/canvas/README.md:┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
./node_modules/@napi-rs/canvas/README.md:│ (index) │ Task name       │ Latency average (ns)  │ Latency median (ns)      │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
./node_modules/@napi-rs/canvas/README.md:├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
./node_modules/@napi-rs/canvas/README.md:│ 0       │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00'            │ '68 ± 0.59%'               │ '68'                      │ 69      │
./node_modules/@napi-rs/canvas/README.md:│ 1       │ 'skia-canvas'   │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%'               │ '48'                      │ 64      │
./node_modules/@napi-rs/canvas/README.md:│ 2       │ 'node-canvas'   │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50'  │ '60 ± 0.62%'               │ '61'                      │ 64      │
./node_modules/@napi-rs/canvas/README.md:└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘


Let me look in the pi-mono docs specifically:

cd /Users/badlogic/workspaces/pi-mono && grep -B5 -A20 "```" packages/coding-agent/docs/*.md | grep -A20 "ascii\|diagram\|```$" | head -80


sh:  packages/coding-agent/docs/*.md | grep -A20 ascii|diagram|: No such file or directory


cd /Users/badlogic/workspaces/pi-mono && grep -rn "entry\|Entry" packages/coding-agent/docs/hooks.md | head -10


packages/coding-agent/docs/hooks.md:151:  // event.entries: SessionEntry[] - all session entries
packages/coding-agent/docs/hooks.md:179:For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.
packages/coding-agent/docs/hooks.md:196:| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
packages/coding-agent/docs/hooks.md:213:  compactionEntry: {
packages/coding-agent/docs/hooks.md:217:    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
packages/coding-agent/docs/hooks.md:224:  compactionEntry: {
packages/coding-agent/docs/hooks.md:228:    firstKeptEntryIndex: event.entries.length, // discard all messages
packages/coding-agent/docs/hooks.md:237:- `event.compactionEntry`: The saved compaction entry
packages/coding-agent/docs/hooks.md:239:- `event.fromHook`: Whether the compaction entry was provided by a hook
packages/coding-agent/docs/hooks.md:614:    // Create a git stash entry before LLM makes changes


cd /Users/badlogic/workspaces/pi-mono && grep -B2 -A15 "^\[" packages/coding-agent/docs/hooks.md | head -60


(no output)


Let me look at the compaction.ts file for context on the algorithm:


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

[481 more lines in file. Use offset=51 to continue]


Now let me look at the current hooks.md section and create a good ASCII diagram:


#### Custom Compaction

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

**How default compaction works:**
1. When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns
2. Messages before the cut point are summarized and discarded
3. Messages after the cut point are kept verbatim
4. The summary + kept messages become the new context

**Event fields:**

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
| `tokensBefore` | Current context token count (why compaction triggered). |
| `model` | Model to use for summarization. |
| `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
| `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |

**Common patterns:**

```typescript
// Get all messages since last compaction
const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];

// Default behavior: summarize old, keep recent
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Your custom summary...",
    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
    tokensBefore: event.tokensBefore,
  }
};

// Full compaction: summarize everything, keep nothing
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Complete summary of all work...",
    firstKeptEntryIndex: event.entries.length, // discard all messages
    tokensBefore: event.tokensBefore,
  }
};
```

See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.

**After compaction (`compact` event):**
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook

### agent_start / agent_end

Fired once per user prompt.

```typescript
pi.on("agent_start", async (event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages: AppMessage[] - new messages from this prompt
});

[624 more lines in file. Use offset=251 to continue]


edit

**How default compaction works:**
1. When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns
2. Messages before the cut point are summarized and discarded
3. Messages after the cut point are kept verbatim
4. The summary + kept messages become the new context

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:

```
Session entries (before compaction):
┌─────────────────────────────────────────────────────────────────┐
│ [header] [prev compaction] [msg] [msg] [msg] [msg] [msg] [msg]  │
│                   ↑          └─────┬─────┘   └───────┬───────┘  │
│           previousSummary    messagesToSummarize  messagesToKeep│
│                              (will be discarded)  (kept as-is)  │
│                                         ↑                       │
│                              cutPoint.firstKeptEntryIndex       │
└─────────────────────────────────────────────────────────────────┘

After compaction:
┌─────────────────────────────────────────────────────────────────┐
│ [header] [prev compaction] [NEW compaction] [msg] [msg] [msg]   │
│                             └──────┬──────┘ └───────┬───────┘   │
│                               summary of      messagesToKeep    │
│                            messagesToSummarize  (unchanged)     │
│                            + previousSummary                    │
└─────────────────────────────────────────────────────────────────┘
```

The new compaction entry's `firstKeptEntryIndex` tells the session loader where to start reading messages after the summary.


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

     ...
 182
 183 The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:
 184
 185 **How default compaction works:**
-186 1. When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns
-187 2. Messages before the cut point are summarized and discarded
-188 3. Messages after the cut point are kept verbatim
-189 4. The summary + kept messages become the new context
 190
+187 When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:
+188
+189 ```
+190 Session entries (before compaction):
+191 ┌─────────────────────────────────────────────────────────────────┐
+192 │ [header] [prev compaction] [msg] [msg] [msg] [msg] [msg] [msg]  │
+193 │                   ↑          └─────┬─────┘   └───────┬───────┘  │
+194 │           previousSummary    messagesToSummarize  messagesToKeep│
+195 │                              (will be discarded)  (kept as-is)  │
+196 │                                         ↑                       │
+197 │                              cutPoint.firstKeptEntryIndex       │
+198 └─────────────────────────────────────────────────────────────────┘
+199
+200 After compaction:
+201 ┌─────────────────────────────────────────────────────────────────┐
+202 │ [header] [prev compaction] [NEW compaction] [msg] [msg] [msg]   │
+203 │                             └──────┬──────┘ └───────┬───────┘   │
+204 │                               summary of      messagesToKeep    │
+205 │                            messagesToSummarize  (unchanged)     │
+206 │                            + previousSummary                    │
+207 └─────────────────────────────────────────────────────────────────┘
+208 ```
+209
+210 The new compaction entry's `firstKeptEntryIndex` tells the session loader where to start reading messages after the summary.
+211
 191 **Event fields:**
 192
 193 | Field | Description |
 194 |-------|-------------|
     ...


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


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add ASCII 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 159ms. 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 21ms. 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 3ms. No fixes applied.

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

Checked 27 files in 24ms. 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 ea16af8] Add ASCII diagram to compaction docs
 1 file changed, 25 insertions(+), 4 deletions(-)
To https://github.com/badlogic/pi-mono.git
   403faaf..ea16af8  main -> main


Done. Added an ASCII diagram showing:
- Before compaction: how entries are laid out with `previousSummary`, `messagesToSummarize`, `messagesToKeep`, and where `cutPoint.firstKeptEnt


this is not how it works. the session entry array is append only


Ah, you're right. The compaction entry is appended, not inserted. Let me fix the diagram:

edit

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:

```
Session entries (before compaction):
┌─────────────────────────────────────────────────────────────────┐
│ [header] [prev compaction] [msg] [msg] [msg] [msg] [msg] [msg]  │
│                   ↑          └─────┬─────┘   └───────┬───────┘  │
│           previousSummary    messagesToSummarize  messagesToKeep│
│                              (will be discarded)  (kept as-is)  │
│                                         ↑                       │
│                              cutPoint.firstKeptEntryIndex       │
└─────────────────────────────────────────────────────────────────┘

After compaction:
┌─────────────────────────────────────────────────────────────────┐
│ [header] [prev compaction] [NEW compaction] [msg] [msg] [msg]   │
│                             └──────┬──────┘ └───────┬───────┘   │
│                               summary of      messagesToKeep    │
│                            messagesToSummarize  (unchanged)     │
│                            + previousSummary                    │
└─────────────────────────────────────────────────────────────────┘
```

The new compaction entry's `firstKeptEntryIndex` tells the session loader where to start reading messages after the summary.

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:

```
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`.


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

     ...
 187 When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:
 188
 189 ```
 190 Session entries (before compaction):
-191 ┌─────────────────────────────────────────────────────────────────┐
-192 │ [header] [prev compaction] [msg] [msg] [msg] [msg] [msg] [msg]  │
-193 │                   ↑          └─────┬─────┘   └───────┬───────┘  │
-194 │           previousSummary    messagesToSummarize  messagesToKeep│
-195 │                              (will be discarded)  (kept as-is)  │
-196 │                                         ↑                       │
-197 │                              cutPoint.firstKeptEntryIndex       │
-198 └─────────────────────────────────────────────────────────────────┘
 199
-200 After compaction:
-201 ┌─────────────────────────────────────────────────────────────────┐
-202 │ [header] [prev compaction] [NEW compaction] [msg] [msg] [msg]   │
-203 │                             └──────┬──────┘ └───────┬───────┘   │
-204 │                               summary of      messagesToKeep    │
-205 │                            messagesToSummarize  (unchanged)     │
-206 │                            + previousSummary                    │
-207 └─────────────────────────────────────────────────────────────────┘
+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
+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)
 208 ```
 209
-210 The new compaction entry's `firstKeptEntryIndex` tells the session loader where to start reading messages after the summary.
+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`.
 211
 212 **Event fields:**
 213
 214 | Field | Description |
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Fix compaction ASCII diagram to show append-only behavior" && 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 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 17ms. 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 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 ea3ab71] Fix compaction ASCII diagram to show append-only behavior
 1 file changed, 22 insertions(+), 20 deletions(-)
To https://github.com/badlogic/pi-mono.git
   ea16af8..ea3ab71  main -> main


Done. The diagram now correctly shows:
- Entries are append-only (new compaction added at index 8)
- `firstKeptEntryIndex = 5` is stored in the new compaction entry
- On reload: messages before index 5 are ignored, messages from index 5 onwards are loaded
- The summary in the compaction entry replaces the ignored messages


ok, and then we need to also show what will actually be passed to the agent as context


#### 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 ~20k tokens of recent turns:

```
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`.

**Event fields:**

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |

[677 more lines in file. Use offset=221 to continue]


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`.

```
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
```


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

     ...
 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`.
 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
 214 **Event fields:**
 215
 216 | Field | Description |
 217 |-------|-------------|
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add LLM context 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 171ms. 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 44ms. 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

✅ All pre-commit checks passed!
[main 51aa133] Add LLM context diagram to compaction docs
 1 file changed, 11 insertions(+)
To https://github.com/badlogic/pi-mono.git
   ea3ab71..51aa133  main -> main


Done. Added a third diagram showing what actually gets sent to the LLM: system prompt + summary message (from the compaction entry) + messages from `firstKeptEntryIndex` onwards.


~20k tokens  that's user configurable via settings iirc


cd /Users/badlogic/workspaces/pi-mono && grep -n "keepRecentTokens" packages/coding-agent/src/core/settings-manager.ts


8:	keepRecentTokens?: number; // default: 20000
247:		return this.settings.compaction?.keepRecentTokens ?? 20000;
250:	getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } {
254:			keepRecentTokens: this.getCompactionKeepRecentTokens(),


Yes, it's configurable. Let me fix the docs:

edit

When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:

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


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

     ...
 183 The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:
 184
 185 **How default compaction works:**
 186
-187 When context exceeds the threshold, pi finds a "cut point" that keeps ~20k tokens of recent turns:
+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 Session entries (before compaction):
 191
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Clarify keepRecentTokens is configurable" && 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 40ms. 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 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 699702e] Clarify keepRecentTokens is configurable
 1 file changed, 1 insertion(+), 1 deletion(-)
To https://github.com/badlogic/pi-mono.git
   51aa133..699702e  main -> main


Done.


what about split turns?


Good point. The diagrams don't show the `isSplitTurn` case. Let me check how that works:


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

[301 more lines in file. Use offset=231 to continue]