import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { execFileSync } from "node:child_process"; import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; import { pathToFileURL } from "node:url"; const EXT_DIR = path.dirname(new URL(import.meta.url).pathname); const ANALYZE = path.join(EXT_DIR, "analyze.py"); const RENDER = path.join(EXT_DIR, "render.py"); const OUT_DIR = path.join(os.homedir(), ".pi", "agent", "usage-data"); const STATS_PATH = path.join(OUT_DIR, "stats.json"); const NARR_PATH = path.join(OUT_DIR, "narrative.json"); // The report is written to the shared /workspace mount so it's accessible // from the host machine. const REPORT_DIR = "/workspace"; const REPORT_PATH = path.join(REPORT_DIR, "pi-insights-report.html"); // /workspace inside the sandbox maps to this path on the host machine. const SANDBOX_MOUNT = "/workspace"; const HOST_MOUNT = "/Users/aselimov/rescale"; // Return a clickable file:// URL for the report instead of launching a // browser. Spawning an opener (xdg-open/open) is unreliable in headless // sandboxes and a missing binary surfaces as an uncaught async error event. // The URL is rewritten to the host path so it's clickable from the user's // real computer rather than referencing the container filesystem. function reportUrl(file: string) { const hostPath = file.startsWith(SANDBOX_MOUNT) ? HOST_MOUNT + file.slice(SANDBOX_MOUNT.length) : file; return pathToFileURL(hostPath).href; } function renderReport() { execFileSync("python3", [RENDER, STATS_PATH, NARR_PATH, REPORT_PATH], { encoding: "utf-8", }); } export default function (pi: ExtensionAPI) { // Tool the LLM calls to supply the qualitative narrative. pi.registerTool({ name: "write_insights_report", label: "Write Insights Report", description: "Write the qualitative narrative for the pi usage insights report, then " + "render and open the final HTML. Call this exactly once after analyzing the stats.", parameters: Type.Object({ summary: Type.String({ description: "One or two sentence overview of the user's pi usage.", }), highlights: Type.Array(Type.String(), { description: "Notable facts about how they use pi (3-6 items).", }), friction: Type.Array(Type.String(), { description: "Observed friction patterns or inefficiencies (0-5 items).", }), suggestions: Type.Array( Type.Object({ title: Type.String(), detail: Type.String(), }), { description: "Concrete, actionable workflow suggestions (2-5 items)." } ), }), async execute(_id, params) { fs.mkdirSync(OUT_DIR, { recursive: true }); fs.writeFileSync(NARR_PATH, JSON.stringify(params, null, 2)); renderReport(); const url = reportUrl(REPORT_PATH); return { content: [ { type: "text", text: `Insights report written. Open it here:\n${url}`, }, ], details: { reportPath: REPORT_PATH, url }, }; }, }); pi.registerCommand("insights", { description: "Analyze your recent pi sessions and generate an insights report (optional: number of days, default 30)", handler: async (args, ctx) => { const days = (args || "").trim().match(/^\d+$/) ? args.trim() : "30"; ctx.ui.setStatus("insights", "Analyzing sessions…"); let statsRaw: string; try { statsRaw = execFileSync("python3", [ANALYZE, days], { encoding: "utf-8" }); } catch (e: any) { ctx.ui.setStatus("insights", ""); ctx.ui.notify(`insights: analysis failed: ${e?.message ?? e}`, "error"); return; } fs.mkdirSync(OUT_DIR, { recursive: true }); fs.writeFileSync(STATS_PATH, statsRaw); let stats: any; try { stats = JSON.parse(statsRaw); } catch { ctx.ui.setStatus("insights", ""); ctx.ui.notify("insights: could not parse analyzer output", "error"); return; } ctx.ui.setStatus("insights", ""); if (!stats?.totals?.sessions) { // Render a stats-only report (no LLM narrative needed). if (fs.existsSync(NARR_PATH)) fs.rmSync(NARR_PATH); renderReport(); ctx.ui.notify( `No sessions found in the last ${days} days. Empty report: ${reportUrl(REPORT_PATH)}`, "info" ); return; } const t = stats.totals; const topProjects = (stats.projects || []) .slice(0, 8) .map((p: any) => `${p.project} (${p.sessions} sessions, ${p.tool_calls} tool calls)`) .join(", "); const topTools = (stats.tool_usage || []) .slice(0, 8) .map((x: any) => `${x.name}=${x.count}`) .join(", "); const peakHours = (stats.hour_histogram || []) .map((c: number, h: number) => ({ h, c })) .sort((a: any, b: any) => b.c - a.c) .slice(0, 3) .map((x: any) => `${x.h}:00`) .join(", "); const prompt = [ "You are generating a pi usage insights report (like Claude Code's /insights).", "Below are deterministic statistics computed from the user's pi session history", `over the last ${days} days. Analyze them and then call the`, "`write_insights_report` tool exactly once with a concise narrative.", "Be specific, reference the actual numbers, and make suggestions actionable", "(e.g. /compact usage, AGENTS.md rules, prompt templates, model choice,", "running sessions inside the right project dir). Do not write any prose to the", "chat beyond a one-line confirmation; put everything in the tool call.", "", "KEY STATS:", `- Sessions: ${t.sessions} across ${stats.active_days} active days`, `- Prompts: ${t.user_msgs}, assistant turns: ${t.assistant_msgs}, tool calls: ${t.tool_calls}`, `- Tokens: ${t.total_tokens} (cacheRead ${t.cache_read}), cost: $${t.cost}`, `- Avg session duration: ${stats.avg_session_duration_min ?? "n/a"} min`, `- Compactions: ${t.compactions}, branches: ${t.branches}`, `- Top projects: ${topProjects}`, `- Tool usage: ${topTools}`, `- Peak hours: ${peakHours}`, "", "Full JSON (for reference):", "```json", JSON.stringify(stats), "```", ].join("\n"); pi.sendUserMessage(prompt); ctx.ui.notify( `Analyzed ${t.sessions} sessions. Generating insights…`, "info" ); }, }); }