mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
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
This commit is contained in:
parent
75146a2b96
commit
0f4d929a8e
4 changed files with 203 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null>(
|
|||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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.",
|
||||
"",
|
||||
"<conversation>",
|
||||
conversationText,
|
||||
"</conversation>",
|
||||
].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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue