insights and other extension fixes
This commit is contained in:
parent
8bd65f7313
commit
ca9acbdffb
8 changed files with 955 additions and 4 deletions
|
|
@ -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";
|
const QUICK_MARKER_TYPE = "quick-question-session";
|
||||||
|
|
||||||
|
|
@ -27,10 +27,20 @@ export default function (pi: ExtensionAPI) {
|
||||||
|
|
||||||
const marker = getQuickSessionMarker(ctx.sessionManager.getBranch());
|
const marker = getQuickSessionMarker(ctx.sessionManager.getBranch());
|
||||||
if (marker) {
|
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.setStatus("quick-question", undefined);
|
||||||
ctx.ui.setWidget("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" };
|
return { action: "handled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
.pi/agent/extensions/extensions
Normal file
1
.pi/agent/extensions/extensions
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/workspace/dev_sandbox/.pi/agent/extensions
|
||||||
37
.pi/agent/extensions/insights/README.md
Normal file
37
.pi/agent/extensions/insights/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# /insights
|
||||||
|
|
||||||
|
A pi extension that analyzes your recent pi session history and generates an
|
||||||
|
interactive HTML usage report — like Claude Code's `/insights`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/insights # analyze the last 30 days
|
||||||
|
/insights 7 # analyze the last 7 days
|
||||||
|
```
|
||||||
|
|
||||||
|
The report opens in your browser and is saved to:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.pi/agent/usage-data/report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. `analyze.py` walks `~/.pi/agent/sessions/*/*.jsonl` and computes deterministic
|
||||||
|
stats (sessions, prompts, tool calls, tokens, cost, per-project breakdown,
|
||||||
|
activity by hour/weekday/day, longest/costliest sessions). No LLM, no tokens.
|
||||||
|
2. The command feeds those stats to the model, which writes a qualitative
|
||||||
|
narrative (summary, highlights, friction patterns, suggestions) by calling the
|
||||||
|
`write_insights_report` tool.
|
||||||
|
3. `render.py` combines the stats + narrative into a self-contained HTML report
|
||||||
|
and opens it.
|
||||||
|
|
||||||
|
Intermediate files (`stats.json`, `narrative.json`) are also written to
|
||||||
|
`~/.pi/agent/usage-data/` for inspection.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `index.ts` — extension entry point (command + tool)
|
||||||
|
- `analyze.py` — deterministic session analytics → JSON
|
||||||
|
- `render.py` — stats + narrative JSON → HTML report
|
||||||
222
.pi/agent/extensions/insights/analyze.py
Normal file
222
.pi/agent/extensions/insights/analyze.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic session analytics for the /insights command.
|
||||||
|
|
||||||
|
Walks ~/.pi/agent/sessions, computes usage stats over a window of days,
|
||||||
|
and prints a JSON summary on stdout. No LLM involved here.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
import collections
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
SESSIONS_DIR = os.path.expanduser("~/.pi/agent/sessions")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ts(s):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def project_name(cwd):
|
||||||
|
if not cwd:
|
||||||
|
return "(unknown)"
|
||||||
|
return os.path.basename(cwd.rstrip("/")) or cwd
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(days=30):
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
|
files = glob.glob(os.path.join(SESSIONS_DIR, "*", "*.jsonl"))
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
tool_counts = collections.Counter()
|
||||||
|
model_counts = collections.Counter()
|
||||||
|
project_stats = collections.defaultdict(lambda: {
|
||||||
|
"sessions": 0, "user_msgs": 0, "assistant_msgs": 0,
|
||||||
|
"tool_calls": 0, "cost": 0.0, "tokens": 0,
|
||||||
|
})
|
||||||
|
hour_hist = collections.Counter()
|
||||||
|
dow_hist = collections.Counter()
|
||||||
|
day_hist = collections.Counter()
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"sessions": 0, "user_msgs": 0, "assistant_msgs": 0, "tool_results": 0,
|
||||||
|
"tool_calls": 0, "cost": 0.0, "input_tokens": 0, "output_tokens": 0,
|
||||||
|
"cache_read": 0, "cache_write": 0, "total_tokens": 0,
|
||||||
|
"compactions": 0, "branches": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for fpath in files:
|
||||||
|
header = None
|
||||||
|
first_ts = None
|
||||||
|
last_ts = None
|
||||||
|
s_user = s_asst = s_tools = s_toolres = 0
|
||||||
|
s_cost = 0.0
|
||||||
|
s_tokens = 0
|
||||||
|
s_compactions = 0
|
||||||
|
s_branches = 0
|
||||||
|
s_models = set()
|
||||||
|
s_tool_counter = collections.Counter()
|
||||||
|
cwd = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(fpath, "r", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
e = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
et = e.get("type")
|
||||||
|
if et == "session":
|
||||||
|
header = e
|
||||||
|
cwd = e.get("cwd")
|
||||||
|
first_ts = parse_ts(e.get("timestamp"))
|
||||||
|
continue
|
||||||
|
ts = parse_ts(e.get("timestamp"))
|
||||||
|
if ts:
|
||||||
|
if first_ts is None:
|
||||||
|
first_ts = ts
|
||||||
|
last_ts = ts
|
||||||
|
if et == "compaction":
|
||||||
|
s_compactions += 1
|
||||||
|
elif et == "branch_summary":
|
||||||
|
s_branches += 1
|
||||||
|
elif et == "message":
|
||||||
|
msg = e.get("message", {})
|
||||||
|
role = msg.get("role")
|
||||||
|
if role == "user":
|
||||||
|
s_user += 1
|
||||||
|
elif role == "assistant":
|
||||||
|
s_asst += 1
|
||||||
|
if msg.get("model"):
|
||||||
|
s_models.add(msg["model"])
|
||||||
|
u = msg.get("usage") or {}
|
||||||
|
s_cost += (u.get("cost") or {}).get("total", 0) or 0
|
||||||
|
s_tokens += u.get("totalTokens", 0) or 0
|
||||||
|
totals["input_tokens"] += u.get("input", 0) or 0
|
||||||
|
totals["output_tokens"] += u.get("output", 0) or 0
|
||||||
|
totals["cache_read"] += u.get("cacheRead", 0) or 0
|
||||||
|
totals["cache_write"] += u.get("cacheWrite", 0) or 0
|
||||||
|
for c in msg.get("content", []):
|
||||||
|
if isinstance(c, dict) and c.get("type") == "toolCall":
|
||||||
|
name = c.get("name", "?")
|
||||||
|
s_tools += 1
|
||||||
|
s_tool_counter[name] += 1
|
||||||
|
elif role == "toolResult":
|
||||||
|
s_toolres += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Window filter: use session start time
|
||||||
|
anchor = first_ts or last_ts
|
||||||
|
if anchor is None or anchor < cutoff:
|
||||||
|
continue
|
||||||
|
|
||||||
|
proj = project_name(cwd)
|
||||||
|
ps = project_stats[proj]
|
||||||
|
ps["sessions"] += 1
|
||||||
|
ps["user_msgs"] += s_user
|
||||||
|
ps["assistant_msgs"] += s_asst
|
||||||
|
ps["tool_calls"] += s_tools
|
||||||
|
ps["cost"] += s_cost
|
||||||
|
ps["tokens"] += s_tokens
|
||||||
|
|
||||||
|
for name, n in s_tool_counter.items():
|
||||||
|
tool_counts[name] += n
|
||||||
|
for m in s_models:
|
||||||
|
model_counts[m] += 1
|
||||||
|
|
||||||
|
if anchor:
|
||||||
|
local = anchor.astimezone()
|
||||||
|
hour_hist[local.hour] += 1
|
||||||
|
dow_hist[local.weekday()] += 1
|
||||||
|
day_hist[local.strftime("%Y-%m-%d")] += 1
|
||||||
|
|
||||||
|
duration_min = None
|
||||||
|
if first_ts and last_ts and last_ts > first_ts:
|
||||||
|
duration_min = round((last_ts - first_ts).total_seconds() / 60.0, 1)
|
||||||
|
|
||||||
|
sessions.append({
|
||||||
|
"file": os.path.basename(fpath),
|
||||||
|
"project": proj,
|
||||||
|
"cwd": cwd,
|
||||||
|
"start": anchor.isoformat() if anchor else None,
|
||||||
|
"duration_min": duration_min,
|
||||||
|
"user_msgs": s_user,
|
||||||
|
"assistant_msgs": s_asst,
|
||||||
|
"tool_calls": s_tools,
|
||||||
|
"tool_results": s_toolres,
|
||||||
|
"cost": round(s_cost, 4),
|
||||||
|
"tokens": s_tokens,
|
||||||
|
"compactions": s_compactions,
|
||||||
|
"branches": s_branches,
|
||||||
|
"models": sorted(s_models),
|
||||||
|
})
|
||||||
|
|
||||||
|
totals["sessions"] += 1
|
||||||
|
totals["user_msgs"] += s_user
|
||||||
|
totals["assistant_msgs"] += s_asst
|
||||||
|
totals["tool_results"] += s_toolres
|
||||||
|
totals["tool_calls"] += s_tools
|
||||||
|
totals["cost"] += s_cost
|
||||||
|
totals["total_tokens"] += s_tokens
|
||||||
|
totals["compactions"] += s_compactions
|
||||||
|
totals["branches"] += s_branches
|
||||||
|
|
||||||
|
totals["cost"] = round(totals["cost"], 4)
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for name, ps in project_stats.items():
|
||||||
|
projects.append({
|
||||||
|
"project": name,
|
||||||
|
"sessions": ps["sessions"],
|
||||||
|
"user_msgs": ps["user_msgs"],
|
||||||
|
"assistant_msgs": ps["assistant_msgs"],
|
||||||
|
"tool_calls": ps["tool_calls"],
|
||||||
|
"cost": round(ps["cost"], 4),
|
||||||
|
"tokens": ps["tokens"],
|
||||||
|
})
|
||||||
|
projects.sort(key=lambda p: p["sessions"], reverse=True)
|
||||||
|
|
||||||
|
durations = [s["duration_min"] for s in sessions if s["duration_min"]]
|
||||||
|
avg_duration = round(sum(durations) / len(durations), 1) if durations else None
|
||||||
|
|
||||||
|
active_days = len(day_hist)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_days": days,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"totals": totals,
|
||||||
|
"active_days": active_days,
|
||||||
|
"avg_session_duration_min": avg_duration,
|
||||||
|
"projects": projects,
|
||||||
|
"tool_usage": [{"name": k, "count": v} for k, v in tool_counts.most_common()],
|
||||||
|
"model_usage": [{"model": k, "sessions": v} for k, v in model_counts.most_common()],
|
||||||
|
"hour_histogram": [hour_hist.get(h, 0) for h in range(24)],
|
||||||
|
"dow_histogram": [dow_hist.get(d, 0) for d in range(7)],
|
||||||
|
"daily_histogram": dict(sorted(day_hist.items())),
|
||||||
|
"longest_sessions": sorted(
|
||||||
|
[s for s in sessions if s["duration_min"]],
|
||||||
|
key=lambda s: s["duration_min"], reverse=True
|
||||||
|
)[:10],
|
||||||
|
"costliest_sessions": sorted(sessions, key=lambda s: s["cost"], reverse=True)[:10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
days = 30
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
days = int(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
print(json.dumps(analyze(days), indent=2))
|
||||||
174
.pi/agent/extensions/insights/index.ts
Normal file
174
.pi/agent/extensions/insights/index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const ANALYZE = path.join(EXT_DIR, "analyze.py");
|
||||||
|
const RENDER = path.join(EXT_DIR, "render.py");
|
||||||
|
const OUT_DIR = path.join(os.homedir(), ".pi", "agent", "usage-data");
|
||||||
|
const STATS_PATH = path.join(OUT_DIR, "stats.json");
|
||||||
|
const NARR_PATH = path.join(OUT_DIR, "narrative.json");
|
||||||
|
// The report is written to the shared /workspace mount so it's accessible
|
||||||
|
// from the host machine.
|
||||||
|
const REPORT_DIR = "/workspace";
|
||||||
|
const REPORT_PATH = path.join(REPORT_DIR, "pi-insights-report.html");
|
||||||
|
|
||||||
|
// /workspace inside the sandbox maps to this path on the host machine.
|
||||||
|
const SANDBOX_MOUNT = "/workspace";
|
||||||
|
const HOST_MOUNT = "/Users/aselimov/rescale";
|
||||||
|
|
||||||
|
// Return a clickable file:// URL for the report instead of launching a
|
||||||
|
// browser. Spawning an opener (xdg-open/open) is unreliable in headless
|
||||||
|
// sandboxes and a missing binary surfaces as an uncaught async error event.
|
||||||
|
// The URL is rewritten to the host path so it's clickable from the user's
|
||||||
|
// real computer rather than referencing the container filesystem.
|
||||||
|
function reportUrl(file: string) {
|
||||||
|
const hostPath = file.startsWith(SANDBOX_MOUNT)
|
||||||
|
? HOST_MOUNT + file.slice(SANDBOX_MOUNT.length)
|
||||||
|
: file;
|
||||||
|
return pathToFileURL(hostPath).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReport() {
|
||||||
|
execFileSync("python3", [RENDER, STATS_PATH, NARR_PATH, REPORT_PATH], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
// Tool the LLM calls to supply the qualitative narrative.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "write_insights_report",
|
||||||
|
label: "Write Insights Report",
|
||||||
|
description:
|
||||||
|
"Write the qualitative narrative for the pi usage insights report, then " +
|
||||||
|
"render and open the final HTML. Call this exactly once after analyzing the stats.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
summary: Type.String({
|
||||||
|
description: "One or two sentence overview of the user's pi usage.",
|
||||||
|
}),
|
||||||
|
highlights: Type.Array(Type.String(), {
|
||||||
|
description: "Notable facts about how they use pi (3-6 items).",
|
||||||
|
}),
|
||||||
|
friction: Type.Array(Type.String(), {
|
||||||
|
description: "Observed friction patterns or inefficiencies (0-5 items).",
|
||||||
|
}),
|
||||||
|
suggestions: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
title: Type.String(),
|
||||||
|
detail: Type.String(),
|
||||||
|
}),
|
||||||
|
{ description: "Concrete, actionable workflow suggestions (2-5 items)." }
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_id, params) {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(NARR_PATH, JSON.stringify(params, null, 2));
|
||||||
|
renderReport();
|
||||||
|
const url = reportUrl(REPORT_PATH);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Insights report written. Open it here:\n${url}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { reportPath: REPORT_PATH, url },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("insights", {
|
||||||
|
description: "Analyze your recent pi sessions and generate an insights report (optional: number of days, default 30)",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const days = (args || "").trim().match(/^\d+$/) ? args.trim() : "30";
|
||||||
|
ctx.ui.setStatus("insights", "Analyzing sessions…");
|
||||||
|
|
||||||
|
let statsRaw: string;
|
||||||
|
try {
|
||||||
|
statsRaw = execFileSync("python3", [ANALYZE, days], { encoding: "utf-8" });
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.ui.setStatus("insights", "");
|
||||||
|
ctx.ui.notify(`insights: analysis failed: ${e?.message ?? e}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(STATS_PATH, statsRaw);
|
||||||
|
|
||||||
|
let stats: any;
|
||||||
|
try {
|
||||||
|
stats = JSON.parse(statsRaw);
|
||||||
|
} catch {
|
||||||
|
ctx.ui.setStatus("insights", "");
|
||||||
|
ctx.ui.notify("insights: could not parse analyzer output", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.setStatus("insights", "");
|
||||||
|
|
||||||
|
if (!stats?.totals?.sessions) {
|
||||||
|
// Render a stats-only report (no LLM narrative needed).
|
||||||
|
if (fs.existsSync(NARR_PATH)) fs.rmSync(NARR_PATH);
|
||||||
|
renderReport();
|
||||||
|
ctx.ui.notify(
|
||||||
|
`No sessions found in the last ${days} days. Empty report: ${reportUrl(REPORT_PATH)}`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = stats.totals;
|
||||||
|
const topProjects = (stats.projects || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((p: any) => `${p.project} (${p.sessions} sessions, ${p.tool_calls} tool calls)`)
|
||||||
|
.join(", ");
|
||||||
|
const topTools = (stats.tool_usage || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((x: any) => `${x.name}=${x.count}`)
|
||||||
|
.join(", ");
|
||||||
|
const peakHours = (stats.hour_histogram || [])
|
||||||
|
.map((c: number, h: number) => ({ h, c }))
|
||||||
|
.sort((a: any, b: any) => b.c - a.c)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((x: any) => `${x.h}:00`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
"You are generating a pi usage insights report (like Claude Code's /insights).",
|
||||||
|
"Below are deterministic statistics computed from the user's pi session history",
|
||||||
|
`over the last ${days} days. Analyze them and then call the`,
|
||||||
|
"`write_insights_report` tool exactly once with a concise narrative.",
|
||||||
|
"Be specific, reference the actual numbers, and make suggestions actionable",
|
||||||
|
"(e.g. /compact usage, AGENTS.md rules, prompt templates, model choice,",
|
||||||
|
"running sessions inside the right project dir). Do not write any prose to the",
|
||||||
|
"chat beyond a one-line confirmation; put everything in the tool call.",
|
||||||
|
"",
|
||||||
|
"KEY STATS:",
|
||||||
|
`- Sessions: ${t.sessions} across ${stats.active_days} active days`,
|
||||||
|
`- Prompts: ${t.user_msgs}, assistant turns: ${t.assistant_msgs}, tool calls: ${t.tool_calls}`,
|
||||||
|
`- Tokens: ${t.total_tokens} (cacheRead ${t.cache_read}), cost: $${t.cost}`,
|
||||||
|
`- Avg session duration: ${stats.avg_session_duration_min ?? "n/a"} min`,
|
||||||
|
`- Compactions: ${t.compactions}, branches: ${t.branches}`,
|
||||||
|
`- Top projects: ${topProjects}`,
|
||||||
|
`- Tool usage: ${topTools}`,
|
||||||
|
`- Peak hours: ${peakHours}`,
|
||||||
|
"",
|
||||||
|
"Full JSON (for reference):",
|
||||||
|
"```json",
|
||||||
|
JSON.stringify(stats),
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
pi.sendUserMessage(prompt);
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Analyzed ${t.sessions} sessions. Generating insights…`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
240
.pi/agent/extensions/insights/render.py
Normal file
240
.pi/agent/extensions/insights/render.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render the insights HTML report from stats JSON + LLM narrative JSON.
|
||||||
|
|
||||||
|
Usage: render.py <stats.json> <narrative.json> <out.html>
|
||||||
|
narrative.json is optional (pass "-" to skip). It may contain:
|
||||||
|
{ "summary": str, "highlights": [str], "suggestions": [{"title","detail"}],
|
||||||
|
"friction": [str] }
|
||||||
|
"""
|
||||||
|
import sys, os, json, html
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s):
|
||||||
|
return html.escape(str(s if s is not None else ""))
|
||||||
|
|
||||||
|
|
||||||
|
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
|
|
||||||
|
def bar_chart(values, labels, color="#7c5cff", height=120):
|
||||||
|
mx = max(values) if values and max(values) > 0 else 1
|
||||||
|
cells = []
|
||||||
|
for v, lab in zip(values, labels):
|
||||||
|
h = int((v / mx) * height) if mx else 0
|
||||||
|
cells.append(
|
||||||
|
f'<div class="bar-col"><div class="bar" style="height:{h}px;background:{color}" '
|
||||||
|
f'title="{esc(lab)}: {v}"></div><div class="bar-lab">{esc(lab)}</div></div>'
|
||||||
|
)
|
||||||
|
return f'<div class="bars" style="height:{height+24}px">' + "".join(cells) + "</div>"
|
||||||
|
|
||||||
|
|
||||||
|
def stat_card(label, value, sub=""):
|
||||||
|
sub_html = f'<div class="stat-sub">{esc(sub)}</div>' if sub else ""
|
||||||
|
return (
|
||||||
|
f'<div class="card stat"><div class="stat-val">{esc(value)}</div>'
|
||||||
|
f'<div class="stat-lab">{esc(label)}</div>{sub_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_num(n):
|
||||||
|
try:
|
||||||
|
n = float(n)
|
||||||
|
except Exception:
|
||||||
|
return str(n)
|
||||||
|
if n >= 1_000_000_000:
|
||||||
|
return f"{n/1_000_000_000:.1f}B"
|
||||||
|
if n >= 1_000_000:
|
||||||
|
return f"{n/1_000_000:.1f}M"
|
||||||
|
if n >= 1_000:
|
||||||
|
return f"{n/1_000:.1f}K"
|
||||||
|
if n == int(n):
|
||||||
|
return str(int(n))
|
||||||
|
return f"{n:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def build(stats, narr):
|
||||||
|
t = stats["totals"]
|
||||||
|
parts = []
|
||||||
|
A = parts.append
|
||||||
|
|
||||||
|
A('<section class="hero">')
|
||||||
|
A(f'<h1>pi insights</h1>')
|
||||||
|
A(f'<p class="sub">Last {stats["window_days"]} days · 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)
|
||||||
266
.pi/agent/extensions/new-reminder.ts
Normal file
266
.pi/agent/extensions/new-reminder.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* new-reminder
|
||||||
|
*
|
||||||
|
* Two features for managing long-running sessions:
|
||||||
|
*
|
||||||
|
* 1. A reminder widget above the editor that nudges you to start a fresh
|
||||||
|
* session (`/new`). It shows how long the session has been running and how
|
||||||
|
* many turns it has accumulated. When the session "runs long" (exceeds a
|
||||||
|
* configurable age or turn threshold) the box turns yellow.
|
||||||
|
*
|
||||||
|
* 2. `/snew [prompt]` - summarize the current chat, then start a brand new
|
||||||
|
* session seeded with the generated summary. If a prompt is provided, the
|
||||||
|
* summary is prepended to it and the combined prompt is auto-submitted in
|
||||||
|
* the new session. If no prompt is provided, the summary lands in the
|
||||||
|
* editor as an editable draft and waits for user input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { complete, type Message } from "@earendil-works/pi-ai";
|
||||||
|
import type { ExtensionAPI, ExtensionContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tunables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Session is considered "long" once estimated context exceeds this many tokens. */
|
||||||
|
const TOKEN_THRESHOLD = 250_000;
|
||||||
|
/** ...or after this many user turns, whichever comes first. */
|
||||||
|
const TURN_THRESHOLD = 25;
|
||||||
|
/** Widget refresh cadence (ms). Keeps the elapsed-time readout current. */
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conversation extraction helpers (for /snew)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ContentBlock = {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
arguments?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractText(content: unknown): string[] {
|
||||||
|
if (typeof content === "string") return [content];
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!part || typeof part !== "object") continue;
|
||||||
|
const block = part as ContentBlock;
|
||||||
|
if (block.type === "text" && typeof block.text === "string") out.push(block.text);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationText(branch: SessionEntry[]): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
for (const entry of branch) {
|
||||||
|
if (entry.type === "compaction") {
|
||||||
|
const summary = (entry as { summary?: string }).summary;
|
||||||
|
if (summary) sections.push(`Previous summary:\n${summary}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.type !== "message") continue;
|
||||||
|
const msg = entry.message as { role?: string; content?: unknown } | undefined;
|
||||||
|
if (!msg?.role) continue;
|
||||||
|
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
||||||
|
const text = extractText(msg.content).join("\n").trim();
|
||||||
|
if (text.length === 0) continue;
|
||||||
|
const label = msg.role === "user" ? "User" : "Assistant";
|
||||||
|
sections.push(`${label}: ${text}`);
|
||||||
|
}
|
||||||
|
return sections.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY_SYSTEM_PROMPT = `You are a context transfer assistant. Summarize the conversation so it can be resumed in a new, self-contained thread. Capture goals, key decisions, approaches taken, important findings, files touched, and open questions. Be concise and structured with short headings. Output only the summary, with no preamble.`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder widget
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatTokens(tokens: number): string {
|
||||||
|
if (tokens < 1000) return `${tokens}`;
|
||||||
|
return `${(tokens / 1000).toFixed(tokens < 10_000 ? 1 : 0)}k`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
// --- shared state for the reminder widget ---
|
||||||
|
let turnCount = 0;
|
||||||
|
let activeTui: TUI | undefined;
|
||||||
|
let activeCtx: ExtensionContext | undefined;
|
||||||
|
let ticker: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
/** Estimated context tokens for the active model, or null if unknown. */
|
||||||
|
const contextTokens = (): number | null => activeCtx?.getContextUsage()?.tokens ?? null;
|
||||||
|
|
||||||
|
const isLong = () => {
|
||||||
|
const tokens = contextTokens();
|
||||||
|
return (tokens !== null && tokens >= TOKEN_THRESHOLD) || turnCount >= TURN_THRESHOLD;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTicker = () => {
|
||||||
|
if (ticker) {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a bordered box component. Yellow ("warning") when the session is long,
|
||||||
|
// muted otherwise.
|
||||||
|
const makeWidget = (tui: TUI, theme: Theme): Component => {
|
||||||
|
activeTui = tui;
|
||||||
|
return {
|
||||||
|
render(width: number): string[] {
|
||||||
|
const long = isLong();
|
||||||
|
const colorKey = long ? "warning" : "muted";
|
||||||
|
const color = (s: string) => theme.fg(colorKey, s);
|
||||||
|
|
||||||
|
const tokens = contextTokens();
|
||||||
|
const icon = long ? "\u26a0" : "\u23f1"; // warning sign / stopwatch
|
||||||
|
const lead = long
|
||||||
|
? `${icon} Long session \u2014 consider /new`
|
||||||
|
: `${icon} Session`;
|
||||||
|
const ctxStat = tokens === null ? "ctx ?" : `${formatTokens(tokens)} ctx`;
|
||||||
|
const stats = `${ctxStat} \u00b7 ${turnCount} turn${turnCount === 1 ? "" : "s"}`;
|
||||||
|
const label = ` ${lead} \u00b7 ${stats} `;
|
||||||
|
|
||||||
|
const inner = Math.max(0, width - 2);
|
||||||
|
const truncated = label.length > inner ? label.slice(0, inner) : label;
|
||||||
|
const pad = inner - truncated.length;
|
||||||
|
const top = color("\u256d" + "\u2500".repeat(inner) + "\u256e");
|
||||||
|
const mid = color("\u2502") + (long ? theme.bold(theme.fg("warning", truncated)) : color(truncated)) + " ".repeat(pad) + color("\u2502");
|
||||||
|
const bot = color("\u2570" + "\u2500".repeat(inner) + "\u256f");
|
||||||
|
return [top, mid, bot];
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startReminder = (ctx: ExtensionContext) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
activeCtx = ctx;
|
||||||
|
ctx.ui.setWidget("new-reminder", (tui, theme) => makeWidget(tui, theme), { placement: "aboveEditor" });
|
||||||
|
stopTicker();
|
||||||
|
ticker = setInterval(() => activeTui?.requestRender(), TICK_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
// Reset the turn counter for the (possibly new/resumed) session.
|
||||||
|
turnCount = 0;
|
||||||
|
startReminder(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
turnCount += 1;
|
||||||
|
activeTui?.requestRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", () => {
|
||||||
|
stopTicker();
|
||||||
|
activeTui = undefined;
|
||||||
|
activeCtx = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// /snew - summarize then start a new session seeded with summary + prompt
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
pi.registerCommand("snew", {
|
||||||
|
description: "Summarize the current chat, then start a new session. With a prompt: auto-submit summary+prompt. Without: seed the editor and wait.",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("/snew requires interactive mode", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = args.trim();
|
||||||
|
|
||||||
|
if (!ctx.model) {
|
||||||
|
ctx.ui.notify("No model selected", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.waitForIdle();
|
||||||
|
|
||||||
|
const conversationText = buildConversationText(ctx.sessionManager.getBranch());
|
||||||
|
if (!conversationText.trim()) {
|
||||||
|
ctx.ui.notify("No conversation to summarize", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
||||||
|
if (!auth.ok || !auth.apiKey) {
|
||||||
|
ctx.ui.notify(auth.ok ? `No API key for ${ctx.model.provider}` : auth.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("Summarizing current chat...", "info");
|
||||||
|
|
||||||
|
const summaryMessages: Message[] = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Summarize this conversation for handoff to a new session.\n\n<conversation>\n${conversationText}\n</conversation>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
try {
|
||||||
|
const response = await complete(
|
||||||
|
ctx.model,
|
||||||
|
{ systemPrompt: SUMMARY_SYSTEM_PROMPT, messages: summaryMessages },
|
||||||
|
{ apiKey: auth.apiKey, headers: auth.headers, reasoningEffort: "low", signal: ctx.signal },
|
||||||
|
);
|
||||||
|
summary = response.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
} catch (err) {
|
||||||
|
ctx.ui.notify(`Summary failed: ${(err as Error).message}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
ctx.ui.notify("Empty summary; aborting", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentSession = ctx.sessionManager.getSessionFile();
|
||||||
|
|
||||||
|
if (userPrompt) {
|
||||||
|
// Prompt provided: combine summary + prompt and auto-submit.
|
||||||
|
const combinedPrompt = `## Context from previous session\n\n${summary}\n\n---\n\n## Task\n\n${userPrompt}`;
|
||||||
|
const result = await ctx.newSession({
|
||||||
|
parentSession,
|
||||||
|
withSession: async (replacementCtx) => {
|
||||||
|
await replacementCtx.sendUserMessage(combinedPrompt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.cancelled) {
|
||||||
|
ctx.ui.notify("New session cancelled", "info");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No prompt: seed the editor with just the summary and wait for input.
|
||||||
|
const draftPrompt = `## Context from previous session\n\n${summary}\n\n---\n\n## Task\n\n`;
|
||||||
|
const result = await ctx.newSession({
|
||||||
|
parentSession,
|
||||||
|
withSession: async (replacementCtx) => {
|
||||||
|
replacementCtx.ui.setEditorText(draftPrompt);
|
||||||
|
replacementCtx.ui.notify("New session seeded with summary. Add your prompt and submit.", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
ctx.ui.notify("New session cancelled", "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM debian:bookworm-slim
|
FROM --platform=linux/arm64 debian:bookworm-slim
|
||||||
|
|
||||||
ARG PYTHON_VERSION=3.11
|
ARG PYTHON_VERSION=3.11
|
||||||
ARG NODE_MAJOR=22
|
ARG NODE_MAJOR=22
|
||||||
|
|
@ -19,6 +19,7 @@ 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 \
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue