174 lines
6.5 KiB
TypeScript
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"
|
|
);
|
|
},
|
|
});
|
|
}
|