/** * picc-recap — Claude Code-style away summary for pi. * * Replicates Claude Code's `services/awaySummary.ts` + `hooks/useAwaySummary.ts`: * a dim, one-off "while you were away" line, generated by a small/fast model from * the last ~30 messages, using Claude Code's exact prompt. * * Adaptations to pi (see README "Deltas"): * - pi has no terminal-focus detection, so the trigger is an idle timer that * starts when the agent settles and fires after `idleAfterSettleMs` (5 min, * like Claude Code's BLUR_DELAY_MS). It is cancelled the moment you type. * - Displayed as a TUI widget with Claude Code's `※` marker (REFERENCE_MARK, * U+203B), cleared once you act. * * Loaded by jiti from `~/.pi/agent/extensions/picc-recap/src/index.ts`. * * Hooks: * - session_start → load config, restore last-summary state * - agent_settled → (auto) start idle timer * - input → cancel pending timer + clear widget (user is active) * - agent_start → cancel pending timer + clear widget * - session_shutdown→ cancel timer, clear widget * * Commands: * - /away → force a summary now */ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, } from "@earendil-works/pi-coding-agent"; import { CancellableLoader, Text } from "@earendil-works/pi-tui"; import { buildSummaryMessages, generateAwaySummary, readSessionMemory, } from "./awaySummary.js"; import { DEFAULT_CONFIG, loadRecapConfig, type RecapConfig, resolveRecapModel, } from "./config.js"; /** Claude Code's REFERENCE_MARK (`\u203b`, komejirushi). */ const REFERENCE_MARK = "\u203b"; const WIDGET_KEY = "away-summary"; const CUSTOM_TYPE = "away_summary"; interface LastAwaySummary { recap: string; toEntryId?: string; generatedAt: number; model?: string; } interface RecapState { config: RecapConfig; timer?: ReturnType; running: boolean; controller?: AbortController; last?: LastAwaySummary; } // --------------------------------------------------------------------------- // Session-branch helpers // --------------------------------------------------------------------------- /** The id of the last real user message in the branch (the "current turn"). */ function lastUserTurnEntryId(entries: SessionEntry[]): string | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry?.type !== "message") continue; if (entry.message.role === "user") return entry.id; } return undefined; } /** * Claude Code's `hasSummarySinceLastUserTurn`: skip generation if a previous * away summary was produced after the last real user message. */ function alreadySummarizedSinceLastUserTurn( entries: SessionEntry[], last: LastAwaySummary | undefined, ): boolean { if (!last?.toEntryId) return false; const lastUser = lastUserTurnEntryId(entries); return lastUser === last.toEntryId; } function restoreState(entries: SessionEntry[]): LastAwaySummary | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry?.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue; const data = entry.data as Partial | undefined; if (data && typeof data.recap === "string" && typeof data.generatedAt === "number") { return data as LastAwaySummary; } } return undefined; } // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- function showSummary(ctx: ExtensionContext, recap: string): void { if (ctx.mode !== "tui") return; ctx.ui.setWidget( WIDGET_KEY, (_tui, theme) => new Text( theme.fg("dim", REFERENCE_MARK) + theme.fg("dim", " ") + theme.fg("dim", theme.bold("recap:")) + theme.fg("dim", " ") + theme.fg("dim", theme.italic(recap)), 1, 0, ), ); } function clearWidget(ctx: ExtensionContext): void { if (ctx.mode !== "tui") return; ctx.ui.setWidget(WIDGET_KEY, undefined); } // --------------------------------------------------------------------------- // Core run // --------------------------------------------------------------------------- async function runAwaySummary( pi: ExtensionAPI, ctx: ExtensionContext, state: RecapState, _reason: "auto" | "manual", options: { force?: boolean; signal?: AbortSignal; showProgress?: boolean } = {}, ): Promise { const { force = false, signal, showProgress = true } = options; const config = state.config; if (!config.enabled) return; if (state.running) return; // Only TUI generates + displays (headless is a no-op, like Claude Code's // terminal requirement). Manual in headless still generates to stdout-free // path? No — match Claude Code: no terminal → no summary. if (ctx.mode !== "tui") return; const entries = ctx.sessionManager.getBranch(); if (!force && alreadySummarizedSinceLastUserTurn(entries, state.last)) return; const model = resolveRecapModel(ctx, config); if (!model) return; // If an explicit signal was not supplied (auto path), own an AbortController // so the run can be cancelled when the user becomes active. const ownController = !signal; const controller = ownController ? new AbortController() : undefined; const runSignal = signal ?? controller?.signal; if (runSignal?.aborted) return; state.running = true; state.controller = controller ?? undefined; try { const memory = await readSessionMemory(ctx, config); const messages = buildSummaryMessages(entries, config.recentMessageWindow, memory); if (messages.length === 0) return; if (showProgress) showProgressWidget(ctx); const recap = await generateAwaySummary({ model, messages, maxTokens: config.maxTokens, signal: runSignal, sessionId: ctx.sessionManager.getSessionId?.(), getAuth: (m) => ctx.modelRegistry.getApiKeyAndHeaders(m), }); // If the user became active and aborted, don't display or persist. if (!recap || runSignal?.aborted) return; state.last = { recap, toEntryId: lastUserTurnEntryId(entries), generatedAt: Date.now(), model: `${model.provider}/${model.id}`, }; pi.appendEntry(CUSTOM_TYPE, state.last); showSummary(ctx, recap); } catch (error) { // Claude Code swallows errors and shows nothing. if (!runSignal?.aborted) { console.debug?.("[picc-recap] run failed:", error); } } finally { if (state.controller === controller) state.controller = undefined; state.running = false; if (!showProgress) clearWidget(ctx); } } function showProgressWidget(ctx: ExtensionContext): void { if (ctx.mode !== "tui") return; clearWidget(ctx); ctx.ui.setWidget( WIDGET_KEY, (_tui, theme) => new Text(theme.fg("dim", `${REFERENCE_MARK} Generating…`), 1, 0), ); } // --------------------------------------------------------------------------- // Auto timer (the Claude Code trigger, adapted) // --------------------------------------------------------------------------- function cancelTimer(state: RecapState): void { if (state.timer) { clearTimeout(state.timer); state.timer = undefined; } } function cancelActiveRun(state: RecapState): void { if (state.running) state.controller?.abort(); } function scheduleAutoRecap(ctx: ExtensionContext, state: RecapState): void { cancelTimer(state); const config = state.config; if (!config.enabled || !config.auto || ctx.mode !== "tui") return; state.timer = setTimeout(() => { state.timer = undefined; // Only fire if truly idle and no queued work (Claude Code waits for the // turn to be done before generating). if (!ctx.isIdle() || ctx.hasPendingMessages()) return; void runAwaySummary( piRef, ctx, state, "auto", { showProgress: true }, ).catch(() => clearWidget(ctx)); }, config.idleAfterSettleMs); } // `pi` is captured by the factory closure; scheduleAutoRecap needs it. Set in the // factory before any timers can fire. let piRef: ExtensionAPI; // --------------------------------------------------------------------------- // Extension factory // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI): void { piRef = pi; const state: RecapState = { config: { ...DEFAULT_CONFIG }, running: false, controller: undefined, last: undefined, timer: undefined, }; pi.on("session_start", async (_event, ctx) => { try { state.config = await loadRecapConfig(ctx); } catch (error) { ctx.ui.notify(`Failed to load picc-recap config: ${error}`, "warning"); } state.last = restoreState(ctx.sessionManager.getBranch()); }); pi.on("agent_settled", async (_event, ctx) => { scheduleAutoRecap(ctx, state); }); // User is active again → cancel any pending/in-flight summary and clear. pi.on("input", async (_event, ctx) => { cancelTimer(state); cancelActiveRun(state); clearWidget(ctx); }); pi.on("agent_start", async (_event, ctx) => { cancelTimer(state); cancelActiveRun(state); clearWidget(ctx); }); pi.on("session_shutdown", async (_event, ctx) => { cancelTimer(state); cancelActiveRun(state); clearWidget(ctx); }); pi.registerCommand("away", { description: "Generate a 'while you were away' summary of the recent conversation", handler: async (_args, ctx: ExtensionCommandContext) => { cancelTimer(state); const config = state.config; if (!config.enabled) { if (ctx.hasUI) ctx.ui.notify("picc-recap is disabled by config", "warning"); return; } if (!config.manualCommand) { if (ctx.mode === "tui") showDisabledError(ctx); else if (ctx.hasUI) ctx.ui.notify("/away is disabled by config", "warning"); return; } if (ctx.mode !== "tui") { await runAwaySummary(pi, ctx, state, "manual", { force: true }); return; } await ctx.ui.custom((tui, theme, _keybindings, done) => { const loader = new CancellableLoader( tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Generating away summary… (esc to cancel)", ); let closed = false; const finish = () => { if (closed) return; closed = true; done(); }; loader.onAbort = () => finish(); void runAwaySummary(pi, ctx, state, "manual", { force: true, signal: loader.signal, showProgress: false, }) .then(finish) .catch((error) => { if (!loader.signal.aborted) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Away summary failed: ${message}`, "error"); } finish(); }); return loader; }); }, }); } function showDisabledError(ctx: ExtensionContext): void { if (ctx.mode !== "tui") return; ctx.ui.setWidget( WIDGET_KEY, (_tui, theme) => new Text(theme.fg("error", `${REFERENCE_MARK} /away is disabled by config`), 1, 0), ); }