Example

Take over compaction summarization

Keep Pi's cut point, supply the compaction summary, or cancel a manual /compact — and see what survives in the next request.

Compaction changes what the next model request is built from, never what the session file holds. On Pi 1.0.4 an extension can supply the summary through session_before_compact while Pi still chooses the cut point; every entry stays in the file, and navigating back before the compaction sends the originals again.

Supply the summary, keep the cut point

An excerpt of custom-summary.ts: it serialises the messages Pi is about to replace, returns them as the summary, and echoes back Pi’s own firstKeptEntryId.

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

		// preparation.messagesToSummarize : the messages that are about to be replaced
		// preparation.previousSummary     : the last compaction's summary, if any
		// preparation.firstKeptEntryId    : where the kept messages start
		const serialized = serializeConversation(convertToLlm(preparation.messagesToSummarize));

		return {
			compaction: {
				summary: `Custom summary of ${preparation.messagesToSummarize.length} message(s):\n${serialized.slice(0, 500)}`,
				firstKeptEntryId: preparation.firstKeptEntryId,
				tokensBefore: preparation.tokensBefore,
			},
		};
	});

Cancel instead

The other documented outcome, an excerpt of cancel-compaction.ts — refuse to compact at all. On the tested session this stops only a manual /compact:

	pi.on("session_before_compact", async (event) => {
		return event.reason === "manual" ? { cancel: true } : undefined;
	});

Expected outcome

All rows below are observed on persisted sessions run with a scripted provider whose keepRecentTokens was lowered so a short conversation had something to summarise — the evidence is the mechanism around the model, not a real model:

What happened Asserted result
session.compact() with the summary handler compaction entry with the extension’s summary, firstKeptEntryId kept, fromHook: true
The prompt after it oldest question unsent, most recent kept, summary sent as a user-role message
Message count before/after unchanged — every message entry is still stored
Seven budgets, keepRecentTokens 50–2500 kept range never begins with a tool result; each result stays with its call
Request after compaction still carries a system message with bash declared
Manual /compact with the cancel handler rejected; the entry count is unchanged

Mechanism and limitations

Documented: the exported declarations define session_before_compact, the compaction return shape (summary, firstKeptEntryId, tokensBefore, fromHook), and the never-cut-at-tool-results rule. The trigger is Chapter 4’s reserve calculation (contextTokens > contextWindow - reserveTokens), shown in the Chapter 4 example on this site and not repeated here. Observed: compaction.test.ts drives the real handlers through an AgentSession and asserts the summary, cut point, sent messages and file count rows above; state.test.ts drives the shared history and asserts the tool-call/result pairing at seven budgets, the split of one long span, the intact system message, and that navigating before the compaction sends the originals again.

Two costs sit outside the frame. A single long user-message span becomes a split turn: messagesToSummarize can be empty while the early part is in turnPrefixMessages, so a handler reading only the first list summarises nothing — the shipped custom-compaction.ts joins both lists. And nothing here shows a real model choosing a summary: the extension builds it locally, and the serializeConversation path to a different model is the chapter’s proposal, not exercised in this example.

Full source and tests .

Understand this example

Copy this prompt into your AI tool. No code runs here.

Apply this example

Copy this prompt into your AI tool. No code runs here.