dev_sandbox/.pi/agent/extensions/insights/index.ts
2026-06-18 14:52:14 -04:00

174 lines
6.5 KiB
TypeScript

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"
);
},
});
}