Compare commits
No commits in common. "ca9acbdffbc3f921078a8ddaa12c901e3da40435" and "411be9ab83f420e52a5646b2e9eccb47e852dbdd" have entirely different histories.
ca9acbdffb
...
411be9ab83
8 changed files with 63 additions and 955 deletions
|
|
@ -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";
|
const QUICK_MARKER_TYPE = "quick-question-session";
|
||||||
|
|
||||||
|
|
@ -27,20 +27,10 @@ export default function (pi: ExtensionAPI) {
|
||||||
|
|
||||||
const marker = getQuickSessionMarker(ctx.sessionManager.getBranch());
|
const marker = getQuickSessionMarker(ctx.sessionManager.getBranch());
|
||||||
if (marker) {
|
if (marker) {
|
||||||
// switchSession reloads the session and refreshes the chat window, unlike the
|
(ctx.sessionManager as unknown as { setSessionFile(path: string): void }).setSessionFile(marker.returnSession);
|
||||||
// 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.setStatus("quick-question", undefined);
|
||||||
ctx.ui.setWidget("quick-question", undefined);
|
ctx.ui.setWidget("quick-question", undefined);
|
||||||
const result = await cmdCtx.switchSession(marker.returnSession, {
|
ctx.ui.notify("Returned from quick session.", "info");
|
||||||
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" };
|
return { action: "handled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/workspace/dev_sandbox/.pi/agent/extensions
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
#!/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 · 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)
|
|
||||||
|
|
@ -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<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");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
62
Dockerfile
62
Dockerfile
|
|
@ -1,4 +1,4 @@
|
||||||
FROM --platform=linux/arm64 debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
ARG PYTHON_VERSION=3.11
|
ARG PYTHON_VERSION=3.11
|
||||||
ARG NODE_MAJOR=22
|
ARG NODE_MAJOR=22
|
||||||
|
|
@ -19,7 +19,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
ssh \
|
ssh \
|
||||||
make \
|
make \
|
||||||
sudo \
|
|
||||||
build-essential \
|
build-essential \
|
||||||
unzip \
|
unzip \
|
||||||
jq \
|
jq \
|
||||||
|
|
@ -62,6 +61,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& ln -sf /usr/bin/python3 /usr/bin/python \
|
&& ln -sf /usr/bin/python3 /usr/bin/python \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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)
|
# 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
|
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 && \
|
apt-get install -y --no-install-recommends nodejs && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
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)
|
# 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 && \
|
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 && \
|
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 && \
|
apt-get update && apt-get install -y --no-install-recommends docker-ce-cli && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
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)
|
# yq (mikefarah)
|
||||||
ARG YQ_VERSION=v4.44.3
|
ARG YQ_VERSION=v4.44.3
|
||||||
RUN ARCH=$(dpkg --print-architecture) && \
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
|
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
|
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
|
# Zig
|
||||||
RUN ARCH=$(uname -m) && \
|
RUN ARCH=$(uname -m) && \
|
||||||
case "$ARCH" in \
|
case "$ARCH" in \
|
||||||
|
|
@ -99,6 +143,14 @@ RUN ARCH=$(uname -m) && \
|
||||||
ln -s "/opt/zig-${ZIG_VERSION}/zig" /usr/local/bin/zig && \
|
ln -s "/opt/zig-${ZIG_VERSION}/zig" /usr/local/bin/zig && \
|
||||||
rm /tmp/zig.tar.xz
|
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)
|
# GitHub CLI (gh)
|
||||||
RUN mkdir -p -m 755 /etc/apt/keyrings && \
|
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 && \
|
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 && \
|
git submodule update && \
|
||||||
./deploy.sh
|
./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
|
# Global gitignore: lives on the host at ~/repos/dev_sandbox/.gitignore_global
|
||||||
# (bind-mounted to /workspace/dev_sandbox/.gitignore_global). Symlinked into
|
# (bind-mounted to /workspace/dev_sandbox/.gitignore_global). Symlinked into
|
||||||
# place by entrypoint.sh so host edits take effect without a rebuild.
|
# place by entrypoint.sh so host edits take effect without a rebuild.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue