Compare commits

...

2 commits

Author SHA1 Message Date
Alex Selimov
ca9acbdffb insights and other extension fixes 2026-06-18 14:52:14 -04:00
Alex Selimov
8bd65f7313 Remove uneeded tools 2026-05-26 10:44:39 -04:00
8 changed files with 955 additions and 63 deletions

View file

@ -1,4 +1,4 @@
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
const QUICK_MARKER_TYPE = "quick-question-session";
@ -27,10 +27,20 @@ export default function (pi: ExtensionAPI) {
const marker = getQuickSessionMarker(ctx.sessionManager.getBranch());
if (marker) {
(ctx.sessionManager as unknown as { setSessionFile(path: string): void }).setSessionFile(marker.returnSession);
// switchSession reloads the session and refreshes the chat window, unlike the
// low-level setSessionFile which only swaps the file pointer. switchSession lives
// on ExtensionCommandContext; the input handler's ctx is command-capable at runtime.
const cmdCtx = ctx as unknown as ExtensionCommandContext;
ctx.ui.setStatus("quick-question", undefined);
ctx.ui.setWidget("quick-question", undefined);
ctx.ui.notify("Returned from quick session.", "info");
const result = await cmdCtx.switchSession(marker.returnSession, {
withSession: async (replacementCtx) => {
replacementCtx.ui.notify("Returned from quick session.", "info");
},
});
if (result.cancelled) {
ctx.ui.notify("Return to original session cancelled", "info");
}
return { action: "handled" };
}

View file

@ -0,0 +1 @@
/workspace/dev_sandbox/.pi/agent/extensions

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)

View file

