refactor(coding-agent): fix compaction for branched sessions, consolidate hook context types

Compaction API:
- prepareCompaction() now takes (pathEntries, settings) only
- CompactionPreparation restructured: removed cutPoint/messagesToKeep/boundaryStart, added turnPrefixMessages/isSplitTurn/previousSummary/fileOps/settings
- compact() now takes (preparation, model, apiKey, customInstructions?, signal?)
- Fixed token overflow by using getPath() instead of getEntries()

Hook types:
- HookEventContext renamed to HookContext
- HookCommandContext removed, RegisteredCommand.handler takes (args, ctx)
- HookContext now includes model field
- SessionBeforeCompactEvent: removed previousCompactions/model, added branchEntries
- SessionBeforeTreeEvent: removed model (use ctx.model)
- HookRunner.initialize() added for modes to set up callbacks
This commit is contained in:
Mario Zechner 2025-12-31 02:24:24 +01:00
parent b4ce93c577
commit ddda8b124c
12 changed files with 177 additions and 201 deletions

View file

@ -20,13 +20,15 @@
- `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry`
- `appendCompaction()` now accepts optional `details` parameter
- `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId`
- `prepareCompaction()` now returns `firstKeptEntryId` in its result
- `prepareCompaction(pathEntries, settings)` now takes path entries (from `getPath()`) and settings only
- `CompactionPreparation` restructured: removed `cutPoint`, `messagesToKeep`, `boundaryStart`; added `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `settings`
- `compact(preparation, model, apiKey, customInstructions?, signal?)` now takes preparation and execution context separately
- **Hook types**:
- `SessionEventBase` no longer has `sessionManager`/`modelRegistry` - access them via `HookEventContext` instead
- `HookEventContext` now has `sessionManager` and `modelRegistry` (moved from events)
- `HookEventContext` no longer has `exec()` - use `pi.exec()` instead
- `HookCommandContext` no longer has `exec()` - use `pi.exec()` instead
- `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first)
- `HookEventContext` renamed to `HookContext`
- `HookContext` now has `sessionManager`, `modelRegistry`, and `model` (current model, may be undefined)
- `HookCommandContext` removed - `RegisteredCommand.handler` now takes `(args: string, ctx: HookContext)`
- `before_compact` event: removed `previousCompactions` and `model`, added `branchEntries: SessionEntry[]` (hooks extract what they need)
- `before_tree` event: removed `model` (use `ctx.model` instead)
- `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile`
- Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`)
- Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction)
@ -38,7 +40,7 @@
- New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`)
- `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null`
- Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookCommandContext`
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
@ -58,7 +60,7 @@
- `FileOperations`, `collectEntriesForBranchSummary`, `prepareBranchEntries`, `generateBranchSummary` - branch summarization utilities
- `CompactionPreparation`, `CompactionDetails` - compaction preparation types
- `ReadonlySessionManager` - read-only session manager interface for hooks
- `HookMessage`, `HookCommandContext`, `HookMessageRenderOptions` - hook types
- `HookMessage`, `HookContext`, `HookMessageRenderOptions` - hook types
- `isHookMessage`, `createHookMessage` - hook message utilities
### Added
@ -93,6 +95,7 @@
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings
- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()`
## [0.30.2] - 2025-12-26

View file

@ -3,7 +3,7 @@
*
* Replaces the default compaction behavior with a full summary of the entire context.
* Instead of keeping the last 20k tokens of conversation turns, this hook:
* 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary)
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
* 2. Discards all old turns completely, keeping only the summary
*
* This example also demonstrates using a different model (Gemini Flash) for summarization,
@ -14,6 +14,7 @@
*/
import { complete, getModel } from "@mariozechner/pi-ai";
import type { CompactionEntry } from "@mariozechner/pi-coding-agent";
import { convertToLlm } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
@ -21,11 +22,8 @@ export default function (pi: HookAPI) {
pi.on("session_before_compact", async (event, ctx) => {
ctx.ui.notify("Custom compaction hook triggered", "info");
const { preparation, previousCompactions, signal } = event;
const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation;
// Get previous summary from most recent compaction (if any)
const previousSummary = previousCompactions[0]?.summary;
const { preparation, branchEntries, signal } = event;
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
const model = getModel("google", "gemini-2.5-flash");
@ -42,7 +40,7 @@ export default function (pi: HookAPI) {
}
// Combine all messages for full summary
const allMessages = [...messagesToSummarize, ...messagesToKeep];
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
ctx.ui.notify(
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,

View file

@ -5,13 +5,9 @@
* Useful to ensure work is committed before switching context.
*/
import type { HookAPI, HookEventContext } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
async function checkDirtyRepo(
pi: HookAPI,
ctx: HookEventContext,
action: string,
): Promise<{ cancel: boolean } | undefined> {
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
// Check for uncommitted changes
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);

View file

@ -310,7 +310,7 @@ export default function (pi: HookAPI) {
pi.registerCommand("snake", {
description: "Play Snake!",
handler: async (ctx) => {
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Snake requires interactive mode", "error");
return;

View file

@ -30,7 +30,7 @@ import {
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html.js";
import type {
HookCommandContext,
HookContext,
HookRunner,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
@ -552,17 +552,17 @@ export class AgentSession {
// Build command context
const cwd = process.cwd();
const ctx: HookCommandContext = {
args,
const ctx: HookContext = {
ui: uiContext,
hasUI: this._hookRunner.getHasUI(),
cwd,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
model: this.model,
};
try {
await command.handler(ctx);
await command.handler(args, ctx);
return true;
} catch (err) {
// Emit error via hook runner
@ -895,13 +895,13 @@ export class AgentSession {
throw new Error(`No API key for ${this.model.provider}`);
}
const entries = this.sessionManager.getEntries();
const pathEntries = this.sessionManager.getPath();
const settings = this.settingsManager.getCompactionSettings();
const preparation = prepareCompaction(entries, settings);
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
// Check why we can't compact
const lastEntry = entries[entries.length - 1];
const lastEntry = pathEntries[pathEntries.length - 1];
if (lastEntry?.type === "compaction") {
throw new Error("Already compacted");
}
@ -912,15 +912,11 @@ export class AgentSession {
let fromHook = false;
if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const result = (await this._hookRunner.emit({
type: "session_before_compact",
preparation,
previousCompactions,
branchEntries: pathEntries,
customInstructions,
model: this.model,
signal: this._compactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@ -948,12 +944,11 @@ export class AgentSession {
} else {
// Generate compaction result
const result = await compact(
entries,
preparation,
this.model,
settings,
apiKey,
this._compactionAbortController.signal,
customInstructions,
this._compactionAbortController.signal,
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@ -1073,9 +1068,9 @@ export class AgentSession {
return;
}
const entries = this.sessionManager.getEntries();
const pathEntries = this.sessionManager.getPath();
const preparation = prepareCompaction(entries, settings);
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
@ -1085,15 +1080,11 @@ export class AgentSession {
let fromHook = false;
if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const hookResult = (await this._hookRunner.emit({
type: "session_before_compact",
preparation,
previousCompactions,
branchEntries: pathEntries,
customInstructions: undefined,
model: this.model,
signal: this._autoCompactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@ -1122,10 +1113,10 @@ export class AgentSession {
} else {
// Generate compaction result
const compactResult = await compact(
entries,
preparation,
this.model,
settings,
apiKey,
undefined,
this._autoCompactionAbortController.signal,
);
summary = compactResult.summary;
@ -1628,7 +1619,6 @@ export class AgentSession {
const result = (await this._hookRunner.emit({
type: "session_before_tree",
preparation,
model: this.model!, // Checked above if summarize is true
signal: this._branchSummaryAbortController.signal,
})) as SessionBeforeTreeResult | undefined;

View file

@ -519,42 +519,48 @@ export async function generateSummary(
// ============================================================================
export interface CompactionPreparation {
cutPoint: CutPointResult;
/** UUID of first entry to keep */
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AgentMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
turnPrefixMessages: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
isSplitTurn: boolean;
tokensBefore: number;
boundaryStart: number;
/** Summary from previous compaction, for iterative update */
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
settings: CompactionSettings;
}
export function prepareCompaction(
entries: SessionEntry[],
pathEntries: SessionEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
}
let prevCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = entries.length;
const boundaryEnd = pathEntries.length;
const lastUsage = getLastAssistantUsage(entries);
const lastUsage = getLastAssistantUsage(pathEntries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Get UUID of first kept entry
const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return undefined; // Session needs migration
}
@ -565,18 +571,46 @@ export function prepareCompaction(
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntry(entries[i]);
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) messagesToSummarize.push(msg);
}
// Messages to keep (recent turns, kept after summary)
const messagesToKeep: AgentMessage[] = [];
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
const msg = getMessageFromEntry(entries[i]);
if (msg) messagesToKeep.push(msg);
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) turnPrefixMessages.push(msg);
}
}
return { cutPoint, firstKeptEntryId, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
// Get previous summary for iterative update
let previousSummary: string | undefined;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
// Also extract file ops from turn prefix if splitting
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
};
}
// ============================================================================
@ -599,87 +633,39 @@ Summarize the prefix to provide context for the retained suffix:
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Calculate compaction and generate summary.
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param entries - All session entries (must have uuid fields for v2)
* @param model - Model to use for summarization
* @param settings - Compaction settings
* @param apiKey - API key for LLM
* @param signal - Optional abort signal
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
export async function compact(
entries: SessionEntry[],
preparation: CompactionPreparation,
model: Model<any>,
settings: CompactionSettings,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
signal?: AbortSignal,
): Promise<CompactionResult> {
// 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: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntry(entries[i]);
if (msg) historyMessages.push(msg);
}
// Get previous summary for iterative update (if not from hook)
let previousSummary: string | undefined;
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(historyMessages, entries, prevCompactionIndex);
// Extract messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutResult.isSplitTurn) {
for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntry(entries[i]);
if (msg) turnPrefixMessages.push(msg);
}
// Also extract file ops from turn prefix
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
const {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
} = preparation;
// Generate summaries (can be parallel if both needed) and merge into one
let summary: string;
if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
historyMessages.length > 0
messagesToSummarize.length > 0
? generateSummary(
historyMessages,
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
@ -695,7 +681,7 @@ export async function compact(
} else {
// Just generate history summary
summary = await generateSummary(
historyMessages,
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
@ -709,9 +695,6 @@ export async function compact(
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
// Get UUID of first kept entry
const firstKeptEntry = entries[cutResult.firstKeptEntryIndex];
const firstKeptEntryId = firstKeptEntry.id;
if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
}

View file

@ -3,6 +3,7 @@
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
@ -11,9 +12,9 @@ import type {
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
HookContext,
HookError,
HookEvent,
HookEventContext,
HookMessageRenderer,
HookUIContext,
RegisteredCommand,
@ -72,6 +73,7 @@ export class HookRunner {
private modelRegistry: ModelRegistry;
private timeout: number;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
constructor(
hooks: LoadedHook[],
@ -89,6 +91,30 @@ export class HookRunner {
this.timeout = timeout;
}
/**
* Initialize HookRunner with all required context.
* Modes call this once the agent session is fully set up.
*/
initialize(options: {
/** Function to get the current model */
getModel: () => Model<any> | undefined;
/** Handler for hooks to send messages */
sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler;
/** UI context for interactive prompts */
uiContext?: HookUIContext;
/** Whether UI is available */
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
this.setSendMessageHandler(options.sendMessageHandler);
this.setAppendEntryHandler(options.appendEntryHandler);
if (options.uiContext) {
this.setUIContext(options.uiContext, options.hasUI ?? false);
}
}
/**
* Set the UI context for hooks.
* Call this when the mode initializes and UI is available.
@ -217,13 +243,14 @@ export class HookRunner {
/**
* Create the event context for handlers.
*/
private createContext(): HookEventContext {
private createContext(): HookContext {
return {
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
};
}

View file

@ -88,9 +88,9 @@ export interface HookUIContext {
}
/**
* Context passed to hook event handlers.
* Context passed to hook event and command handlers.
*/
export interface HookEventContext {
export interface HookContext {
/** UI methods for user interaction */
ui: HookUIContext;
/** Whether UI is available (false in print mode) */
@ -101,6 +101,8 @@ export interface HookEventContext {
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
}
// ============================================================================
@ -152,14 +154,12 @@ export interface SessionBranchEvent {
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with cut point, messages to summarize/keep, etc. */
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Previous compaction entries, newest first. Use for iterative summarization. */
previousCompactions: CompactionEntry[];
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Current model */
model: Model<any>;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
@ -196,9 +196,7 @@ export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Model to use for summarization (conversation model) */
model: Model<any>;
/** Abort signal - honors Escape during summarization */
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
@ -529,7 +527,7 @@ export interface SessionBeforeTreeResult {
* Handlers can return R, undefined, or void (bare return statements).
*/
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
export type HookHandler<E, R = undefined> = (event: E, ctx: HookEventContext) => Promise<R | void> | R | void;
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
@ -546,24 +544,6 @@ export type HookMessageRenderer<T = unknown> = (
theme: Theme,
) => Component | undefined;
/**
* Context passed to hook command handlers.
*/
export interface HookCommandContext {
/** Arguments after the command name */
args: string;
/** UI methods for user interaction */
ui: HookUIContext;
/** Whether UI is available (false in print mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
sessionManager: ReadonlySessionManager;
/** Model registry for API keys */
modelRegistry: ModelRegistry;
}
/**
* Command registration options.
*/
@ -571,7 +551,7 @@ export interface RegisteredCommand {
name: string;
description?: string;
handler: (ctx: HookCommandContext) => Promise<void>;
handler: (args: string, ctx: HookContext) => Promise<void>;
}
/**

View file

@ -27,9 +27,9 @@ export {
} from "./custom-tools/index.js";
export {
type HookAPI,
type HookContext,
type HookError,
type HookEvent,
type HookEventContext,
type HookFactory,
HookRunner,
type HookUIContext,

View file

@ -11,23 +11,27 @@ describe("Documentation example", () => {
const exampleHook = (pi: HookAPI) => {
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
// All these should be accessible on the event
const { preparation, previousCompactions, model } = event;
// sessionManager and modelRegistry come from ctx, not event
const { sessionManager, modelRegistry } = ctx;
const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation;
// Get previous summary from most recent compaction
const _previousSummary = previousCompactions[0]?.summary;
const { preparation, branchEntries, signal } = event;
// sessionManager, modelRegistry, and model come from ctx
const { sessionManager, modelRegistry, model } = ctx;
const {
messagesToSummarize,
turnPrefixMessages,
tokensBefore,
firstKeptEntryId,
isSplitTurn,
previousSummary,
} = preparation;
// Verify types
expect(Array.isArray(messagesToSummarize)).toBe(true);
expect(Array.isArray(messagesToKeep)).toBe(true);
expect(typeof cutPoint.firstKeptEntryIndex).toBe("number");
expect(Array.isArray(turnPrefixMessages)).toBe(true);
expect(typeof isSplitTurn).toBe("boolean");
expect(typeof tokensBefore).toBe("number");
expect(model).toBeDefined();
expect(typeof sessionManager.getEntries).toBe("function");
expect(typeof modelRegistry.getApiKey).toBe("function");
expect(typeof firstKeptEntryId).toBe("string");
expect(Array.isArray(branchEntries)).toBe(true);
const summary = messagesToSummarize
.filter((m) => m.role === "user")

View file

@ -143,12 +143,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const beforeEvent = beforeCompactEvents[0];
expect(beforeEvent.preparation).toBeDefined();
expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
expect(beforeEvent.preparation.messagesToKeep).toBeDefined();
expect(beforeEvent.preparation.turnPrefixMessages).toBeDefined();
expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
expect(beforeEvent.model).toBeDefined();
// sessionManager and modelRegistry are now on ctx, not event
expect(typeof beforeEvent.preparation.isSplitTurn).toBe("boolean");
expect(beforeEvent.branchEntries).toBeDefined();
// sessionManager, modelRegistry, and model are now on ctx, not event
const afterEvent = compactEvents[0];
expect(afterEvent.compactionEntry).toBeDefined();
@ -363,19 +363,17 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(capturedBeforeEvent).not.toBeNull();
const event = capturedBeforeEvent!;
expect(event.preparation.cutPoint).toHaveProperty("firstKeptEntryIndex");
expect(event.preparation.cutPoint).toHaveProperty("isSplitTurn");
expect(event.preparation.cutPoint).toHaveProperty("turnStartIndex");
expect(typeof event.preparation.isSplitTurn).toBe("boolean");
expect(event.preparation.firstKeptEntryId).toBeDefined();
expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true);
expect(Array.isArray(event.preparation.messagesToKeep)).toBe(true);
expect(Array.isArray(event.preparation.turnPrefixMessages)).toBe(true);
expect(typeof event.preparation.tokensBefore).toBe("number");
expect(event.model).toHaveProperty("provider");
expect(event.model).toHaveProperty("id");
expect(Array.isArray(event.branchEntries)).toBe(true);
// sessionManager and modelRegistry are now on ctx, not event
// sessionManager, modelRegistry, and model are now on ctx, not event
// Verify they're accessible via session
expect(typeof session.sessionManager.getEntries).toBe("function");
expect(typeof session.modelRegistry.getApiKey).toBe("function");

View file

@ -11,6 +11,7 @@ import {
DEFAULT_COMPACTION_SETTINGS,
findCutPoint,
getLastAssistantUsage,
prepareCompaction,
shouldCompact,
} from "../src/core/compaction/index.js";
import {
@ -398,12 +399,10 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
const entries = loadLargeSessionEntries();
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const compactionResult = await compact(
entries,
model,
DEFAULT_COMPACTION_SETTINGS,
process.env.ANTHROPIC_OAUTH_TOKEN!,
);
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined();
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
expect(compactionResult.summary.length).toBeGreaterThan(100);
expect(compactionResult.firstKeptEntryId).toBeTruthy();
@ -421,12 +420,10 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
const loaded = buildSessionContext(entries);
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const compactionResult = await compact(
entries,
model,
DEFAULT_COMPACTION_SETTINGS,
process.env.ANTHROPIC_OAUTH_TOKEN!,
);
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined();
const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!);
// Simulate appending compaction to entries by creating a proper entry
const lastEntry = entries[entries.length - 1];