// ABOUTME: Memory-aware compaction extension — hooks into pi's native compaction to save/restore context. // ABOUTME: Writes daily logs, session state, and optionally updates MEMORY.md during every compaction cycle. /** * Memory Cycle — Automatic memory-aware compaction with seamless restore * * Hooks into pi's native compaction system to: * 1. BEFORE compact: Extract session insights (daily log, session state, stable facts) * 2. AFTER compact: Inject restored memory context so agent continues seamlessly * * Also provides: * /cycle [instructions] — Manual command to trigger compact → new session → restore * cycle_memory — LLM-callable tool for the same workflow * * The agent gets a clean context window but retains full awareness of * everything that happened before. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; // convertToLlm and serializeConversation available if needed for custom summary generation import { Type } from "@sinclair/typebox"; import { Box, Text } from "@mariozechner/pi-tui"; import { getProjectName, getTimestamp, extractFileOps, writeDailyLog, writeSessionState, readRecentLogs, readSessionState, extractCompactionContext, buildRestorationContent, buildCycleMemoryInjection, } from "./lib/memory-cycle-helpers.ts"; import { getProactiveCompactionPhase } from "./lib/context-gate.ts"; // ── Tool Parameters ────────────────────────────────────────────────── const CycleParams = Type.Object({ instructions: Type.Optional( Type.String({ description: "Custom instructions for what to focus on in the summary" }), ), }); // ── Compaction Card Details ────────────────────────────────────────── interface CompactionCardDetails { /** "cycle" for cycle_memory, "auto" for footer auto-compact, "manual" for /compact */ source: "cycle" | "auto" | "manual"; /** Context percentage after compaction */ postPercent: number; /** Recent session task, if available */ task?: string; /** Recently edited files */ recentFiles?: string[]; } // ── Compaction Card Renderer ───────────────────────────────────────── // Renders a minimal, elegant dark-themed status card when compaction // completes. Appears for cycle_memory, auto-compact, and manual /compact. function renderCompactionCard( message: any, _options: any, theme: any, ) { const details = message.details; const percent = details?.postPercent ?? 0; const source = details?.source ?? "cycle"; // ── Title ─────────────────────────────────────────────────── const label = source === "auto" ? "Context Compacted" : source === "manual" ? "Context Compacted" : "Memory Cycle Complete"; const title = theme.fg("muted", label); // ── Percentage — color-coded by health ────────────────────── const pctColor = percent <= 30 ? "success" : percent <= 60 ? "muted" : "warning"; const pctText = theme.fg(pctColor as any, `${percent}%`) + theme.fg("dim", " context used"); // ── Detail lines (task + files) ───────────────────────────── const detailLines: string[] = []; if (details?.task) { const truncated = details.task.length > 72 ? details.task.slice(0, 69) + "..." : details.task; detailLines.push( theme.fg("dim", "task ") + theme.fg("muted", truncated), ); } if (details?.recentFiles?.length) { const shown = details.recentFiles.slice(0, 3); const names = shown.map((f: string) => { const parts = f.split("/"); return parts.length > 1 ? parts.slice(-2).join("/") : parts[0]; }); const more = details.recentFiles.length > 3 ? theme.fg("dim", ` +${details.recentFiles.length - 3}`) : ""; detailLines.push( theme.fg("dim", "files ") + theme.fg("muted", names.join(theme.fg("dim", " / "))) + more, ); } // ── Assemble card body ────────────────────────────────────── const lines: string[] = [ title, pctText, ]; if (detailLines.length > 0) { lines.push(""); // blank separator line for (const dl of detailLines) lines.push(dl); } const body = lines.join("\n"); // Custom dark-charcoal background — distinct from the ocean-blue theme // Neutral gray so it reads as a "system" card, not success/error const cardBg = (text: string) => `\x1b[48;2;30;36;42m${text}\x1b[49m`; const box = new Box( 3, // generous horizontal padding 1, // vertical breathing room cardBg, ); box.addChild(new Text(body, 0, 0)); return box; } // ── Extension ──────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { // ── Message Renderers ──────────────────────────────────────── // Register custom renderers for compaction status cards. // These render in the chat when display:true is set on sendMessage. pi.registerMessageRenderer("memory-cycle-resume", renderCompactionCard); pi.registerMessageRenderer("auto-compact-resume", renderCompactionCard); pi.registerMessageRenderer("memory-restored", renderCompactionCard); // ── Proactive compaction state ─────────────────────────────── // Two-phase: prep at 70% (wrap up work), hard stop at 80% (call cycle_memory). // Flags prevent repeated injection within the same compaction cycle. let prepInjected = false; // true after 70% prep message sent let compactInjected = false; // true after 80% hard-stop message sent // ── Hook: before_agent_start — proactive compaction ────────── // Fires before every agent turn. Checks context usage and injects // messages to guide the LLM toward compaction before overflow. pi.on("before_agent_start", async (_event, ctx) => { const usage = ctx.getContextUsage(); const { phase, percent } = getProactiveCompactionPhase(usage?.percent); if (phase === "compact" && !compactInjected) { compactInjected = true; ctx.ui.notify( `Context overflow detected, Auto-compacting... (escape to cancel)`, "info", ); return { message: { customType: "auto-compact-gate", content: `URGENT: Context window is at ${Math.round(percent)}% capacity. You MUST call cycle_memory immediately to prevent context overflow. Do not perform any other actions first. Call cycle_memory now.`, display: false, }, }; } if (phase === "prep" && !prepInjected) { prepInjected = true; ctx.ui.notify( `Context at ${Math.round(percent)}% -- wrapping up soon`, "info", ); return { message: { customType: "auto-compact-gate", content: `Context window is at ${Math.round(percent)}% capacity. Start wrapping up your current work: commit any in-progress changes, save state, and prepare for a memory cycle. When you finish your current step, call cycle_memory. Do not start any new large operations.`, display: false, }, }; } return {}; }); // Track cwd across compact events (before_compact → compact) let preCompactCwd: string = ""; // When cycle_memory triggers compaction, suppress redundant UI from // session_before_compact and session_compact — the cycle_memory // onComplete handler shows a single clean card instead. let cycleMemoryActive = false; // ── Hook: session_before_compact ────────────────────────────── // Runs as part of pi's native compaction (both auto and manual /compact). // We extract session insights and save them to disk BEFORE the context // is compacted. We do NOT cancel or replace compaction — we let pi's // default compaction run normally. pi.on("session_before_compact", async (event, ctx) => { preCompactCwd = ctx.cwd; const { preparation } = event; try { const project = getProjectName(ctx.cwd); const { date, time, iso } = getTimestamp(); // Use pi's already-extracted file operations from preparation const prepFileOps = preparation.fileOps; const readFiles = prepFileOps?.read ? [...prepFileOps.read] : []; const writtenFiles = prepFileOps?.written ? [...prepFileOps.written] : []; const editedFiles = prepFileOps?.edited ? [...prepFileOps.edited] : []; const modifiedFiles = [...new Set([...writtenFiles, ...editedFiles])]; // Also supplement with branch-level file ops for completeness const branchOps = extractFileOps(ctx.sessionManager.getBranch()); for (const f of branchOps.read) { if (!readFiles.includes(f)) readFiles.push(f); } for (const f of branchOps.modified) { if (!modifiedFiles.includes(f)) modifiedFiles.push(f); } // Build a compact summary from the messages being compacted const { summaryText, continueText } = extractCompactionContext( preparation.messagesToSummarize, preparation.previousSummary, ); // Write daily log entry writeDailyLog({ project, summary: summaryText, date, time, keyFiles: [...modifiedFiles, ...readFiles].slice(0, 10), continuePrompt: continueText, }); // Write session state writeSessionState(ctx.cwd, { project, iso, continuePrompt: continueText, currentTask: summaryText, filesEdited: modifiedFiles.slice(0, 10), filesRead: readFiles.slice(0, 10), }); // Only show notification for manual /compact — cycle_memory shows its own card if (!cycleMemoryActive) { ctx.ui.notify("Memory saved (daily log + session state)", "info"); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[memory-cycle] Pre-compact save failed: ${msg}`); // Don't cancel compaction on save failure } // Return nothing = let pi's default compaction proceed normally return; }); // ── Hook: session_compact ───────────────────────────────────── // Fires AFTER compaction completes (both manual /compact and core auto-compaction). // We inject a memory-restore message so the agent knows what happened // and can continue seamlessly. // // For core auto-compaction: the interactive-mode handles UI rebuild via // auto_compaction_start/end events. We just provide the restoration context. // For manual /compact: we send both a display card and restoration context. pi.on("session_compact", async (event, ctx) => { // Reset proactive compaction flags — allows next cycle to trigger prepInjected = false; compactInjected = false; const { compactionEntry } = event; const recentLogs = readRecentLogs(); const sessionState = readSessionState(preCompactCwd || ctx.cwd); // Build restoration context const parts = buildRestorationContent(sessionState); if (recentLogs) parts.push("", recentLogs); const postUsage = ctx.getContextUsage(); const postPercent = postUsage?.percent ? Math.round(postUsage.percent) : 0; // When cycle_memory is driving compaction, skip the display card here — // the cycle_memory onComplete handler shows a single clean card instead. // Only show the card for manual /compact or core auto-compaction. if (!cycleMemoryActive) { // Short card visible to user pi.sendMessage( { customType: "memory-restored", content: `Context compacted -- now at ${postPercent}%.`, display: true, details: { source: "manual", postPercent, task: sessionState?.currentTask, recentFiles: sessionState?.filesEdited, } satisfies CompactionCardDetails, }, ); } // Full restoration context for the agent (not displayed) // Always send this — cycle_memory onComplete will add its own, // but for manual /compact this is the only restoration message. if (!cycleMemoryActive) { pi.sendMessage( { customType: "memory-restored", content: parts.join("\n"), display: false, }, { deliverAs: "nextTurn" }, ); } }); // ── /cycle command ──────────────────────────────────────────── // Manual command: compact → new session → restore (full reset) pi.registerCommand("cycle", { description: "Compact → new session → restore: fresh context with full memory", handler: async (args, ctx) => { const customInstructions = args?.trim() || undefined; await ctx.waitForIdle(); const parentSessionFile = ctx.sessionManager.getSessionFile(); const entries = ctx.sessionManager.getBranch(); if (entries.length < 3) { ctx.ui.notify("Session too short to cycle — nothing to compact.", "warning"); return; } ctx.ui.notify("Memory Cycle: Step 1/3 — Compacting...", "info"); // Step 1: Compact and capture summary const compactionSummary = await new Promise((resolve) => { ctx.compact({ customInstructions: customInstructions ?? "Create a comprehensive summary preserving all goals, decisions, progress, file changes, and context needed to continue work seamlessly in a fresh session.", onComplete: () => { // The session_before_compact hook already saved memory artifacts. // Extract summary from post-compaction session. const postEntries = ctx.sessionManager.getBranch(); for (let i = postEntries.length - 1; i >= 0; i--) { const entry = postEntries[i]; if (entry.type === "compaction") { resolve((entry as any).summary ?? null); return; } } resolve(null); }, onError: (err) => { ctx.ui.notify(`Compaction failed: ${err.message}`, "error"); resolve(null); }, }); }); if (!compactionSummary) { ctx.ui.notify("Memory Cycle aborted — compaction produced no summary.", "error"); return; } ctx.ui.notify("Memory Cycle: Step 2/3 — Creating fresh session...", "info"); // Gather restoration context const recentLogs = readRecentLogs(); const sessionState = readSessionState(ctx.cwd); // Step 2: New session with parent link and memory injection const result = await ctx.newSession({ parentSession: parentSessionFile, setup: async (sm) => { const memoryText = buildCycleMemoryInjection({ compactionSummary, sessionState, recentLogs, }); sm.appendMessage({ role: "user", content: [{ type: "text", text: memoryText }], timestamp: Date.now(), }); }, }); if (result.cancelled) { ctx.ui.notify("Memory Cycle cancelled — session switch was blocked.", "warning"); return; } ctx.ui.notify("Memory Cycle complete — fresh context with full memory.", "success"); }, }); // ── Deferred compaction via agent_end hook ──────────────────── // The cycle_memory tool CANNOT call ctx.compact() directly because // compact() calls abort() which waits for the agent to be idle, // but the agent is blocked waiting for the tool to return → deadlock. // // Instead: tool sets a flag → returns immediately → agent_end fires // when the agent loop finishes → we compact from there (agent is idle). let pendingCycleMemory: { instructions?: string } | null = null; pi.on("agent_end", async (_event, ctx) => { if (!pendingCycleMemory) return; const request = pendingCycleMemory; pendingCycleMemory = null; // Signal to session_before_compact and session_compact hooks // to suppress their redundant UI — we show a single clean card. cycleMemoryActive = true; ctx.ui.setStatus("memory-cycle", "Compacting context..."); ctx.compact({ customInstructions: request.instructions ?? "Create a comprehensive summary preserving all goals, decisions, progress, file changes, and context needed to continue work seamlessly.", onComplete: () => { cycleMemoryActive = false; const postUsage = ctx.getContextUsage(); const postPercent = postUsage?.percent ? Math.round(postUsage.percent) : 0; // Read restored context for the agent const sessionState = readSessionState(ctx.cwd); const recentLogs = readRecentLogs(); const parts = buildRestorationContent(sessionState); if (recentLogs) parts.push("", recentLogs); const resumeContent = [ "Memory cycle complete — context compacted and restored.", `Context usage now at ${postPercent}%.`, "", ...parts, "", "Continue where you left off. Resume the task you were working on before compaction. Do NOT ask the user what to do — just keep working.", ].join("\n"); ctx.ui.setStatus("memory-cycle", undefined); // Single clean display card — no separate notify() to avoid // duplicate text noise in the terminal. pi.sendMessage( { customType: "memory-cycle-resume", content: `Memory cycle complete -- context compacted and restored.\nContext usage now at ${postPercent}%.`, display: true, details: { source: "cycle", postPercent, task: sessionState?.currentTask, recentFiles: sessionState?.filesEdited, } satisfies CompactionCardDetails, }, ); // Full restoration context for the agent (not displayed) pi.sendMessage( { customType: "memory-cycle-resume", content: resumeContent, display: false, }, { deliverAs: "followUp", triggerTurn: true }, ); }, onError: (err: Error) => { cycleMemoryActive = false; ctx.ui.setStatus("memory-cycle", undefined); ctx.ui.notify(`Memory Cycle failed: ${err.message}. Try /compact manually.`, "error"); }, }); }); // ── cycle_memory tool (LLM-callable) ───────────────────────── pi.registerTool({ name: "cycle_memory", label: "Cycle Memory", description: "Compact current session, start fresh, and restore memory. Use when context is getting large or you want a clean slate while keeping all progress.", promptSnippet: "Compact → clear → restore: fresh context with full memory", promptGuidelines: [ "Use cycle_memory when context usage is high (>70%) or the user asks to compact/cycle/refresh memory.", "After cycle_memory completes, you will have a fresh context window with full memory of what happened.", "The tool returns immediately — compaction happens after the current turn ends. You will be resumed automatically with restored context.", ], parameters: CycleParams, renderCall(args, theme) { const hint = (args as any).instructions as string | undefined; const preview = hint ? hint.length > 50 ? hint.slice(0, 47) + "..." : hint : ""; const text = theme.fg("dim", "cycle_memory") + (preview ? theme.fg("dim", " ") + theme.fg("muted", preview) : ""); return new Text(text, 0, 0); }, renderResult(result, _options, theme) { const details = result.details as { status?: string } | undefined; const status = details?.status ?? "done"; const msg = status === "scheduled" ? theme.fg("dim", "Memory cycle scheduled — compacting after this turn") : theme.fg("dim", "Memory cycle complete"); return new Text(msg, 0, 0); }, async execute(_toolCallId, params: { instructions?: string }, _signal, _onUpdate, ctx) { const customInstructions = params.instructions?.trim() || undefined; // Schedule compaction for after this agent turn ends (avoids deadlock). // The agent_end hook above picks this up and fires ctx.compact(). pendingCycleMemory = { instructions: customInstructions }; return { content: [ { type: "text", text: "Memory cycle scheduled. Compaction will run automatically after this turn completes. You will be resumed with full memory context. Do not call any more tools — just finish this turn.", }, ], details: { status: "scheduled", instructions: params.instructions }, }; }, }); }