insights and other extension fixes

This commit is contained in:
Alex Selimov 2026-06-18 14:50:25 -04:00
parent 8bd65f7313
commit ca9acbdffb
8 changed files with 955 additions and 4 deletions

View file

@ -0,0 +1,37 @@
# /insights
A pi extension that analyzes your recent pi session history and generates an
interactive HTML usage report — like Claude Code's `/insights`.
## Usage
```
/insights # analyze the last 30 days
/insights 7 # analyze the last 7 days
```
The report opens in your browser and is saved to:
```
~/.pi/agent/usage-data/report.html
```
## How it works
1. `analyze.py` walks `~/.pi/agent/sessions/*/*.jsonl` and computes deterministic
stats (sessions, prompts, tool calls, tokens, cost, per-project breakdown,
activity by hour/weekday/day, longest/costliest sessions). No LLM, no tokens.
2. The command feeds those stats to the model, which writes a qualitative
narrative (summary, highlights, friction patterns, suggestions) by calling the
`write_insights_report` tool.
3. `render.py` combines the stats + narrative into a self-contained HTML report
and opens it.
Intermediate files (`stats.json`, `narrative.json`) are also written to
`~/.pi/agent/usage-data/` for inspection.
## Files
- `index.ts` — extension entry point (command + tool)
- `analyze.py` — deterministic session analytics → JSON
- `render.py` — stats + narrative JSON → HTML report

