/** * Agent 回合摘要:统计一次 agent 运行的工具使用并格式化成摘要文本。 * * 分类:bash / read / edit / write / other(精确工具名;MCP 风格名归 other)。 * 计数:bash 按调用次数;read/edit/write 按非空 path/file_path 去重;other 按调用次数。 * 失败单独累计;另记回合耗时。 * * 呈现:`summaryLine` 纯文本,`summaryMarkdown` Markdown(可 box 引用块)。 */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { formatDuration } from "../../utils/format.ts"; export { formatDuration }; /** 工具分类。 */ export type AgentToolCategory = "bash" | "read" | "edit" | "write" | "other"; /** 一次 agent 回合的统计快照。 */ export type AgentSummaryData = { commands: number; reads: number; edits: number; writes: number; others: number; failed: number; durationMs: number; }; export function classifyTool(toolName: string): AgentToolCategory { const base = toolName.split(".").pop() ?? toolName; if (base === "bash") return "bash"; if (base === "read") return "read"; if (base === "edit") return "edit"; if (base === "write") return "write"; return "other"; } function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } function toolPath(args?: Record | null): string | undefined { const path = args?.path ?? args?.file_path; return nonEmptyString(path) ? path : undefined; } /** 累积一次 agent 回合的工具统计;agent_start 时新建实例。 */ export class AgentRunSummary { toolCount = 0; commandCount = 0; readFiles = new Set(); editFiles = new Set(); writeFiles = new Set(); otherCount = 0; failedCount = 0; readonly startedAt: number; constructor(startedAt = Date.now()) { this.startedAt = startedAt; } /** tool_execution_start 时调用。 */ recordToolStart(toolName: string, args?: Record | null): void { this.toolCount++; const path = toolPath(args); switch (classifyTool(toolName)) { case "bash": this.commandCount++; break; case "read": if (path) this.readFiles.add(path); break; case "edit": if (path) this.editFiles.add(path); break; case "write": if (path) this.writeFiles.add(path); break; default: this.otherCount++; } } /** tool_execution_end 时调用;isError 为 true 计入失败。 */ recordToolResult(isError: boolean): void { if (isError) this.failedCount++; } snapshot(now = Date.now()): AgentSummaryData { return { commands: this.commandCount, reads: this.readFiles.size, edits: this.editFiles.size, writes: this.writeFiles.size, others: this.otherCount, failed: this.failedCount, durationMs: now - this.startedAt, }; } } const plural = (count: number) => (count === 1 ? "" : "s"); /** 输出顺序:bash → read → edit → write → other → failed。 */ function summaryParts(data: AgentSummaryData): string[] { const parts: string[] = []; if (data.commands) parts.push(`ran ${data.commands} command${plural(data.commands)}`); if (data.reads) parts.push(`read ${data.reads} file${plural(data.reads)}`); if (data.edits) parts.push(`edited ${data.edits} file${plural(data.edits)}`); if (data.writes) parts.push(`wrote ${data.writes} file${plural(data.writes)}`); if (data.others) parts.push(`${data.others} other tool${plural(data.others)}`); if (data.failed) parts.push(`${data.failed} failed`); return parts; } /** 纯文本摘要行:句首大写 + 可选耗时。 */ export function summaryLine(data: AgentSummaryData): string { const parts = summaryParts(data); if (parts.length === 0) return ""; const text = parts.join(", "); const capitalized = text[0].toUpperCase() + text.slice(1); const duration = formatDuration(data.durationMs); return duration ? `${capitalized} · ${duration}` : capitalized; } /** * Markdown 摘要行。 * `box` 为 true:引用块 `> *斜体*`;false:整体加粗。 * `colors`:仅数字染色(success / failed)。 */ export function summaryMarkdown( data: AgentSummaryData, box = false, colors: { success: string; failed: string } = { success: "", failed: "" }, ): string { const parts = summaryParts(data); if (parts.length === 0) return ""; const capitalizeFirst = (part: string, first: boolean) => { const verb = part.match(/^[a-z]+/)?.[0] ?? ""; return first && verb ? verb[0].toUpperCase() + verb.slice(1) + part.slice(verb.length) : part; }; const paintNumber = (code: string, part: string) => code ? part.replace(/(\d+)/, `${code}$1\x1b[0m`) : part; const text = parts .map((part, index) => capitalizeFirst(part, index === 0)) .map((part) => paintNumber(part.endsWith("failed") ? colors.failed : colors.success, part)) .join(", "); const duration = formatDuration(data.durationMs); const line = duration ? `${text} · ${duration}` : text; return box ? `> *${line}*` : `**${line}**`; } /** * 绑定 pi 事件到摘要统计: * - agent_start 重置 * - tool_execution_start / end 累计 * - agent_end 回调(toolCount < minToolCount 跳过) */ export function bindAgentSummary( pi: ExtensionAPI, onSummary: (data: AgentSummaryData) => void, minToolCount = 2, ): () => void { let summary = new AgentRunSummary(); pi.on("agent_start", async () => { summary = new AgentRunSummary(); }); pi.on("tool_execution_start", async (event) => { summary.recordToolStart(event.toolName, event.args); }); pi.on("tool_execution_end", async (event) => { summary.recordToolResult(event.isError === true); }); pi.on("agent_end", async () => { if (summary.toolCount >= minToolCount) onSummary(summary.snapshot()); }); return () => { summary = new AgentRunSummary(); }; }