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 { parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "../core/compact-args"; import { loadSettings, type PiVccSettings } from "../core/settings"; import type { PiVccCompactionDetails } from "../details"; import type { CompactionReason } from "../types"; export { PI_VCC_COMPACT_INSTRUCTION } from "../core/compact-args"; export interface CompactionStats { summarized: number; kept: number; keptUserTurns: number; totalUserTurns: number; requestedKeepUserTurns: number; keepUserTurnsExplicit: boolean; keepFallbackToCompactAll: boolean; keptTokensEst: number; reason?: CompactionReason; willRetry?: boolean; } let lastStats: CompactionStats | null = null; let lastCompactWasPiVcc = false; let pendingFollowUpPrompt: string | null = null; export const getLastCompactionStats = () => lastStats; const formatTokens = (n: number): string => { if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); }; export const formatCompactionStats = (stats: CompactionStats): string => { const fallbackNote = stats.keepFallbackToCompactAll ? stats.keepUserTurnsExplicit ? `; requested keep:${stats.requestedKeepUserTurns}, compact-all fallback` : "; compact-all fallback" : ""; return `pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.keptUserTurns}/${stats.totalUserTurns} user turns${fallbackNote} (${stats.kept} messages, ~${formatTokens(stats.keptTokensEst)} tok).`; }; const readCompactionEventContext = (event: unknown): { reason?: CompactionReason; willRetry: boolean } => { const raw = event as { reason?: unknown; willRetry?: unknown }; const reason = raw.reason === "manual" || raw.reason === "threshold" || raw.reason === "overflow" ? raw.reason : undefined; return { reason, willRetry: raw.willRetry === true }; }; const parseCompactionInstructions = (customInstructions?: string): { isPiVcc: boolean; keepUserTurns: number; keepUserTurnsExplicit: boolean; followUpPrompt: string | null; } => { const trimmed = customInstructions?.trim(); if (trimmed === PI_VCC_COMPACT_INSTRUCTION) { return { isPiVcc: true, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null }; } const keepPrefix = `${PI_VCC_COMPACT_INSTRUCTION} `; if (trimmed?.startsWith(keepPrefix)) { const parsed = parseKeepAndPrompt(trimmed.slice(keepPrefix.length)); return { isPiVcc: true, keepUserTurns: parsed.keepUserTurns ?? 1, keepUserTurnsExplicit: parsed.keepUserTurnsExplicit, followUpPrompt: null, }; } const parsed = parseKeepAndPrompt(customInstructions); return { isPiVcc: false, keepUserTurns: parsed.keepUserTurns ?? 1, keepUserTurnsExplicit: parsed.keepUserTurnsExplicit, followUpPrompt: parsed.followUpPrompt || null, }; }; const normalizeKeepUserTurns = (keepUserTurns: number): number => { if (!Number.isFinite(keepUserTurns)) return 0; return Math.max(0, Math.floor(keepUserTurns)); }; 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; keptUserTurns: number; totalUserTurns: number; requestedKeepUserTurns: number; keepFallbackToCompactAll: boolean; } | { ok: false; reason: OwnCutCancelReason }; export function buildOwnCut(branchEntries: any[], keepUserTurns = 1): OwnCutResult { const normalizedKeepUserTurns = normalizeKeepUserTurns(keepUserTurns); // 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" }; const userIndices = liveMessages.reduce((acc, e, i) => { if (e.message.role === "user") acc.push(i); return acc; }, []); const compactAll = (keepFallbackToCompactAll: boolean) => ({ ok: true as const, messages: liveMessages.map((e) => e.message), firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, totalUserTurns: userIndices.length, requestedKeepUserTurns: normalizedKeepUserTurns, keepFallbackToCompactAll, }); if (normalizedKeepUserTurns <= 0) return compactAll(false); // Summarize all messages before the requested kept user-turn tail. const targetUserIdx = userIndices.length - normalizedKeepUserTurns; const cutIdx = targetUserIdx >= 0 ? userIndices[targetUserIdx] : -1; if (cutIdx <= 0) { // Keep request cannot form a safe boundary (single user prompt, no user prompt, // or keep larger than available user turns), so compact EVERYTHING and keep no tail. // 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 compactAll(true); } return { ok: true, messages: liveMessages.slice(0, cutIdx).map((e) => e.message), firstKeptEntryId: liveMessages[cutIdx].entry.id, compactAll: false, keptUserTurns: userIndices.length - targetUserIdx, totalUserTurns: userIndices.length, requestedKeepUserTurns: normalizedKeepUserTurns, keepFallbackToCompactAll: 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", }; export const registerBeforeCompactHook = (pi: ExtensionAPI) => { pi.on("session_before_compact", (event, ctx) => { const { preparation, branchEntries, customInstructions } = event; const { reason, willRetry } = readCompactionEventContext(event); const settings = loadSettings(); // Always handle explicit /pi-vcc marker. // Otherwise, only handle when user opted in via settings. const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions); pendingFollowUpPrompt = null; if (!isPiVcc && !settings.overrideDefaultCompaction) return; const ownCut = buildOwnCut(branchEntries as any[], keepUserTurns); if (!ownCut.ok) { const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction"); const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1; // Recompute liveMessages view (same logic as buildOwnCut) for diagnostic const lastKeptId: string | undefined = lastComp?.firstKeptEntryId; const hasPriorCompaction = lastCompIdx >= 0; const hasValidKeptId = !!lastKeptId && (branchEntries as any[]).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 as any[])[i]; if (e.type === "compaction") continue; if (e.type === "message" && e.message) liveRoles.push(e.message.role); } } else { let foundKept = !lastKeptId; for (const e of branchEntries as any[]) { if (!foundKept && e.id === lastKeptId) foundKept = true; if (!foundKept) continue; if (e.type === "compaction") continue; if (e.type === "message" && e.message) liveRoles.push(e.message.role); } } const userIndices = liveRoles.reduce((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []); pendingFollowUpPrompt = null; const fallbackToCore = !isPiVcc && (reason === "overflow" || willRetry); dbg(settings, { cancelled: !fallbackToCore, fallbackToCore, reason: ownCut.reason, compaction: { reason, willRetry }, isPiVcc, counts: { total: branchEntries.length, messages: (branchEntries as any[]).filter((e: any) => e.type === "message").length, compactions: (branchEntries as any[]).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 as any[]).some((e: any) => e.id === lastComp.firstKeptEntryId) : null, } : null, tail: (branchEntries as any[]).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, })), }); if (fallbackToCore) return; try { ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning"); } catch {} return { cancel: true }; } pendingFollowUpPrompt = followUpPrompt; 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, keptUserTurns: ownCut.keptUserTurns, totalUserTurns: ownCut.totalUserTurns, requestedKeepUserTurns: ownCut.requestedKeepUserTurns, keepUserTurnsExplicit, keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll, keptTokensEst: Math.round(keptChars / 4), reason, willRetry, }; 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, compaction: { reason, willRetry }, 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: "pi-vcc", version: 1, sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]), sourceMessageCount: agentMessages.length, previousSummaryUsed: Boolean(preparation.previousSummary), reason, willRetry, }; 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", async (event, ctx) => { const { reason, willRetry } = readCompactionEventContext(event); if (!event.fromExtension) return; const followUpPrompt = pendingFollowUpPrompt; pendingFollowUpPrompt = null; if (lastCompactWasPiVcc) return; // /pi-vcc handles its own toast via onComplete if (reason === "overflow" || willRetry) return; const stats = lastStats; if (!stats) return; if (followUpPrompt) { try { await pi.sendUserMessage(followUpPrompt); } catch {} } setTimeout(() => { try { ctx?.ui?.notify?.( formatCompactionStats(stats), "info", ); } catch {} }, 500); }); };