View file

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Deterministic session analytics for the /insights command.
Walks ~/.pi/agent/sessions, computes usage stats over a window of days,
and prints a JSON summary on stdout. No LLM involved here.
"""
import sys
import os
import json
import glob
import collections
from datetime import datetime, timezone, timedelta
SESSIONS_DIR = os.path.expanduser("~/.pi/agent/sessions")
def parse_ts(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def project_name(cwd):
if not cwd:
return "(unknown)"
return os.path.basename(cwd.rstrip("/")) or cwd
def analyze(days=30):
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
files = glob.glob(os.path.join(SESSIONS_DIR, "*", "*.jsonl"))
sessions = []
tool_counts = collections.Counter()
model_counts = collections.Counter()
project_stats = collections.defaultdict(lambda: {
"sessions": 0, "user_msgs": 0, "assistant_msgs": 0,
"tool_calls": 0, "cost": 0.0, "tokens": 0,
})
hour_hist = collections.Counter()
dow_hist = collections.Counter()
day_hist = collections.Counter()
totals = {
"sessions": 0, "user_msgs": 0, "assistant_msgs": 0, "tool_results": 0,
"tool_calls": 0, "cost": 0.0, "input_tokens": 0, "output_tokens": 0,
"cache_read": 0, "cache_write": 0, "total_tokens": 0,
"compactions": 0, "branches": 0,
}
for fpath in files:
header = None
first_ts = None
last_ts = None
s_user = s_asst = s_tools = s_toolres = 0
s_cost = 0.0
s_tokens = 0
s_compactions = 0
s_branches = 0
s_models = set()
s_tool_counter = collections.Counter()
cwd = None
try:
with open(fpath, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
e = json.loads(line)
except Exception:
continue
et = e.get("type")
if et == "session":
header = e
cwd = e.get("cwd")
first_ts = parse_ts(e.get("timestamp"))
continue
ts = parse_ts(e.get("timestamp"))
if ts:
if first_ts is None:
first_ts = ts
last_ts = ts
if et == "compaction":
s_compactions += 1
elif et == "branch_summary":
s_branches += 1
elif et == "message":
msg = e.get("message", {})
role = msg.get("role")
if role == "user":
s_user += 1
elif role == "assistant":
s_asst += 1
if msg.get("model"):
s_models.add(msg["model"])
u = msg.get("usage") or {}
s_cost += (u.get("cost") or {}).get("total", 0) or 0
s_tokens += u.get("totalTokens", 0) or 0
totals["input_tokens"] += u.get("input", 0) or 0
totals["output_tokens"] += u.get("output", 0) or 0
totals["cache_read"] += u.get("cacheRead", 0) or 0
totals["cache_write"] += u.get("cacheWrite", 0) or 0
for c in msg.get("content", []):
if isinstance(c, dict) and c.get("type") == "toolCall":
name = c.get("name", "?")
s_tools += 1
s_tool_counter[name] += 1
elif role == "toolResult":
s_toolres += 1
except Exception:
continue
# Window filter: use session start time
anchor = first_ts or last_ts
if anchor is None or anchor < cutoff:
continue
proj = project_name(cwd)
ps = project_stats[proj]
ps["sessions"] += 1
ps["user_msgs"] += s_user
ps["assistant_msgs"] += s_asst
ps["tool_calls"] += s_tools
ps["cost"] += s_cost
ps["tokens"] += s_tokens
for name, n in s_tool_counter.items():
tool_counts[name] += n
for m in s_models:
model_counts[m] += 1
if anchor:
local = anchor.astimezone()
hour_hist[local.hour] += 1
dow_hist[local.weekday()] += 1
day_hist[local.strftime("%Y-%m-%d")] += 1
duration_min = None
if first_ts and last_ts and last_ts > first_ts:
duration_min = round((last_ts - first_ts).total_seconds() / 60.0, 1)
sessions.append({
"file": os.path.basename(fpath),
"project": proj,
"cwd": cwd,
"start": anchor.isoformat() if anchor else None,
"duration_min": duration_min,
"user_msgs": s_user,
"assistant_msgs": s_asst,
"tool_calls": s_tools,
"tool_results": s_toolres,
"cost": round(s_cost, 4),
"tokens": s_tokens,
"compactions": s_compactions,
"branches": s_branches,
"models": sorted(s_models),
})
totals["sessions"] += 1
totals["user_msgs"] += s_user
totals["assistant_msgs"] += s_asst
totals["tool_results"] += s_toolres
totals["tool_calls"] += s_tools
totals["cost"] += s_cost
totals["total_tokens"] += s_tokens
totals["compactions"] += s_compactions
totals["branches"] += s_branches
totals["cost"] = round(totals["cost"], 4)
projects = []
for name, ps in project_stats.items():
projects.append({
"project": name,
"sessions": ps["sessions"],
"user_msgs": ps["user_msgs"],
"assistant_msgs": ps["assistant_msgs"],
"tool_calls": ps["tool_calls"],
"cost": round(ps["cost"], 4),
"tokens": ps["tokens"],
})
projects.sort(key=lambda p: p["sessions"], reverse=True)
durations = [s["duration_min"] for s in sessions if s["duration_min"]]
avg_duration = round(sum(durations) / len(durations), 1) if durations else None
active_days = len(day_hist)
return {
"window_days": days,
"generated_at": datetime.now(timezone.utc).isoformat(),
"totals": totals,
"active_days": active_days,
"avg_session_duration_min": avg_duration,
"projects": projects,
"tool_usage": [{"name": k, "count": v} for k, v in tool_counts.most_common()],
"model_usage": [{"model": k, "sessions": v} for k, v in model_counts.most_common()],
"hour_histogram": [hour_hist.get(h, 0) for h in range(24)],
"dow_histogram": [dow_hist.get(d, 0) for d in range(7)],
"daily_histogram": dict(sorted(day_hist.items())),
"longest_sessions": sorted(
[s for s in sessions if s["duration_min"]],
key=lambda s: s["duration_min"], reverse=True
)[:10],
"costliest_sessions": sorted(sessions, key=lambda s: s["cost"], reverse=True)[:10],
}
if __name__ == "__main__":
days = 30
if len(sys.argv) > 1:
try:
days = int(sys.argv[1])
except ValueError:
pass
print(json.dumps(analyze(days), indent=2))

View file

@ -0,0 +1,174 @@
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"
);
},
});
}

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Render the insights HTML report from stats JSON + LLM narrative JSON.
Usage: render.py <stats.json> <narrative.json> <out.html>
narrative.json is optional (pass "-" to skip). It may contain:
{ "summary": str, "highlights": [str], "suggestions": [{"title","detail"}],
"friction": [str] }
"""
import sys, os, json, html
def esc(s):
return html.escape(str(s if s is not None else ""))
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def bar_chart(values, labels, color="#7c5cff", height=120):
mx = max(values) if values and max(values) > 0 else 1
cells = []
for v, lab in zip(values, labels):
h = int((v / mx) * height) if mx else 0
cells.append(
f'<div class="bar-col"><div class="bar" style="height:{h}px;background:{color}" '
f'title="{esc(lab)}: {v}"></div><div class="bar-lab">{esc(lab)}</div></div>'
)
return f'<div class="bars" style="height:{height+24}px">' + "".join(cells) + "</div>"
def stat_card(label, value, sub=""):
sub_html = f'<div class="stat-sub">{esc(sub)}</div>' if sub else ""
return (
f'<div class="card stat"><div class="stat-val">{esc(value)}</div>'
f'<div class="stat-lab">{esc(label)}</div>{sub_html}</div>'
)
def fmt_num(n):
try:
n = float(n)
except Exception:
return str(n)
if n >= 1_000_000_000:
return f"{n/1_000_000_000:.1f}B"
if n >= 1_000_000:
return f"{n/1_000_000:.1f}M"
if n >= 1_000:
return f"{n/1_000:.1f}K"
if n == int(n):
return str(int(n))
return f"{n:.2f}"
def build(stats, narr):
t = stats["totals"]
parts = []
A = parts.append
A('<section class="hero">')
A(f'<h1>pi insights</h1>')
A(f'<p class="sub">Last {stats["window_days"]} days &middot; generated {esc(stats["generated_at"][:16].replace("T"," "))} UTC</p>')
if narr.get("summary"):
A(f'<p class="summary">{esc(narr["summary"])}</p>')
A("</section>")
# Top stats
A('<section class="grid">')
A(stat_card("Sessions", fmt_num(t["sessions"]), f'{stats["active_days"]} active days'))
A(stat_card("Prompts sent", fmt_num(t["user_msgs"])))
A(stat_card("Assistant turns", fmt_num(t["assistant_msgs"])))
A(stat_card("Tool calls", fmt_num(t["tool_calls"])))
A(stat_card("Total tokens", fmt_num(t["total_tokens"])))
A(stat_card("Total cost", f'${t["cost"]:.2f}'))
if stats.get("avg_session_duration_min"):
A(stat_card("Avg session", f'{stats["avg_session_duration_min"]:.0f} min'))
cache = t["cache_read"] + t["cache_write"]
if cache:
rate = (t["cache_read"] / cache * 100) if cache else 0
A(stat_card("Cache hit", f'{rate:.0f}%', f'{fmt_num(t["cache_read"])} read'))
A("</section>")
return parts
def build_charts(stats, narr, parts):
A = parts.append
# Activity by hour
hours = stats["hour_histogram"]
hour_labels = [str(h) if h % 3 == 0 else "" for h in range(24)]
A('<section class="card wide"><h2>Activity by hour of day</h2>')
A(bar_chart(hours, hour_labels, "#7c5cff"))
A("</section>")
# Activity by weekday
dow = stats["dow_histogram"]
A('<section class="card wide"><h2>Activity by weekday</h2>')
A(bar_chart(dow, DOW, "#21c7a8"))
A("</section>")
# Daily
daily = stats.get("daily_histogram", {})
if daily:
keys = list(daily.keys())
vals = [daily[k] for k in keys]
labs = [k[5:] if i % max(1, len(keys)//8) == 0 else "" for i, k in enumerate(keys)]
A('<section class="card wide"><h2>Sessions per day</h2>')
A(bar_chart(vals, labs, "#f5a623"))
A("</section>")
# Projects table
A('<section class="card wide"><h2>Projects</h2><table>')
A('<tr><th>Project</th><th>Sessions</th><th>Prompts</th><th>Tool calls</th><th>Tokens</th><th>Cost</th></tr>')
for p in stats["projects"][:15]:
A(f'<tr><td>{esc(p["project"])}</td><td>{p["sessions"]}</td><td>{p["user_msgs"]}</td>'
f'<td>{p["tool_calls"]}</td><td>{fmt_num(p["tokens"])}</td><td>${p["cost"]:.2f}</td></tr>')
A("</table></section>")
# Tool usage
tu = stats["tool_usage"]
if tu:
names = [x["name"] for x in tu]
counts = [x["count"] for x in tu]
A('<section class="card wide"><h2>Tool usage</h2>')
A(bar_chart(counts, names, "#ff6b9d"))
A("</section>")
# Models
mu = stats["model_usage"]
if mu:
A('<section class="card wide"><h2>Models used</h2><table><tr><th>Model</th><th>Sessions</th></tr>')
for m in mu:
short = m["model"].split("/")[-1]
A(f'<tr><td title="{esc(m["model"])}">{esc(short)}</td><td>{m["sessions"]}</td></tr>')
A("</table></section>")
def build_narrative(stats, narr, parts):
A = parts.append
if narr.get("highlights"):
A('<section class="card wide narr"><h2>Highlights</h2><ul>')
for h in narr["highlights"]:
A(f"<li>{esc(h)}</li>")
A("</ul></section>")
if narr.get("friction"):
A('<section class="card wide narr"><h2>Friction patterns</h2><ul>')
for h in narr["friction"]:
A(f"<li>{esc(h)}</li>")
A("</ul></section>")
if narr.get("suggestions"):
A('<section class="card wide narr"><h2>Suggestions</h2><div class="suggestions">')
for s in narr["suggestions"]:
if isinstance(s, dict):
A(f'<div class="sugg"><div class="sugg-title">{esc(s.get("title",""))}</div>'
f'<div class="sugg-detail">{esc(s.get("detail",""))}</div></div>')
else:
A(f'<div class="sugg"><div class="sugg-detail">{esc(s)}</div></div>')
A("</div></section>")
# Longest sessions
ls = stats.get("longest_sessions", [])
if ls:
A('<section class="card wide"><h2>Longest sessions</h2><table>')
A('<tr><th>Project</th><th>Started</th><th>Duration</th><th>Prompts</th><th>Cost</th></tr>')
for s in ls:
A(f'<tr><td>{esc(s["project"])}</td><td>{esc((s["start"] or "")[:16].replace("T"," "))}</td>'
f'<td>{s["duration_min"]:.0f} min</td><td>{s["user_msgs"]}</td><td>${s["cost"]:.2f}</td></tr>')
A("</table></section>")
CSS = """
:root { --bg:#0e0e14; --card:#181822; --fg:#e8e8f0; --muted:#9090a8; --accent:#7c5cff; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
.wrap { max-width:1080px; margin:0 auto; padding:32px 20px 80px; }
.hero { text-align:center; padding:24px 0 8px; }
.hero h1 { font-size:42px; margin:0; background:linear-gradient(90deg,#7c5cff,#21c7a8);
-webkit-background-clip:text; background-clip:text; color:transparent; }
.hero .sub { color:var(--muted); margin:4px 0 16px; }
.hero .summary { max-width:720px; margin:0 auto; font-size:17px; color:#d0d0e0; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
gap:14px; margin:28px 0; }
.card { background:var(--card); border:1px solid #25252f; border-radius:14px; padding:18px 20px; }
.card.wide { margin:18px 0; }
.card h2 { font-size:15px; text-transform:uppercase; letter-spacing:.06em;
color:var(--muted); margin:0 0 16px; font-weight:600; }
.stat { text-align:center; }
.stat-val { font-size:30px; font-weight:700; }
.stat-lab { color:var(--muted); font-size:13px; margin-top:4px; }
.stat-sub { color:#6a6a82; font-size:11px; margin-top:2px; }
.bars { display:flex; align-items:flex-end; gap:4px; }
.bar-col { flex:1; display:flex; flex-direction:column; align-items:center;
justify-content:flex-end; height:100%; }
.bar { width:100%; border-radius:4px 4px 0 0; min-height:2px; transition:opacity .15s; }
.bar:hover { opacity:.7; }
.bar-lab { font-size:10px; color:var(--muted); margin-top:6px; white-space:nowrap; }
table { width:100%; border-collapse:collapse; font-size:14px; }
th { text-align:left; color:var(--muted); font-weight:600; padding:8px 10px;
border-bottom:1px solid #2a2a38; font-size:12px; text-transform:uppercase; }
td { padding:8px 10px; border-bottom:1px solid #1f1f29; }
tr:hover td { background:#1d1d28; }
.narr ul { margin:0; padding-left:20px; } .narr li { margin:8px 0; }
.suggestions { display:grid; gap:12px; }
.sugg { background:#1f1f2b; border-left:3px solid var(--accent); border-radius:8px; padding:12px 16px; }
.sugg-title { font-weight:600; margin-bottom:4px; }
.sugg-detail { color:#c0c0d4; font-size:14px; }
"""
def render(stats, narr):
parts = build(stats, narr)
build_narrative(stats, narr, parts)
build_charts(stats, narr, parts)
body = "\n".join(parts)
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>pi insights</title><style>{CSS}</style></head>
<body><div class="wrap">{body}</div></body></html>"""
if __name__ == "__main__":
stats_path, narr_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
with open(stats_path) as f:
stats = json.load(f)
narr = {}
if narr_path and narr_path != "-" and os.path.exists(narr_path):
try:
with open(narr_path) as f:
narr = json.load(f)
except Exception:
narr = {}
html_out = render(stats, narr)
with open(out_path, "w") as f:
f.write(html_out)
print(out_path)