import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent"; /** * pi-recap — a Claude Code-style session recap for pi. * * Shows a one-line recap of the current session state above the footer * (the line between the input editor and the status bar), like this: * * ※ recap: Building a pi extension that summarizes sessions; widget rendering in progress * * The recap is generated from the session goal (first user prompt) plus the * most recent rounds of activity, so it stays stable across turns and only * changes when the actual session state changes. It is a pure UI element: * it never enters the model context. * * Triggers: * - automatically after `agent_end`, once the session has at least * MIN_TURNS user messages (never twice for the same input key) * - manually at any time via the `/recap` command * * State survives session switches: the latest recap is persisted as a custom * session entry and restored on `/resume` or `/fork`. */ const STATE_KEY = "pi-recap"; const WIDGET_KEY = "pi-recap"; const RECAP_PREFIX = "※ recap:"; /** Default minimum number of user messages before the recap kicks in (`minTurns`). */ const MIN_TURNS = 3; /** How many most recent user/assistant rounds feed the recap. */ const MAX_ROUNDS = 3; /** Default auto-update cooldown in turns (`cooldownTurns`). */ const AUTO_UPDATE_COOLDOWN_TURNS = 3; /** Default word-overlap ratio above which a recap is considered unchanged (`similarityThreshold`). */ const SIMILARITY_THRESHOLD = 0.7; /** Truncation limits for the model input (keeps each call ~1-2k tokens). */ const GOAL_MAX_CHARS = 200; const USER_MAX_CHARS = 200; const ASSISTANT_MAX_CHARS = 300; const DEFAULT_MAX_WORDS = 40; const DEFAULT_RECAP_PROMPT = (maxWords: number) => `The user stepped away and is coming back. Recap in under ${maxWords} words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents.`; type Settings = { recap?: { model?: string; maxWords?: number; minTurns?: number; cooldownTurns?: number; similarityThreshold?: number; placement?: "above" | "below"; prompts?: { recap?: string; }; }; }; type SessionState = { recap?: string; goal?: string; inputKey?: string; }; type Round = { user: string; assistant: string; tools: string[]; }; /** Outcome of a generateRecap attempt, surfaced to the /recap command. */ type GenerateOutcome = | { kind: "generated" } | { kind: "busy" } | { kind: "skipped"; reason: string }; let currentRecap: string | null = null; let goalPrompt: string | null = null; let lastInputKey: string | null = null; let isGenerating = false; let agentEndCount = 0; let lastAutoUpdateAt = -Infinity; // --------------------------------------------------------------------------- // Content helpers // --------------------------------------------------------------------------- function extractText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((block) => { if (!block || typeof block !== "object") return ""; const item = block as { type?: string; text?: string }; if (item.type === "text") return item.text ?? ""; return ""; }) .filter(Boolean) .join(" "); } function extractToolNames(content: unknown): string[] { if (!Array.isArray(content)) return []; const names: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const item = block as { type?: string; name?: string }; if (item.type === "toolCall" && typeof item.name === "string") { names.push(item.name); } } return names; } export function cleanLine(line: string): string { return line .replace(/^[-•*]\s+/, "") // bullet marker + required space ("-3" stays intact) .replace(/^#+\s*/, "") // markdown heading syntax .replace(/^\d{1,3}[.)]\s+/, "") // ordered list marker + required space; 1-3 digits so years/decimals survive .replace(/^[※*](?:\s*recap\s*[::]|\s+)/i, "") // "※ recap:" / "※recap:" / "※ " prefix; bare "**" stays intact .replace(/^recap\s*[::]\s*/i, "") .replace(/^(?:here(?:'s| is| are)? (?:a |the )?recap[^::]*[::]\s*)/i, "") // "Here is a recap...:" preamble .replace(/\*\*/g, "") .replace(/`/g, "") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/^['"`]+|['"`]+$/g, "") .replace(/\s+/g, " ") .trim() .replace(/[.?!。!?]+$/, ""); } export function cleanSingleLine(text: string, fallback = ""): string { // Skip preamble lines ("Here is a recap...:") and heading-only lines // ("## Recap"), take the first line that still has content after cleaning. for (const part of text.split("\n")) { const trimmed = part.trim(); if (!trimmed) continue; if (/^#+\s*/.test(trimmed)) continue; // heading-only lines are not recap content const cleaned = cleanLine(trimmed); if (cleaned) return cleaned; } return fallback; } export function limitWords(text: string, maxWords: number): string { const words = text.trim().split(/\s+/).filter(Boolean); if (words.length <= maxWords) return text.trim(); return `${words.slice(0, maxWords).join(" ")}…`; } // --------------------------------------------------------------------------- // Session context extraction // --------------------------------------------------------------------------- function buildSessionContext(ctx: ExtensionContext): { goal: string | null; rounds: Round[]; userCount: number } { const branch = ctx.sessionManager.getBranch(); const rounds: Round[] = []; let current: Round | null = null; let userCount = 0; for (const entry of branch) { if (entry.type !== "message") continue; if (!("content" in entry.message)) continue; const role = entry.message.role; const content = entry.message.content; if (role === "user") { const text = extractText(content).trim(); if (!text) continue; userCount++; current = { user: text, assistant: "", tools: [] }; rounds.push(current); } else if (role === "assistant" && current) { const text = extractText(content).trim(); if (text) current.assistant = text; const tools = extractToolNames(content); if (tools.length > 0) current.tools = tools; } } return { goal: rounds[0]?.user ?? null, rounds: rounds.slice(-MAX_ROUNDS), userCount, }; } export function buildInputKey(goal: string, rounds: Round[]): string { const last = rounds[rounds.length - 1]; return [ goal.slice(0, 80), last?.user.slice(0, 80) ?? "", last?.assistant.slice(0, 80) ?? "", ].join("|"); } function normalizeForCompare(text: string): string { return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); } export function similarity(a: string, b: string): number { const wordsA = new Set(normalizeForCompare(a).split(" ").filter(Boolean)); const wordsB = new Set(normalizeForCompare(b).split(" ").filter(Boolean)); if (wordsA.size === 0 || wordsB.size === 0) return 0; let overlap = 0; for (const word of wordsA) { if (wordsB.has(word)) overlap++; } // Jaccard: symmetric overall similarity (a shortened recap that drops // content scores lower, so it is not suppressed by the gate). const union = new Set([...wordsA, ...wordsB]); return overlap / union.size; } function buildPrompt(goal: string, rounds: Round[], maxWords: number): string { const sections: string[] = [ "", "Note: this is the ORIGINAL goal from the start of the session. It may already be completed or superseded. Judge the CURRENT state from below, not from this goal.", goal.slice(0, GOAL_MAX_CHARS), "", "", "", ]; for (const [index, round] of rounds.entries()) { const label = `Round ${index + 1}`; sections.push(`${label} user: ${round.user.slice(0, USER_MAX_CHARS)}`); if (round.tools.length > 0) { sections.push(`${label} tools: ${round.tools.join(", ")}`); } if (round.assistant) { sections.push(`${label} assistant: ${round.assistant.slice(0, ASSISTANT_MAX_CHARS)}`); } } sections.push(""); sections.push(""); sections.push("Write the recap now."); sections.push("Write the recap in the same language as the user's messages."); return sections.join("\n"); } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- function getAgentDir(): string { return process.env.PI_CODING_AGENT_DIR || join(process.env.HOME ?? "", ".pi", "agent"); } function readJsonFile(path: string): Record { try { if (!existsSync(path)) return {}; return JSON.parse(readFileSync(path, "utf8")) as Record; } catch (error) { reportError("settings", `${path}: ${describeError(error)}`); return {}; } } function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } function mergeSettings(base: Record, override: Record): Record { const result = { ...base }; for (const [key, value] of Object.entries(override)) { const current = result[key]; result[key] = isPlainObject(current) && isPlainObject(value) ? mergeSettings(current, value) : value; } return result; } export function positiveInt(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback; } export function unitRatio(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : fallback; } function getRecapConfig( ctx: ExtensionContext, ): { model: string | undefined; maxWords: number; minTurns: number; cooldownTurns: number; similarityThreshold: number; prompt: string; placement: "aboveEditor" | "belowEditor"; } { const globalSettings = readJsonFile(join(getAgentDir(), "settings.json")); const projectSettings = readJsonFile(join(ctx.cwd, ".pi", "settings.json")); const recap = (mergeSettings(globalSettings, projectSettings) as Settings).recap; const configuredMaxWords = recap?.maxWords; const maxWords = typeof configuredMaxWords === "number" && Number.isFinite(configuredMaxWords) && configuredMaxWords > 0 ? Math.floor(configuredMaxWords) : DEFAULT_MAX_WORDS; return { model: recap?.model?.trim() || undefined, maxWords, minTurns: positiveInt(recap?.minTurns, MIN_TURNS), cooldownTurns: positiveInt(recap?.cooldownTurns, AUTO_UPDATE_COOLDOWN_TURNS), similarityThreshold: unitRatio(recap?.similarityThreshold, SIMILARITY_THRESHOLD), prompt: recap?.prompts?.recap?.trim() || DEFAULT_RECAP_PROMPT(maxWords), placement: recap?.placement === "above" ? "aboveEditor" : "belowEditor", }; } // --------------------------------------------------------------------------- // Model selection & recap generation // --------------------------------------------------------------------------- function splitModelRef(modelRef: string): { provider: string; modelId: string } | undefined { const separator = modelRef.indexOf("/"); if (separator <= 0 || separator === modelRef.length - 1) return undefined; return { provider: modelRef.slice(0, separator).trim(), modelId: modelRef.slice(separator + 1).trim(), }; } function pickModel(ctx: ExtensionContext, configuredModel: string | undefined) { if (configuredModel) { const parsed = splitModelRef(configuredModel); let found = parsed && ctx.modelRegistry.find(parsed.provider, parsed.modelId); // Bare model id ("deepseek-v4-flash"): resolve against the current // session model's provider. if (!found && !parsed && ctx.model) { found = ctx.modelRegistry.find(ctx.model.provider, configuredModel); } if (found && ctx.modelRegistry.hasConfiguredAuth(found)) return found; reportError("model", `${configuredModel} is not configured, falling back to the session model`); } if (ctx.model && ctx.modelRegistry.hasConfiguredAuth(ctx.model)) return ctx.model; reportError("model", "no usable model for recap generation"); return undefined; } async function generateRecap(pi: ExtensionAPI, ctx: ExtensionContext, force: boolean): Promise { if (isGenerating) return { kind: "busy" }; if (!ctx.hasUI) return { kind: "skipped", reason: "no UI" }; const { goal, rounds, userCount } = buildSessionContext(ctx); if (!goal || rounds.length === 0) return { kind: "skipped", reason: "no conversation yet" }; const config = getRecapConfig(ctx); if (!force && userCount < config.minTurns) return { kind: "skipped", reason: `fewer than ${config.minTurns} turns` }; const inputKey = buildInputKey(goal, rounds); if (!force && inputKey === lastInputKey) return { kind: "skipped", reason: "input unchanged" }; const model = pickModel(ctx, config.model); if (!model) return { kind: "skipped", reason: "no usable model" }; // Cooldown: only attempt an automatic update every N turns, and only // count attempts that actually passed all gates above. Manual (/recap) // runs bypass it. if (!force) { if (agentEndCount - lastAutoUpdateAt < config.cooldownTurns) { return { kind: "skipped", reason: "cooldown" }; } lastAutoUpdateAt = agentEndCount; } // After compaction the branch goal may drift to the first post-compaction // user message; the persisted goalPrompt is the true original session goal. const effectiveGoal = goalPrompt ?? goal; isGenerating = true; try { const response = await ctx.modelRegistry.complete( model, { systemPrompt: config.prompt, messages: [ { role: "user", content: [{ type: "text", text: buildPrompt(effectiveGoal, rounds, config.maxWords) }], timestamp: Date.now(), }, ], }, { maxTokens: 512, // Only honored by OpenAI-family adapters (covers deepseek et al.); // other adapter families silently ignore it. reasoningEffort: "minimal", cacheRetention: "none", signal: ctx.signal, }, ); // A length-capped response is mid-sentence garbage (thinking eats the // budget); keep the previous recap and retry after the cooldown. if (response.stopReason === "length") return { kind: "skipped", reason: "output truncated" }; const recap = cleanSingleLine(extractText(response.content)); if (!recap) return { kind: "skipped", reason: "empty model output" }; // Similarity gate: if the freshly generated recap says essentially // the same thing as the displayed one, keep the displayed text so // the line stays visually stable across similar turns. if (currentRecap && !force && similarity(currentRecap, recap) >= config.similarityThreshold) { lastInputKey = inputKey; persistState(pi, { recap: currentRecap, goal: effectiveGoal, inputKey }); return { kind: "skipped", reason: "similar to displayed recap" }; } currentRecap = limitWords(recap, config.maxWords); lastInputKey = inputKey; renderWidget(ctx, config.placement); persistState(pi, { recap: currentRecap, goal: effectiveGoal, inputKey }); return { kind: "generated" }; } catch (error) { reportError("recap", error); return { kind: "skipped", reason: "generation failed" }; } finally { isGenerating = false; } } // --------------------------------------------------------------------------- // UI & persistence // --------------------------------------------------------------------------- export function isWideChar(ch: string): boolean { const code = ch.codePointAt(0) ?? 0; return ( (code >= 0x1100 && code <= 0x11ff) || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) || code === 0x203b // ※ (full-width reference mark in CJK terminals) ); } export function wrapText(text: string, maxWidth: number): string[] { const lines: string[] = []; let current = ""; let currentWidth = 0; for (const ch of text) { const w = isWideChar(ch) ? 2 : 1; if (currentWidth + w > maxWidth && current) { lines.push(current); current = ""; currentWidth = 0; } current += ch; currentWidth += w; } if (current) lines.push(current); return lines; } let widgetRegistered = false; let widgetPlacement: "aboveEditor" | "belowEditor" | null = null; let widgetTui: { requestRender(): void } | null = null; function registerWidget(ctx: ExtensionContext, placement: "aboveEditor" | "belowEditor"): void { if (widgetRegistered) { // Placement changed mid-session (settings edit without reload): // re-register under the new placement. if (widgetPlacement !== placement) { ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: widgetPlacement ?? placement }); widgetRegistered = false; } else { widgetTui?.requestRender(); return; } } ctx.ui.setWidget( WIDGET_KEY, (tui, theme) => { widgetTui = tui; return { dispose() {}, invalidate() {}, render(width: number): string[] { if (!currentRecap) return []; const lines = wrapText(`${RECAP_PREFIX} ${currentRecap}`, Math.max(12, width - 1)); return lines.map((line, index) => { if (index === 0) { const prefixLen = RECAP_PREFIX.length; return ( theme.fg("dim", line.slice(0, 2)) + theme.fg("accent", theme.bold(line.slice(2, prefixLen))) + theme.fg("muted", theme.italic(line.slice(prefixLen))) ); } return theme.fg("muted", theme.italic(line)); }); }, }; }, { placement }, ); widgetRegistered = true; widgetPlacement = placement; } function renderWidget(ctx: ExtensionContext, placement?: "aboveEditor" | "belowEditor"): void { if (!ctx.hasUI) return; const effectivePlacement = placement ?? getRecapConfig(ctx).placement; registerWidget(ctx, effectivePlacement); } function persistState(pi: ExtensionAPI, state: SessionState): void { pi.appendEntry(STATE_KEY, state); } function getStoredState(ctx: ExtensionContext): SessionState { const branch = ctx.sessionManager.getBranch(); for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; if (entry.type !== "custom" || entry.customType !== STATE_KEY) continue; return (entry as CustomEntry).data ?? {}; } return {}; } function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function reportError(area: string, error: unknown): void { const message = describeError(error); if (message.includes("This extension ctx is stale")) return; console.warn(`[pi-recap] ${area} failed: ${message}`); } // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const state = getStoredState(ctx); currentRecap = state.recap ?? null; goalPrompt = state.goal ?? null; lastInputKey = state.inputKey ?? null; // Module-scoped state is per-session: reset everything so cooldown, // turn counting, and widget registration do not leak across // new/resume/fork sessions. agentEndCount = 0; lastAutoUpdateAt = -Infinity; widgetRegistered = false; widgetPlacement = null; widgetTui = null; renderWidget(ctx); }); pi.on("message_end", async (event, ctx) => { if (event.message?.role !== "user") return; if (goalPrompt) return; const text = extractText(event.message.content).trim(); if (text) { goalPrompt = text; persistState(pi, { recap: currentRecap ?? undefined, goal: goalPrompt, inputKey: lastInputKey ?? undefined }); } }); pi.on("agent_end", async (_event, ctx) => { agentEndCount++; await generateRecap(pi, ctx, false); }); pi.registerCommand("recap", { description: "Generate a session recap now", handler: async (_args, ctx) => { if (!ctx.hasUI) return; ctx.ui.notify("Generating recap…", "info"); const outcome = await generateRecap(pi, ctx, true); if (outcome.kind === "generated") { ctx.ui.notify("Recap updated", "info"); } else if (outcome.kind === "busy") { ctx.ui.notify("A recap is already being generated", "warning"); } else { ctx.ui.notify(`Recap skipped: ${outcome.reason}`, "warning"); } }, }); pi.on("session_shutdown", async (_event, ctx) => { if (!ctx.hasUI) return; ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: getRecapConfig(ctx).placement }); widgetRegistered = false; widgetPlacement = null; widgetTui = null; }); }