From ca9acbdffbc3f921078a8ddaa12c901e3da40435 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 18 Jun 2026 14:50:25 -0400 Subject: [PATCH] insights and other extension fixes --- .pi/agent/extensions/exit.ts | 16 +- .pi/agent/extensions/extensions | 1 + .pi/agent/extensions/insights/README.md | 37 ++++ .pi/agent/extensions/insights/analyze.py | 222 +++++++++++++++++++ .pi/agent/extensions/insights/index.ts | 174 +++++++++++++++ .pi/agent/extensions/insights/render.py | 240 ++++++++++++++++++++ .pi/agent/extensions/new-reminder.ts | 266 +++++++++++++++++++++++ Dockerfile | 3 +- 8 files changed, 955 insertions(+), 4 deletions(-) create mode 100644 .pi/agent/extensions/extensions create mode 100644 .pi/agent/extensions/insights/README.md create mode 100644 .pi/agent/extensions/insights/analyze.py create mode 100644 .pi/agent/extensions/insights/index.ts create mode 100644 .pi/agent/extensions/insights/render.py create mode 100644 .pi/agent/extensions/new-reminder.ts diff --git a/.pi/agent/extensions/exit.ts b/.pi/agent/extensions/exit.ts index beba0a7..c183015 100644 --- a/.pi/agent/extensions/exit.ts +++ b/.pi/agent/extensions/exit.ts @@ -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" }; } diff --git a/.pi/agent/extensions/extensions b/.pi/agent/extensions/extensions new file mode 100644 index 0000000..ee0286c --- /dev/null +++ b/.pi/agent/extensions/extensions @@ -0,0 +1 @@ +/workspace/dev_sandbox/.pi/agent/extensions \ No newline at end of file diff --git a/.pi/agent/extensions/insights/README.md b/.pi/agent/extensions/insights/README.md new file mode 100644 index 0000000..fc9a8c4 --- /dev/null +++ b/.pi/agent/extensions/insights/README.md @@ -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 diff --git a/.pi/agent/extensions/insights/analyze.py b/.pi/agent/extensions/insights/analyze.py new file mode 100644 index 0000000..9dc2a46 --- /dev/null +++ b/.pi/agent/extensions/insights/analyze.py @@ -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)) diff --git a/.pi/agent/extensions/insights/index.ts b/.pi/agent/extensions/insights/index.ts new file mode 100644 index 0000000..c649e0e --- /dev/null +++ b/.pi/agent/extensions/insights/index.ts @@ -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" + ); + }, + }); +} diff --git a/.pi/agent/extensions/insights/render.py b/.pi/agent/extensions/insights/render.py new file mode 100644 index 0000000..cd5124f --- /dev/null +++ b/.pi/agent/extensions/insights/render.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Render the insights HTML report from stats JSON + LLM narrative JSON. + +Usage: render.py +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'
{esc(lab)}
' + ) + return f'
' + "".join(cells) + "
" + + +def stat_card(label, value, sub=""): + sub_html = f'
{esc(sub)}
' if sub else "" + return ( + f'
{esc(value)}
' + f'
{esc(label)}
{sub_html}
' + ) + + +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('
') + A(f'

pi insights

') + A(f'

Last {stats["window_days"]} days · generated {esc(stats["generated_at"][:16].replace("T"," "))} UTC

') + if narr.get("summary"): + A(f'

{esc(narr["summary"])}

') + A("
") + + # Top stats + A('
') + 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("
") + 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('

Activity by hour of day

') + A(bar_chart(hours, hour_labels, "#7c5cff")) + A("
") + + # Activity by weekday + dow = stats["dow_histogram"] + A('

Activity by weekday

') + A(bar_chart(dow, DOW, "#21c7a8")) + A("
") + + # 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('

Sessions per day

') + A(bar_chart(vals, labs, "#f5a623")) + A("
") + + # Projects table + A('

Projects

') + A('') + for p in stats["projects"][:15]: + A(f'' + f'') + A("
ProjectSessionsPromptsTool callsTokensCost
{esc(p["project"])}{p["sessions"]}{p["user_msgs"]}{p["tool_calls"]}{fmt_num(p["tokens"])}${p["cost"]:.2f}
") + + # Tool usage + tu = stats["tool_usage"] + if tu: + names = [x["name"] for x in tu] + counts = [x["count"] for x in tu] + A('

Tool usage

') + A(bar_chart(counts, names, "#ff6b9d")) + A("
") + + # Models + mu = stats["model_usage"] + if mu: + A('

Models used

') + for m in mu: + short = m["model"].split("/")[-1] + A(f'') + A("
ModelSessions
{esc(short)}{m["sessions"]}
") + + +def build_narrative(stats, narr, parts): + A = parts.append + + if narr.get("highlights"): + A('

Highlights

    ') + for h in narr["highlights"]: + A(f"
  • {esc(h)}
  • ") + A("
") + + if narr.get("friction"): + A('

Friction patterns

    ') + for h in narr["friction"]: + A(f"
  • {esc(h)}
  • ") + A("
") + + if narr.get("suggestions"): + A('

Suggestions

') + for s in narr["suggestions"]: + if isinstance(s, dict): + A(f'
{esc(s.get("title",""))}
' + f'
{esc(s.get("detail",""))}
') + else: + A(f'
{esc(s)}
') + A("
") + + # Longest sessions + ls = stats.get("longest_sessions", []) + if ls: + A('

Longest sessions

') + A('') + for s in ls: + A(f'' + f'') + A("
ProjectStartedDurationPromptsCost
{esc(s["project"])}{esc((s["start"] or "")[:16].replace("T"," "))}{s["duration_min"]:.0f} min{s["user_msgs"]}${s["cost"]:.2f}
") + + +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""" + + +pi insights +
{body}
""" + + +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) diff --git a/.pi/agent/extensions/new-reminder.ts b/.pi/agent/extensions/new-reminder.ts new file mode 100644 index 0000000..738dd97 --- /dev/null +++ b/.pi/agent/extensions/new-reminder.ts @@ -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; +}; + +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 | 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\n${conversationText}\n`, + }, + ], + 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"); + } + }, + }); +} diff --git a/Dockerfile b/Dockerfile index f3650b7..b3b3c55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \