import type {
BoundaryResult,
ExtensionAPI,
ExtensionContext,
TurnEndEvent,
} from "@earendil-works/pi-coding-agent";
import {
budgetFor,
buildGuidance,
CHECKPOINT_STEPS,
freshPayloadChars,
type Budget,
} from "./budget.ts";
import {
isRecord,
isReminderType,
REMINDER_TYPE,
type CompactionPolicy,
type PolicyContext,
type ProjectedEntry,
type ReminderFingerprint,
} from "./contracts.ts";
import { registerContextTools } from "./context-tools.ts";
import { safeJsonStringify } from "./message-text.ts";
import { automaticRecovery } from "./retention.ts";
import {
createReminderLookup,
hasReminder,
reminderMatches,
type ReminderLookup,
} from "./reminders.ts";
export type SnapshotReader = (
ctx: ExtensionContext,
livePolicy?: CompactionPolicy,
) => PolicyContext;
const REMINDER_BUFFER_TOKENS = 32_000;
function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
pi.on("before_agent_start", (event) => {
promptOptions = event.systemPromptOptions;
});
pi.on("agent_settled", () => {
// Prompt options belong to one run, never to later idle context evaluation.
promptOptions = undefined;
});
pi.on("context_with_system", (event, ctx) => {
const guidance = buildGuidance(policy(ctx));
const system =
event.messages.findLast(
(message) =>
message.role === "system" && typeof message.sections?.posthorse === "string",
) ?? event.messages.find((message) => message.role === "system");
if (system === undefined || system.role !== "system") {
return;
}
if (promptOptions?.forceSystemPrompt !== undefined) {
// ponytail: Pi applies forced prompts after this hook; keep request-local guidance
// until the host projects forced text before context_with_system.
event.messages.splice(1, 0, {
role: "custom",
customType: "posthorse-guidance",
content: guidance,
display: false,
timestamp: 0,
});
} else {
system.sections = {
...system.sections,
posthorse: `\n${guidance}\n`,
};
}
});
}
function explicitReset(
event: TurnEndEvent,
ctx: ExtensionContext,
summary: string,
limit: () => number,
): BoundaryResult | undefined {
if (event.outcome === "aborted" || ctx.signal?.aborted === true) {
return undefined;
}
if (summary.length > limit()) {
return {
entries: [
...event.entries,
{
type: "custom_message",
customType: "posthorse-reset-deferred",
display: true,
content:
"Posthorse reset deferred: queued messages leave too little room for the requested handoff. The current context is unchanged; process those messages, then request a shorter handoff.",
},
],
};
}
return {
entries: [
...event.entries,
{
type: "compaction",
summary,
firstKeptEntryId: null,
details: { posthorse: 1, reason: "explicit" },
},
],
continue: true,
};
}
function endsRun(
event: TurnEndEvent,
ctx: { readonly hasPendingMessages?: () => boolean },
): boolean {
return (
event.message.role === "assistant" &&
!event.message.content.some((part) => part.type === "toolCall") &&
!event.continue &&
event.context.pendingMessages.length === 0 &&
ctx.hasPendingMessages?.() !== true
);
}
function reminderBudget(
host: PolicyContext,
): { readonly budget: Budget; readonly tokens: number } | undefined {
const usage = host.getContextUsage();
if (usage === undefined || usage.tokens === null || usage.contextWindow <= 0) {
return undefined;
}
const budget = budgetFor(host, usage.contextWindow);
if (
budget === undefined ||
!budget.enabled ||
!budget.supported ||
usage.tokens >= budget.rolloverAt
) {
return undefined;
}
const buffer = Math.min(REMINDER_BUFFER_TOKENS, Math.floor(budget.usable * 0.1));
return usage.tokens < budget.rolloverAt - buffer ? undefined : { budget, tokens: usage.tokens };
}
function sendReminder(
pi: ExtensionAPI,
ctx: ExtensionContext,
available: {
readonly budget: Budget;
readonly tokens: number;
readonly pendingMessages: readonly {
readonly role: string;
readonly customType?: string;
readonly details?: unknown;
}[];
},
reminders: ReminderLookup,
): void {
const { budget, tokens } = available;
const state = reminders.read(ctx);
const fingerprint: ReminderFingerprint = {
windowId: state.windowId,
contextWindow: budget.contextWindow,
reserveTokens: budget.reserveTokens,
};
if (
hasReminder(state.reminders, fingerprint) ||
available.pendingMessages.some(
(message) =>
message.role === "custom" &&
isReminderType(message.customType) &&
reminderMatches(message.details, fingerprint),
)
) {
return;
}
pi.sendMessage(
{
customType: REMINDER_TYPE,
content: `[posthorse] Notes checkpoint: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Please ${CHECKPOINT_STEPS}.`,
display: true,
details: fingerprint,
},
{ deliverAs: "steer" },
);
}
function registerReminderFilter(
pi: ExtensionAPI,
policy: SnapshotReader,
reminders: ReminderLookup,
): void {
pi.on("context", (event, ctx) => {
if (
!event.messages.some(
(message) => message.role === "custom" && isReminderType(message.customType),
)
) {
return;
}
const marker = event.messages.find(
(message) => message.role === "custom" && message.customType === "context-window",
);
let windowId: unknown;
if (marker?.role === "custom") {
windowId = isRecord(marker.details) ? marker.details.windowId : undefined;
} else {
windowId = reminders.read(ctx).windowId;
}
if (typeof windowId !== "string") {
return;
}
const host = policy(ctx);
const budget = budgetFor(host);
const fingerprint: ReminderFingerprint = {
windowId,
contextWindow: budget?.contextWindow,
reserveTokens: budget?.reserveTokens,
};
const enabled = host.getCompactionSettings().enabled;
const stale = (message: {
readonly role: string;
readonly customType?: string;
readonly details?: unknown;
}) =>
message.role === "custom" &&
isReminderType(message.customType) &&
(!enabled || !reminderMatches(message.details, fingerprint));
if (event.messages.some(stale)) {
return { messages: event.messages.filter((message) => !stale(message)) };
}
return;
});
}
function automaticDraft(
pi: ExtensionAPI,
ctx: ExtensionContext,
recovery: {
readonly handoff: string;
readonly tokensBefore: number;
readonly reason: string;
readonly firstKeptEntryId?: string;
},
) {
let firstKeptEntryId = recovery.firstKeptEntryId;
if (firstKeptEntryId === undefined) {
// Public compaction requires an entry id even when no prior conversation safely fits.
pi.appendEntry("posthorse-boundary", {});
firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
}
if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
throw new Error("Pi did not persist the context boundary.");
}
return {
compaction: {
summary: recovery.handoff,
firstKeptEntryId,
tokensBefore: recovery.tokensBefore,
details: { posthorse: 1, reason: recovery.reason },
},
};
}
function reportAutomaticFailure(ctx: ExtensionContext, error: unknown): void {
const detail = error instanceof Error ? error.message : safeJsonStringify(error);
const message = `Posthorse automatic rollover cancelled: ${detail}`;
if (ctx.hasUI) {
ctx.ui.notify(message, "error");
} else {
console.error(message);
}
}
function freshWindowWithoutInput(projected: readonly ProjectedEntry[]): boolean {
const boundary = projected.findIndex(
(entry) => entry.sourceEntry.type === "compaction" && entry.messages.length > 0,
);
if (boundary < 0) {
return false;
}
const turnStarts = new Set([
"user",
"custom",
"bashExecution",
"branchSummary",
"compactionSummary",
]);
return !projected
.slice(boundary + 1)
.some(
(entry) =>
entry.sourceEntry.type !== "compaction" &&
entry.messages.some((message) => turnStarts.has(message.role ?? "")),
);
}
function reachedThreshold(host: PolicyContext): boolean {
const usage = host.getContextUsage();
const budget = budgetFor(host);
return (
budget?.supported === true &&
budget.enabled &&
usage !== undefined &&
usage.tokens !== null &&
usage.tokens >= budget.rolloverAt
);
}
function fallbackTurnEligible(event: TurnEndEvent, ctx: ExtensionContext): boolean {
return (
event.outcome === "completed" &&
!(event.message.role === "assistant" && event.message.stopReason === "length") &&
ctx.signal?.aborted !== true &&
!event.entries.some((entry) => entry.type === "compaction" || entry.type === "context_edit")
);
}
function thresholdFallback(
pi: ExtensionAPI,
event: TurnEndEvent,
ctx: ExtensionContext,
capacity: { readonly host: PolicyContext; readonly toolTokens: () => number },
): BoundaryResult | undefined {
if (!fallbackTurnEligible(event, ctx)) {
return undefined;
}
const { host, toolTokens } = capacity;
if (!reachedThreshold(host)) {
return undefined;
}
if (!freshWindowWithoutInput(event.context.contextEntries)) {
return undefined;
}
const projected = event.context.contextEntries;
const ownerQuestionRegistered = pi
.getAllTools()
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
const recovery = automaticRecovery(
ctx.sessionManager.getBranch(),
projected,
{ host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
ownerQuestionRegistered,
);
if (recovery === undefined) {
return undefined;
}
// ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
// upgrade to a host post-boundary hook if recovery must include later handler edits.
return {
entries: [
...event.entries,
{
type: "compaction",
summary: recovery.handoff,
firstKeptEntryId: recovery.firstKeptEntryId ?? null,
details: { posthorse: 1, reason: "threshold" },
},
],
continue: event.continue,
};
}
function registerAutomaticRollover(
pi: ExtensionAPI,
policy: SnapshotReader,
activeToolTokens: () => number,
): void {
pi.on("session_before_compact", (event, ctx) => {
if (event.reason === "manual") {
return;
}
const cancelled = () => event.signal.aborted || ctx.signal?.aborted === true;
if (cancelled()) {
return { cancel: true };
}
try {
const host = policy(ctx, event.preparation.settings);
if (budgetFor(host)?.supported !== true) {
return;
}
const ownerQuestionRegistered = pi
.getAllTools()
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
const recovery = automaticRecovery(
event.branchEntries,
ctx.sessionManager.buildSessionProjection().entries,
{
host,
toolTokens: activeToolTokens(),
firstKeptEntryId: event.preparation.firstKeptEntryId,
},
ownerQuestionRegistered,
);
if (recovery === undefined) {
throw new Error(
"Too little fresh context capacity for an automatic recovery record.",
);
}
if (cancelled()) {
return { cancel: true };
}
return automaticDraft(pi, ctx, {
...recovery,
tokensBefore: event.preparation.tokensBefore,
reason: event.reason,
});
} catch (error) {
// Hook errors are swallowed by Pi, which would run its summarizer; cancel instead.
if (!cancelled()) {
reportAutomaticFailure(ctx, error);
}
return { cancel: true };
}
});
}
/** Owns per-turn reset requests and leaf-certified reminder state. Pi owns persistence and scheduling. */
export function registerRollover(
pi: ExtensionAPI,
policy: SnapshotReader,
activeToolTokens: () => number,
resetPages: () => void,
): void {
const resetRequests = new Map();
const reminders = createReminderLookup();
const resetTurn = () => {
resetRequests.clear();
resetPages();
};
pi.on("turn_start", resetTurn);
pi.on("session_start", () => {
resetTurn();
reminders.reset();
});
pi.on("session_tree", () => {
reminders.reset();
});
registerGuidance(pi, policy);
registerReminderFilter(pi, policy, reminders);
registerAutomaticRollover(pi, policy, activeToolTokens);
pi.on("turn_end", (event, ctx) => {
const requested = event.toolResults.find(
(result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId),
);
const summary =
requested === undefined ? undefined : resetRequests.get(requested.toolCallId);
resetRequests.clear();
const host = policy(ctx);
if (
event.message.role === "assistant" &&
(event.message.stopReason === "error" || event.message.stopReason === "aborted")
) {
return;
}
if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
return explicitReset(event, ctx, summary, () =>
freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages),
);
}
const fallback = thresholdFallback(pi, event, ctx, {
host,
toolTokens: activeToolTokens,
});
if (fallback !== undefined) {
return fallback;
}
if (endsRun(event, ctx)) {
return;
}
const available = reminderBudget(host);
if (available !== undefined) {
sendReminder(
pi,
ctx,
{ ...available, pendingMessages: event.context.pendingMessages },
reminders,
);
}
return;
});
registerContextTools(pi, policy, activeToolTokens, (id, handoff) => {
resetRequests.set(id, handoff);
});
}