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