diff --git a/.pi/agent/extensions/exit.ts b/.pi/agent/extensions/exit.ts index c183015..beba0a7 100644 --- a/.pi/agent/extensions/exit.ts +++ b/.pi/agent/extensions/exit.ts @@ -1,4 +1,4 @@ -import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent"; const QUICK_MARKER_TYPE = "quick-question-session"; @@ -27,20 +27,10 @@ export default function (pi: ExtensionAPI) { const marker = getQuickSessionMarker(ctx.sessionManager.getBranch()); if (marker) { - // 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.sessionManager as unknown as { setSessionFile(path: string): void }).setSessionFile(marker.returnSession); ctx.ui.setStatus("quick-question", undefined); ctx.ui.setWidget("quick-question", undefined); - 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"); - } + ctx.ui.notify("Returned from quick session.", "info"); return { action: "handled" }; } diff --git a/.pi/agent/extensions/extensions b/.pi/agent/extensions/extensions deleted file mode 100644 index ee0286c..0000000 --- a/.pi/agent/extensions/extensions +++ /dev/null @@ -1 +0,0 @@ -/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 deleted file mode 100644 index fc9a8c4..0000000 --- a/.pi/agent/extensions/insights/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# /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 deleted file mode 100644 index 9dc2a46..0000000 --- a/.pi/agent/extensions/insights/analyze.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/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 deleted file mode 100644 index c649e0e..0000000 --- a/.pi/agent/extensions/insights/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index cd5124f..0000000 --- a/.pi/agent/extensions/insights/render.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/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 deleted file mode 100644 index 738dd97..0000000 --- a/.pi/agent/extensions/new-reminder.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * 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 b3b3c55..e8b3481 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=linux/arm64 debian:bookworm-slim +FROM debian:bookworm-slim ARG PYTHON_VERSION=3.11 ARG NODE_MAJOR=22 @@ -19,7 +19,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ ssh \ make \ - sudo \ build-essential \ unzip \ jq \ @@ -62,6 +61,12 @@ 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 @@ -74,18 +79,57 @@ 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 \ @@ -99,6 +143,14 @@ 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 && \ @@ -127,6 +179,12 @@ 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.