From 0f4d929a8e5587062ef9bb4e96568a92a24987db Mon Sep 17 00:00:00 2001 From: scutifer Date: Tue, 13 Jan 2026 17:31:09 +0530 Subject: [PATCH] coding-agent: example extension to summarize conversation (#684) * feat: add summarize extension example Demonstrates custom UI with markdown rendering and external model integration for conversation summarization. * chore: update examples/extensions/README.md with summarize.ts --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/extensions.md | 5 +- .../examples/extensions/README.md | 1 + .../examples/extensions/summarize.ts | 195 ++++++++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/summarize.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5989e141..35a13503 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Extension example: `summarize.ts` for summarizing conversations using custom UI and an external model + ## [0.45.3] - 2026-01-13 ## [0.45.2] - 2026-01-13 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index e7a13e36..2dca34ea 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -18,6 +18,7 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe - Git checkpointing (stash at each turn, restore on branch) - Path protection (block writes to `.env`, `node_modules/`) - Custom compaction (summarize conversation your way) +- Conversation summaries (see `summarize.ts` example) - Interactive tools (questions, wizards, custom dialogs) - Stateful tools (todo lists, connection pools) - External integrations (file watchers, webhooks, CI triggers) @@ -830,7 +831,7 @@ pi.registerCommand("stats", { }); ``` -**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) +**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) ### pi.registerMessageRenderer(customType, renderer) @@ -1397,7 +1398,7 @@ const result = await ctx.ui.custom( Overlay components should define a `width` property to control their size. The overlay is centered by default. See [overlay-test.ts](../examples/extensions/overlay-test.ts) for a complete example. -**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts) +**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts) ### Custom Editor diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 7d99c999..9ab2ef9c 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -50,6 +50,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs | | `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` | | `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) | +| `summarize.ts` | Summarize the conversation so far with GPT-5.2, and show in a nice transient UI | ### Git Integration diff --git a/packages/coding-agent/examples/extensions/summarize.ts b/packages/coding-agent/examples/extensions/summarize.ts new file mode 100644 index 00000000..46768cb3 --- /dev/null +++ b/packages/coding-agent/examples/extensions/summarize.ts @@ -0,0 +1,195 @@ +import { complete, getModel } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui"; + +type ContentBlock = { + type?: string; + text?: string; + name?: string; + arguments?: Record; +}; + +type SessionEntry = { + type: string; + message?: { + role?: string; + content?: unknown; + }; +}; + +const extractTextParts = (content: unknown): string[] => { + if (typeof content === "string") { + return [content]; + } + + if (!Array.isArray(content)) { + return []; + } + + const textParts: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") { + continue; + } + + const block = part as ContentBlock; + if (block.type === "text" && typeof block.text === "string") { + textParts.push(block.text); + } + } + + return textParts; +}; + +const extractToolCallLines = (content: unknown): string[] => { + if (!Array.isArray(content)) { + return []; + } + + const toolCalls: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") { + continue; + } + + const block = part as ContentBlock; + if (block.type !== "toolCall" || typeof block.name !== "string") { + continue; + } + + const args = block.arguments ?? {}; + toolCalls.push(`Tool ${block.name} was called with args ${JSON.stringify(args)}`); + } + + return toolCalls; +}; + +const buildConversationText = (entries: SessionEntry[]): string => { + const sections: string[] = []; + + for (const entry of entries) { + if (entry.type !== "message" || !entry.message?.role) { + continue; + } + + const role = entry.message.role; + const isUser = role === "user"; + const isAssistant = role === "assistant"; + + if (!isUser && !isAssistant) { + continue; + } + + const entryLines: string[] = []; + const textParts = extractTextParts(entry.message.content); + if (textParts.length > 0) { + const roleLabel = isUser ? "User" : "Assistant"; + const messageText = textParts.join("\n").trim(); + if (messageText.length > 0) { + entryLines.push(`${roleLabel}: ${messageText}`); + } + } + + if (isAssistant) { + entryLines.push(...extractToolCallLines(entry.message.content)); + } + + if (entryLines.length > 0) { + sections.push(entryLines.join("\n")); + } + } + + return sections.join("\n\n"); +}; + +const buildSummaryPrompt = (conversationText: string): string => + [ + "Summarize this conversation so I can resume it later.", + "Include goals, key decisions, progress, open questions, and next steps.", + "Keep it concise and structured with headings.", + "", + "", + conversationText, + "", + ].join("\n"); + +const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => { + if (!ctx.hasUI) { + return; + } + + await ctx.ui.custom((_tui, theme, _kb, done) => { + const container = new Container(); + const border = new DynamicBorder((s: string) => theme.fg("accent", s)); + const mdTheme = getMarkdownTheme(); + + container.addChild(border); + container.addChild(new Text(theme.fg("accent", theme.bold("Conversation Summary")), 1, 0)); + container.addChild(new Markdown(summary, 1, 1, mdTheme)); + container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0)); + container.addChild(border); + + return { + render: (width: number) => container.render(width), + invalidate: () => container.invalidate(), + handleInput: (data: string) => { + if (matchesKey(data, "enter") || matchesKey(data, "escape")) { + done(undefined); + } + }, + }; + }); +}; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("summarize", { + description: "Summarize the current conversation in a custom UI", + handler: async (_args, ctx) => { + const branch = ctx.sessionManager.getBranch(); + const conversationText = buildConversationText(branch); + + if (!conversationText.trim()) { + if (ctx.hasUI) { + ctx.ui.notify("No conversation text found", "warning"); + } + return; + } + + if (ctx.hasUI) { + ctx.ui.notify("Preparing summary...", "info"); + } + + const model = getModel("openai", "gpt-5.2"); + if (!model && ctx.hasUI) { + ctx.ui.notify("Model openai/gpt-5.2 not found", "warning"); + } + + const apiKey = model ? await ctx.modelRegistry.getApiKey(model) : undefined; + if (!apiKey && ctx.hasUI) { + ctx.ui.notify("No API key for openai/gpt-5.2", "warning"); + } + + if (!model || !apiKey) { + return; + } + + const summaryMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: buildSummaryPrompt(conversationText) }], + timestamp: Date.now(), + }, + ]; + + const response = await complete(model, { messages: summaryMessages }, { apiKey, reasoningEffort: "high" }); + + const summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + await showSummaryUi(summary, ctx); + }, + }); +}