/** * pi-usage-stats — 扩展包工具使用次数统计 * * agent_end 时从 session jsonl 增量扫描 toolCall/toolResult, * 用 cursor(byte offset)记录处理进度,避免重复累加。 * 零运行时事件监听,只在 turn 结束时处理。 * * 数据文件:~/.pi/agent/data/usage-stats.json */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync } from "node:fs"; import { UsageTracker } from "./lib/tracker"; import { setTracker } from "./lib/wrap-command"; import type { UsageQuery } from "./lib/types"; import { buildReport } from "./lib/query"; /** 格式化统计报告为可读文本 */ function formatReport(report: ReturnType): string { const lines: string[] = []; const colW = [24, 8, 8, 8, 8]; // 计算字符串的终端显示宽度(CJK 字符占 2 列) const displayWidth = (s: string) => { let w = 0; for (const ch of s) { const cp = ch.codePointAt(0)!; // CJK Unified Ideographs + CJK Extension + Fullwidth + Katakana/Hangul etc. w += (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3000 && cp <= 0x30ff) || (cp >= 0xff00 && cp <= 0xffef) || (cp >= 0x2e80 && cp <= 0x2eff) || (cp >= 0x3400 && cp <= 0x4dbf) || (cp >= 0xf900 && cp <= 0xfaff) ? 2 : 1; } return w; }; const pad = (s: string | number, w: number) => { const str = String(s); return str + " ".repeat(Math.max(0, w - displayWidth(str))); }; const rpad = (s: string | number, w: number) => { const str = String(s); return " ".repeat(Math.max(0, w - displayWidth(str))) + str; }; lines.push(`📊 工具使用统计 (${report.period})`); lines.push("─".repeat(58)); lines.push( `${pad("工具名", colW[0])} ${rpad("AI调用", colW[1])} ${rpad("用户", colW[2])} ${rpad("错误", colW[3])} ${rpad("总计", colW[4])}`, ); lines.push("─".repeat(58)); for (const row of report.rows) { lines.push( `${pad(row.toolName, colW[0])} ${rpad(row.ai, colW[1])} ${rpad(row.user, colW[2])} ${rpad(row.errors, colW[3])} ${rpad(row.total, colW[4])}`, ); } lines.push("─".repeat(58)); lines.push( `${pad("合计", colW[0])} ${rpad(report.totals.ai, colW[1])} ${rpad(report.totals.user, colW[2])} ${rpad(report.totals.errors, colW[3])} ${rpad(report.totals.total, colW[4])}`, ); lines.push(`\n共 ${report.uniqueTools} 个工具`); return lines.join("\n"); } /** 获取当前 session 的 jsonl 文件路径 */ function getSessionJsonlPath(sessionId: string): string | null { // 项目级 session const projectPath = join(process.cwd(), ".pi/sessions", `${sessionId}.jsonl`); if (existsSync(projectPath)) return projectPath; // 全局 session const globalPath = join(homedir(), ".pi/sessions", `${sessionId}.jsonl`); if (existsSync(globalPath)) return globalPath; return null; } export default function (pi: ExtensionAPI): void { const statsPath = join(homedir(), ".pi/agent/data/usage-stats.json"); const tracker = new UsageTracker(statsPath); setTracker(tracker); // ── 1. agent_end — 增量扫描 session jsonl ──────────────── pi.on("agent_end", async () => { const sessionId = process.env.PI_SESSION_ID; if (!sessionId) return; const jsonlPath = getSessionJsonlPath(sessionId); if (!jsonlPath) return; tracker.processSessionFile(sessionId, jsonlPath); tracker.cleanup(180); tracker.flush(); }); // ── 2. /usage 命令(自身记录用户调用)──────────────────── pi.registerCommand("usage", { description: "查看扩展包工具使用统计。用法: /usage [today|week|all] [--tool <名称>]", handler: async (args: unknown, ctx: any) => { tracker.recordUserCall("usage"); const options = parseUsageArgs(typeof args === "string" ? args : ""); const report = buildReport(tracker.getStats(), options); ctx.ui.notify(formatReport(report), "info"); }, }); // ── 3. usage_stats 工具(供 AI 调用)────────────────────── pi.registerTool({ name: "usage_stats", label: "Usage Stats", description: "查询扩展包工具使用统计。返回 AI 调用次数、用户命令调用次数、错误次数等。", parameters: { type: "object", properties: { period: { type: "string", enum: ["today", "week", "all"], description: "时间范围:today=今天,week=近7天,all=全部", }, toolName: { type: "string", description: "按工具名过滤(前缀匹配)", }, }, }, async execute( _id: string, params: Record, _signal: AbortSignal | undefined, _onUpdate: undefined, _ctx: undefined, ): Promise<{ content: Array<{ type: "text"; text: string }> }> { const options: UsageQuery = {}; if (params.period) options.period = params.period as UsageQuery["period"]; if (params.toolName) options.toolName = params.toolName as string; const report = buildReport(tracker.getStats(), options); return { content: [{ type: "text", text: formatReport(report) }] }; }, }); } /** 解析 /usage 命令参数 */ function parseUsageArgs(args: string): UsageQuery { const options: UsageQuery = {}; const parts = args.split(/\s+/); for (let i = 0; i < parts.length; i++) { if (["today", "week", "all"].includes(parts[i])) { options.period = parts[i] as UsageQuery["period"]; } else if (parts[i] === "--tool" && parts[i + 1]) { options.toolName = parts[++i]; } } return options; }