@ -0,0 +1,266 @@
/**
* new-reminder
*
* Two features for managing long-running sessions:
*
* 1. A reminder widget above the editor that nudges you to start a fresh
* session (`/new`). It shows how long the session has been running and how
* many turns it has accumulated. When the session "runs long" (exceeds a
* configurable age or turn threshold) the box turns yellow.
*
* 2. `/snew [prompt]` - summarize the current chat, then start a brand new
* session seeded with the generated summary. If a prompt is provided, the
* summary is prepended to it and the combined prompt is auto-submitted in
* the new session. If no prompt is provided, the summary lands in the
* editor as an editable draft and waits for user input.
*/
import { complete, type Message } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
import type { Component, TUI } from "@earendil-works/pi-tui";
// ---------------------------------------------------------------------------
// Tunables
// ---------------------------------------------------------------------------
/** Session is considered "long" once estimated context exceeds this many tokens. */
const TOKEN_THRESHOLD = 250_000;
/** ...or after this many user turns, whichever comes first. */
const TURN_THRESHOLD = 25;
/** Widget refresh cadence (ms). Keeps the elapsed-time readout current. */
const TICK_MS = 30_000;
// ---------------------------------------------------------------------------
// Conversation extraction helpers (for /snew)
// ---------------------------------------------------------------------------
type ContentBlock = {
type?: string;
text?: string;
name?: string;
arguments?: Record<string, unknown>;
};
function extractText(content: unknown): string[] {
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
const out: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") continue;
const block = part as ContentBlock;
if (block.type === "text" && typeof block.text === "string") out.push(block.text);
}
return out;
}
function buildConversationText(branch: SessionEntry[]): string {
const sections: string[] = [];
for (const entry of branch) {
if (entry.type === "compaction") {
const summary = (entry as { summary?: string }).summary;
if (summary) sections.push(`Previous summary:\n${summary}`);
continue;
}
if (entry.type !== "message") continue;
const msg = entry.message as { role?: string; content?: unknown } | undefined;
if (!msg?.role) continue;
if (msg.role !== "user" && msg.role !== "assistant") continue;
const text = extractText(msg.content).join("\n").trim();
if (text.length === 0) continue;
const label = msg.role === "user" ? "User" : "Assistant";
sections.push(`${label}: ${text}`);
}
return sections.join("\n\n");
}
const SUMMARY_SYSTEM_PROMPT = `You are a context transfer assistant. Summarize the conversation so it can be resumed in a new, self-contained thread. Capture goals, key decisions, approaches taken, important findings, files touched, and open questions. Be concise and structured with short headings. Output only the summary, with no preamble.`;
// ---------------------------------------------------------------------------
// Reminder widget
// ---------------------------------------------------------------------------
function formatTokens(tokens: number): string {
if (tokens < 1000) return `${tokens}`;
return `${(tokens / 1000).toFixed(tokens < 10_000 ? 1 : 0)}k`;
}
export default function (pi: ExtensionAPI) {
// --- shared state for the reminder widget ---
let turnCount = 0;
let activeTui: TUI | undefined;
let activeCtx: ExtensionContext | undefined;
let ticker: ReturnType<typeof setInterval> | undefined;
/** Estimated context tokens for the active model, or null if unknown. */
const contextTokens = (): number | null => activeCtx?.getContextUsage()?.tokens ?? null;
const isLong = () => {
const tokens = contextTokens();
return (tokens !== null && tokens >= TOKEN_THRESHOLD) || turnCount >= TURN_THRESHOLD;
};
const stopTicker = () => {
if (ticker) {
clearInterval(ticker);
ticker = undefined;
}
};
// Build a bordered box component. Yellow ("warning") when the session is long,
// muted otherwise.
const makeWidget = (tui: TUI, theme: Theme): Component => {
activeTui = tui;
return {
render(width: number): string[] {
const long = isLong();
const colorKey = long ? "warning" : "muted";
const color = (s: string) => theme.fg(colorKey, s);
const tokens = contextTokens();
const icon = long ? "\u26a0" : "\u23f1"; // warning sign / stopwatch
const lead = long
? `${icon} Long session \u2014 consider /new`
: `${icon} Session`;
const ctxStat = tokens === null ? "ctx ?" : `${formatTokens(tokens)} ctx`;
const stats = `${ctxStat} \u00b7 ${turnCount} turn${turnCount === 1 ? "" : "s"}`;
const label = ` ${lead} \u00b7 ${stats} `;
const inner = Math.max(0, width - 2);
const truncated = label.length > inner ? label.slice(0, inner) : label;
const pad = inner - truncated.length;
const top = color("\u256d" + "\u2500".repeat(inner) + "\u256e");
const mid = color("\u2502") + (long ? theme.bold(theme.fg("warning", truncated)) : color(truncated)) + " ".repeat(pad) + color("\u2502");
const bot = color("\u2570" + "\u2500".repeat(inner) + "\u256f");
return [top, mid, bot];
},
invalidate() {},
};
};
const startReminder = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
activeCtx = ctx;
ctx.ui.setWidget("new-reminder", (tui, theme) => makeWidget(tui, theme), { placement: "aboveEditor" });
stopTicker();
ticker = setInterval(() => activeTui?.requestRender(), TICK_MS);
};
pi.on("session_start", (_event, ctx) => {
// Reset the turn counter for the (possibly new/resumed) session.
turnCount = 0;
startReminder(ctx);
});
pi.on("agent_start", () => {
turnCount += 1;
activeTui?.requestRender();
});
pi.on("session_shutdown", () => {
stopTicker();
activeTui = undefined;
activeCtx = undefined;
});
// -----------------------------------------------------------------------
// /snew - summarize then start a new session seeded with summary + prompt
// -----------------------------------------------------------------------
pi.registerCommand("snew", {
description: "Summarize the current chat, then start a new session. With a prompt: auto-submit summary+prompt. Without: seed the editor and wait.",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/snew requires interactive mode", "error");
return;
}
const userPrompt = args.trim();
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
await ctx.waitForIdle();
const conversationText = buildConversationText(ctx.sessionManager.getBranch());
if (!conversationText.trim()) {
ctx.ui.notify("No conversation to summarize", "warning");
return;
}
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
if (!auth.ok || !auth.apiKey) {
ctx.ui.notify(auth.ok ? `No API key for ${ctx.model.provider}` : auth.error, "error");
return;
}
ctx.ui.notify("Summarizing current chat...", "info");
const summaryMessages: Message[] = [
{
role: "user",
content: [
{
type: "text",
text: `Summarize this conversation for handoff to a new session.\n\n<conversation>\n${conversationText}\n</conversation>`,
},
],
timestamp: Date.now(),
},
];
let summary: string;
try {
const response = await complete(
ctx.model,
{ systemPrompt: SUMMARY_SYSTEM_PROMPT, messages: summaryMessages },
{ apiKey: auth.apiKey, headers: auth.headers, reasoningEffort: "low", signal: ctx.signal },
);
summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n")
.trim();
} catch (err) {
ctx.ui.notify(`Summary failed: ${(err as Error).message}`, "error");
return;
}
if (!summary) {
ctx.ui.notify("Empty summary; aborting", "warning");
return;
}
const parentSession = ctx.sessionManager.getSessionFile();
if (userPrompt) {
// Prompt provided: combine summary + prompt and auto-submit.
const combinedPrompt = `## Context from previous session\n\n${summary}\n\n---\n\n## Task\n\n${userPrompt}`;
const result = await ctx.newSession({
parentSession,
withSession: async (replacementCtx) => {
await replacementCtx.sendUserMessage(combinedPrompt);
},
});
if (result.cancelled) {
ctx.ui.notify("New session cancelled", "info");
}
return;
}
// No prompt: seed the editor with just the summary and wait for input.
const draftPrompt = `## Context from previous session\n\n${summary}\n\n---\n\n## Task\n\n`;
const result = await ctx.newSession({
parentSession,
withSession: async (replacementCtx) => {
replacementCtx.ui.setEditorText(draftPrompt);
replacementCtx.ui.notify("New session seeded with summary. Add your prompt and submit.", "info");
},
});
if (result.cancelled) {
ctx.ui.notify("New session cancelled", "info");
}
},
});
}

