542 lines
20 KiB
TypeScript
542 lines
20 KiB
TypeScript
// 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<CompactionCardDetails>("memory-cycle-resume", renderCompactionCard);
|
|
pi.registerMessageRenderer<CompactionCardDetails>("auto-compact-resume", renderCompactionCard);
|
|
pi.registerMessageRenderer<CompactionCardDetails>("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<string | null>((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 },
|
|
};
|
|
},
|
|
});
|
|
}
|