import { randomUUID } from "node:crypto"; import type { CompactionEntry, SessionEntry } from "@earendil-works/pi-coding-agent"; import { CONTINUATION_MESSAGE_TYPE, EPOCH_DETAILS_VERSION, EPOCH_KIND, IMPOSSIBLE_FIRST_KEPT_ENTRY_ID, type EpochTrigger, } from "./constants.ts"; /** details payload persisted on package-owned epoch-boundary compaction entries. */ export interface EpochBoundaryDetails { kind: typeof EPOCH_KIND; version: number; epochId: string; previousEpochId: string | null; epochIndex: number; trigger: EpochTrigger; createdAt: string; } export function isEpochBoundary(entry: SessionEntry | undefined): entry is CompactionEntry { return ( entry !== undefined && entry.type === "compaction" && typeof (entry.details as EpochBoundaryDetails | undefined)?.kind === "string" && (entry.details as EpochBoundaryDetails).kind === EPOCH_KIND ); } /** * All package-owned epoch boundaries on a branch, oldest first. * The branch is the authoritative reconstruction source: /tree, fork, resume * and reload all project the epoch sequence from the current branch path. */ export function getEpochBoundaries(branch: SessionEntry[]): CompactionEntry[] { return branch.filter(isEpochBoundary); } export interface EpochState { /** 0 for a fresh session, 1 after the first rollover, ... */ epochIndex: number; epochId: string | null; previousEpochId: string | null; /** The package-owned boundary compaction entry of the current epoch, if any. */ boundary: CompactionEntry | null; boundaries: CompactionEntry[]; } export function getEpochState(branch: SessionEntry[]): EpochState { const boundaries = getEpochBoundaries(branch); const boundary = boundaries[boundaries.length - 1] ?? null; const details = boundary ? (boundary.details as EpochBoundaryDetails) : null; return { epochIndex: boundaries.length, epochId: details?.epochId ?? null, previousEpochId: details?.previousEpochId ?? null, boundary, boundaries, }; } export function newEpochId(): string { return `e-${randomUUID().replace(/-/g, "").slice(0, 12)}`; } /** Extract the visible plain text from a message content field (no thinking blocks). */ export function visibleText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if ( block && typeof block === "object" && (block as { type?: string }).type === "text" && typeof (block as { text?: unknown }).text === "string" ) { parts.push((block as { text: string }).text); } } return parts.join("\n"); } function truncate(s: string, max: number): string { if (s.length <= max) return s; return `${s.slice(0, max)}…[truncated ${s.length - max} chars]`; } /** * Find the verbatim text of the latest real user message on the branch. * Used as the continuation anchor: never summarized or rewritten. */ export function findLatestUserRequest(branch: SessionEntry[]): string | null { for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; if (entry.type !== "message") continue; const message = entry.message; if (message.role !== "user") continue; const text = visibleText(message.content).trim(); if (text) return text; } return null; } /** * Build the deterministic epoch boundary marker that becomes the compaction * summary. Contains no LLM-generated content and no old-transcript summary. * Pi delivers it to the model as a compactionSummary user message. */ export function buildEpochMarker(options: { epochId: string; previousEpochId: string | null; epochIndex: number; trigger: EpochTrigger; sessionId: string | null; notesKeys: string[]; latestUserRequest: string | null; anchorMaxChars: number; }): string { const { epochId, previousEpochId, epochIndex, trigger, sessionId, notesKeys, latestUserRequest, anchorMaxChars, } = options; const lines: string[] = []; lines.push( ``, ); lines.push( "Context epoch boundary - this is NOT a content summary. The same Pi session continues here in a fresh working context. Messages from previous epochs are retired from the active context but remain intact in the durable session transcript.", ); lines.push("Prior conversation remains available through the context_history tool (search / read by entry ref)."); lines.push( `Available context notes: ${notesKeys.length > 0 ? notesKeys.join(", ") : "(none)"} (use context_notes to read or update them)`, ); if (latestUserRequest) { lines.push("Latest user request from the previous epoch:"); lines.push(""); lines.push(truncate(latestUserRequest, anchorMaxChars)); lines.push(""); } lines.push("Continue the active task. Re-ground from the workspace, notes, and history as needed before acting."); lines.push(""); return lines.join("\n"); } /** The one-line nudge appended after a model- or user-initiated rollover. */ export function buildContinuationText(details: EpochBoundaryDetails): string { return ( `New context epoch ${details.epochIndex} (${details.epochId}) started; previous epoch ${ details.previousEpochId ?? "(session start)" } was retired from active context. Continue the active task: re-ground from the workspace, context notes, and context_history as needed before acting.` ); } /** Build the package-owned compaction payload handed to Pi via session_before_compact. */ export function buildEpochCompaction(options: { branch: SessionEntry[]; tokensBefore: number; trigger: EpochTrigger; sessionId: string | null; notesKeys: string[]; anchorMaxChars: number; now?: Date; }): { summary: string; firstKeptEntryId: string; tokensBefore: number; details: EpochBoundaryDetails; } { const { branch, tokensBefore, trigger, sessionId, notesKeys, anchorMaxChars, now } = options; const state = getEpochState(branch); const epochId = newEpochId(); const details: EpochBoundaryDetails = { kind: EPOCH_KIND, version: EPOCH_DETAILS_VERSION, epochId, previousEpochId: state.epochId, epochIndex: state.epochIndex + 1, trigger, createdAt: (now ?? new Date()).toISOString(), }; const summary = buildEpochMarker({ epochId, previousEpochId: details.previousEpochId, epochIndex: details.epochIndex, trigger, sessionId, notesKeys, latestUserRequest: findLatestUserRequest(branch), anchorMaxChars, }); return { summary, firstKeptEntryId: IMPOSSIBLE_FIRST_KEPT_ENTRY_ID, tokensBefore, details, }; } export { CONTINUATION_MESSAGE_TYPE };