View file

@ -1,4 +1,4 @@
FROM debian:bookworm-slim
FROM --platform=linux/arm64 debian:bookworm-slim
ARG PYTHON_VERSION=3.11
ARG NODE_MAJOR=22
@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ssh \
make \
sudo \
build-essential \
unzip \
jq \
@ -61,12 +62,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ln -sf /usr/bin/python3 /usr/bin/python \
&& rm -rf /var/lib/apt/lists/*
# Poetry (official installer avoids PyPI timeout issues)
ENV POETRY_HOME="/opt/poetry"
ENV PATH="${POETRY_HOME}/bin:${PATH}"
RUN curl -sSL https://install.python-poetry.org | python3 - && \
poetry config virtualenvs.in-project true
# uv (fast Python package/project manager from Astral)
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
@ -79,57 +74,18 @@ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# Java 17 (Eclipse Temurin)
RUN curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg && \
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
apt-get update && apt-get install -y --no-install-recommends temurin-${JAVA_VERSION}-jdk && \
rm -rf /var/lib/apt/lists/*
# Gradle
RUN curl -fsSL https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o /tmp/gradle.zip && \
unzip -d /opt /tmp/gradle.zip && \
ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle && \
rm /tmp/gradle.zip
# Go
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz | tar -C /usr/local -xz
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
# Docker CLI (client only, expects host docker socket mount)
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && apt-get install -y --no-install-recommends docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
# kubectl
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl -o /usr/local/bin/kubectl && \
chmod +x /usr/local/bin/kubectl
# yq (mikefarah)
ARG YQ_VERSION=v4.44.3
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
chmod +x /usr/local/bin/yq
# Helm
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# AWS CLI v2
RUN ARCH=$(uname -m) && \
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
unzip -q /tmp/awscli.zip -d /tmp && \
/tmp/aws/install && \
rm -rf /tmp/aws /tmp/awscli.zip
# Terraform
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip" -o /tmp/terraform.zip && \
unzip /tmp/terraform.zip -d /usr/local/bin && \
rm /tmp/terraform.zip
# Zig
RUN ARCH=$(uname -m) && \
case "$ARCH" in \
@ -143,14 +99,6 @@ RUN ARCH=$(uname -m) && \
ln -s "/opt/zig-${ZIG_VERSION}/zig" /usr/local/bin/zig && \
rm /tmp/zig.tar.xz
# Atlassian CLI (acli)
RUN mkdir -p -m 755 /etc/apt/keyrings && \
wget -nv -O- https://acli.atlassian.com/gpg/public-key.asc | gpg --dearmor -o /etc/apt/keyrings/acli-archive-keyring.gpg && \
chmod go+r /etc/apt/keyrings/acli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/acli-archive-keyring.gpg] https://acli.atlassian.com/linux/deb stable main" > /etc/apt/sources.list.d/acli.list && \
apt-get update && apt-get install -y acli && \
rm -rf /var/lib/apt/lists/*
# GitHub CLI (gh)
RUN mkdir -p -m 755 /etc/apt/keyrings && \
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg && \
@ -179,12 +127,6 @@ RUN git clone https://forge.alexselimov.com/aselimov/SelimovDE.git /root/Selimov
git submodule update && \
./deploy.sh
# AWS config (optional, only applied if aws-config exists in build context)
COPY Dockerfile aws-config* /tmp/build-context/
RUN if [ -f /tmp/build-context/aws-config ]; then \
mkdir -p /root/.aws && mv /tmp/build-context/aws-config /root/.aws/config; \
fi && rm -rf /tmp/build-context
# Global gitignore: lives on the host at ~/repos/dev_sandbox/.gitignore_global
# (bind-mounted to /workspace/dev_sandbox/.gitignore_global). Symlinked into
# place by entrypoint.sh so host edits take effect without a rebuild.