import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { convertToLlm } from "@earendil-works/pi-coding-agent"; import { writeFileSync } from "fs"; import { compile } from "../core/summarize"; import { loadSettings, type PiVccSettings } from "../core/settings"; import type { PiVccCompactionDetails } from "../details"; export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__"; export interface CompactionStats { summarized: number; kept: number; keptTokensEst: number; } let lastStats: CompactionStats | null = null; let lastCompactWasPiVcc = false; export const getLastCompactionStats = () => lastStats; const formatTokens = (n: number): string => { if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); }; const dbg = (settings: PiVccSettings, data: Record) => { if (!settings.debug) return; try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {} }; const previewContent = (content: unknown): string => { if (typeof content === "string") return content.slice(0, 300); if (Array.isArray(content)) { return content .map((c: any) => { if (c?.type === "text") return c.text ?? ""; if (c?.type === "toolCall") return `[toolCall:${c.name}]`; if (c?.type === "thinking") return `[thinking]`; if (c?.type === "image") return `[image:${c.mimeType}]`; return `[${c?.type ?? "unknown"}]`; }) .join("\n") .slice(0, 300); } return ""; }; interface EntryWithMessage { entry: { id: string; type: string }; message: { role: string; content: unknown }; } export type OwnCutCancelReason = | "no_live_messages" | "too_few_live_messages"; export type OwnCutResult = | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean } | { ok: false; reason: OwnCutCancelReason }; export function buildOwnCut(branchEntries: any[]): OwnCutResult { // Find the last compaction entry and its firstKeptEntryId let lastCompactionIdx = -1; let lastKeptId: string | undefined; for (let i = branchEntries.length - 1; i >= 0; i--) { if (branchEntries[i].type === "compaction") { lastCompactionIdx = i; lastKeptId = branchEntries[i].firstKeptEntryId; break; } } // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior // compact-all) OR set to an id that no longer exists in the branch. In both cases, // start collecting from right after the last compaction entry. const hasPriorCompaction = lastCompactionIdx >= 0; const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId); const orphanRecovery = hasPriorCompaction && !hasValidKeptId; // Collect live messages const liveMessages: EntryWithMessage[] = []; if (orphanRecovery) { for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) { const e = branchEntries[i]; if (e.type === "compaction") continue; if (e.type === "message" && e.message) { liveMessages.push({ entry: e, message: e.message }); } } } else { let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately for (const e of branchEntries) { if (!foundKept && e.id === lastKeptId) foundKept = true; if (!foundKept) continue; if (e.type === "compaction") continue; if (e.type === "message" && e.message) { liveMessages.push({ entry: e, message: e.message }); } } } if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" }; if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" }; // Summarize all messages, keep only the last user message as context let cutIdx = liveMessages.length - 1; while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") { cutIdx--; } if (cutIdx <= 0) { // Single user prompt scenario (or no user at all). // Compact EVERYTHING and keep no tail. This handles both: // - Single user prompt at index 0: compact all, fresh start after summary // - No user message at all (e.g., long assistant/tool chain): still compact // to recover from context overflow rather than cancelling and leaving // the session unrecoverable. // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery. return { ok: true, messages: liveMessages.map((e) => e.message), firstKeptEntryId: "", compactAll: true, }; } return { ok: true, messages: liveMessages.slice(0, cutIdx).map((e) => e.message), firstKeptEntryId: liveMessages[cutIdx].entry.id, compactAll: false, }; } const REASON_MESSAGES: Record = { no_live_messages: "pi-vcc: Nothing to compact (no live messages)", too_few_live_messages: "pi-vcc: Too few messages to compact", }; function collectLiveRolesForDiagnostics( branchEntries: any[], lastCompIdx: number, lastKeptId: string | undefined, ): string[] { const hasPriorCompaction = lastCompIdx >= 0; const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId); const diagOrphan = hasPriorCompaction && !hasValidKeptId; const liveRoles: string[] = []; if (diagOrphan) { for (let i = lastCompIdx + 1; i < branchEntries.length; i++) { const e = branchEntries[i]; if (e.type === "compaction") continue; if (e.type === "message" && e.message) liveRoles.push(e.message.role); } return liveRoles; } let foundKept = !lastKeptId; for (const e of branchEntries) { if (!foundKept && e.id === lastKeptId) foundKept = true; if (!foundKept || e.type === "compaction") continue; if (e.type === "message" && e.message) liveRoles.push(e.message.role); } return liveRoles; } function handleOwnCutCancellation(args: { ownCut: Extract; isPiVcc: boolean; settings: PiVccSettings; branchEntries: any[]; ctx: any; }): { cancel: true } { const { ownCut, isPiVcc, settings, branchEntries, ctx } = args; const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction"); const lastCompIdx = lastComp ? branchEntries.indexOf(lastComp) : -1; const lastKeptId: string | undefined = lastComp?.firstKeptEntryId; const liveRoles = collectLiveRolesForDiagnostics(branchEntries, lastCompIdx, lastKeptId); const userIndices = liveRoles.reduce((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []); dbg(settings, { cancelled: true, reason: ownCut.reason, isPiVcc, counts: { total: branchEntries.length, messages: branchEntries.filter((e: any) => e.type === "message").length, compactions: branchEntries.filter((e: any) => e.type === "compaction").length, entriesAfterLastCompaction: lastCompIdx >= 0 ? branchEntries.length - lastCompIdx - 1 : null, }, liveMessages: { count: liveRoles.length, userCount: userIndices.length, firstUserIdx: userIndices[0] ?? null, lastUserIdx: userIndices[userIndices.length - 1] ?? null, roleSequence: liveRoles.length <= 30 ? liveRoles : [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)], }, lastCompaction: lastComp ? { hasFirstKeptEntryId: !!lastComp.firstKeptEntryId, foundInBranch: lastComp.firstKeptEntryId ? branchEntries.some((e: any) => e.id === lastComp.firstKeptEntryId) : null, } : null, tail: branchEntries.slice(-5).map((e: any) => ({ type: e.type, role: e.type === "message" ? e.message?.role : undefined, hasContent: e.type === "message" ? e.message?.content != null : undefined, })), }); try { ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning"); } catch {} return { cancel: true }; } export const registerBeforeCompactHook = (pi: ExtensionAPI) => { pi.on("session_before_compact", (event, ctx) => { const { preparation, branchEntries, customInstructions } = event; const settings = loadSettings(); // Always handle explicit /pi-vcc marker. // Otherwise, only handle when user opted in via settings. const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION; if (!isPiVcc && !settings.overrideDefaultCompaction) return; const ownCut = buildOwnCut(branchEntries as any[]); if (!ownCut.ok) { return handleOwnCutCancellation({ ownCut, isPiVcc, settings, branchEntries: branchEntries as any[], ctx, }); } const agentMessages = ownCut.messages; const firstKeptEntryId = ownCut.firstKeptEntryId; const messages = convertToLlm(agentMessages); // Count kept messages and estimate tokens const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId); const keptEntries = keptIdx >= 0 ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message") : []; const keptChars = keptEntries.reduce((sum: number, e: any) => { const c = e.message?.content; if (typeof c === "string") return sum + c.length; if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => { if (p.text) return s + p.text.length; if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length); if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length); return s; }, 0); return sum; }, 0); lastStats = { summarized: agentMessages.length, kept: keptEntries.length, keptTokensEst: Math.round(keptChars / 4), }; const config = settings; const summary = compile({ messages, previousSummary: preparation.previousSummary, fileOps: { readFiles: [...preparation.fileOps.read], modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited], }, }); const branchIds = branchEntries.map((e: any) => e.id); const cutIdx = branchIds.indexOf(firstKeptEntryId); const cutWindow = cutIdx >= 0 ? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({ id: e.id, type: e.type, role: e.type === "message" ? e.message?.role : undefined, preview: e.type === "message" ? previewContent(e.message?.content) : undefined, })) : []; dbg(config, { usedOwnCut: true, messagesToSummarize: agentMessages.length, messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })), messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })), convertedMessages: messages.length, firstKeptEntryId, cutWindow, tokensBefore: preparation.tokensBefore, summaryLength: summary.length, summaryPreview: summary.slice(0, 500), sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]), }); const details: PiVccCompactionDetails = { compactor: "ultimate-pi-vcc", version: 1, sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]), sourceMessageCount: agentMessages.length, previousSummaryUsed: Boolean(preparation.previousSummary), }; lastCompactWasPiVcc = isPiVcc; return { compaction: { summary, details, tokensBefore: preparation.tokensBefore, firstKeptEntryId, }, }; }); // Fire success toast for /compact path only (delayed to let UI settle). // /pi-vcc path uses its own onComplete callback in the command handler. pi.on("session_compact", (event, ctx) => { if (!event.fromExtension) return; if (lastCompactWasPiVcc) return; // /pi-vcc handles its own toast via onComplete const stats = lastStats; if (!stats) return; setTimeout(() => { try { ctx?.ui?.notify?.( `pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`, "info", ); } catch {} }, 500); }); };