- Static site: /manifesto, /live, /hire pages - Ship-log Pi extension: calvana_ship, calvana_oops, calvana_deploy tools - Docker + nginx deploy to calvana.quikcue.com - Terminal-ish dark aesthetic, mobile responsive - Auto-updating /live page from extension state
1101 lines
35 KiB
TypeScript
1101 lines
35 KiB
TypeScript
/**
|
|
* Observatory — Comprehensive observability dashboard for the Pi coding agent
|
|
*
|
|
* A completely passive extension that observes and records every event —
|
|
* session starts, tool calls, agent turns, errors, context usage — into
|
|
* an append-only JSONL log and rolling summary on disk.
|
|
*
|
|
* Surfaces:
|
|
* - Widget: compact live dashboard with session stats, tool counts, cost
|
|
* - Footer: single-line status bar with model, context %, event count
|
|
* - Overlay: full-screen dashboard (4 views) opened via /obs command
|
|
* - Export: markdown report via /obs export
|
|
*
|
|
* Storage: .pi/observatory/events.jsonl, .pi/observatory/summary.json
|
|
*
|
|
* Commands:
|
|
* /obs or /observatory — open dashboard overlay
|
|
* /obs clear — wipe all observatory data
|
|
* /obs export — write markdown report
|
|
* /obs agents — overlay → agents view
|
|
* /obs tools — overlay → tools view
|
|
* /obs timeline — overlay → timeline view
|
|
*
|
|
* Usage: pi -e extensions/observatory.ts
|
|
*/
|
|
|
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { applyExtensionDefaults } from "./themeMap.ts";
|
|
|
|
// ── Data Model ─────────────────────────────────────────────────────────
|
|
|
|
type ObsEventType =
|
|
| "session_start"
|
|
| "session_switch"
|
|
| "session_fork"
|
|
| "tool_call"
|
|
| "tool_end"
|
|
| "agent_start"
|
|
| "agent_end"
|
|
| "dispatch"
|
|
| "error"
|
|
| "blocked";
|
|
|
|
interface ObsEvent {
|
|
ts: number;
|
|
type: ObsEventType;
|
|
sessionId: string;
|
|
tool?: string;
|
|
durationMs?: number;
|
|
blocked?: boolean;
|
|
blockReason?: string;
|
|
agentTurn?: number;
|
|
contextPercent?: number;
|
|
tokensIn?: number;
|
|
tokensOut?: number;
|
|
cost?: number;
|
|
error?: string;
|
|
meta?: Record<string, any>;
|
|
}
|
|
|
|
interface ObsSummary {
|
|
totalSessions: number;
|
|
totalEvents: number;
|
|
totalToolCalls: number;
|
|
totalAgentTurns: number;
|
|
totalErrors: number;
|
|
totalBlocked: number;
|
|
totalTokensIn: number;
|
|
totalTokensOut: number;
|
|
totalCost: number;
|
|
toolCounts: Record<string, number>;
|
|
toolDurations: Record<string, number[]>;
|
|
toolBlocked: Record<string, number>;
|
|
sessions: SessionSummary[];
|
|
lastUpdated: number;
|
|
}
|
|
|
|
interface SessionSummary {
|
|
sessionId: string;
|
|
startedAt: number;
|
|
endedAt?: number;
|
|
model?: string;
|
|
toolCalls: number;
|
|
agentTurns: number;
|
|
errors: number;
|
|
blocked: number;
|
|
tokensIn: number;
|
|
tokensOut: number;
|
|
cost: number;
|
|
peakContextPercent: number;
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
function fmtDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
const secs = Math.floor(ms / 1000);
|
|
if (secs < 60) return `${secs}s`;
|
|
const mins = Math.floor(secs / 60);
|
|
const remSecs = secs % 60;
|
|
if (mins < 60) return `${mins}m ${remSecs}s`;
|
|
const hrs = Math.floor(mins / 60);
|
|
const remMins = mins % 60;
|
|
return `${hrs}h ${remMins}m`;
|
|
}
|
|
|
|
function fmtTokens(n: number): string {
|
|
if (n < 1000) return `${n}`;
|
|
return `${(n / 1000).toFixed(1)}k`;
|
|
}
|
|
|
|
function fmtCost(n: number): string {
|
|
return `$${n.toFixed(4)}`;
|
|
}
|
|
|
|
function fmtTime(ts: number): string {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
}
|
|
|
|
function fmtDate(ts: number): string {
|
|
const d = new Date(ts);
|
|
return d.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
|
}
|
|
|
|
function shortId(): string {
|
|
return Math.random().toString(36).slice(2, 8);
|
|
}
|
|
|
|
function emptySummary(): ObsSummary {
|
|
return {
|
|
totalSessions: 0,
|
|
totalEvents: 0,
|
|
totalToolCalls: 0,
|
|
totalAgentTurns: 0,
|
|
totalErrors: 0,
|
|
totalBlocked: 0,
|
|
totalTokensIn: 0,
|
|
totalTokensOut: 0,
|
|
totalCost: 0,
|
|
toolCounts: {},
|
|
toolDurations: {},
|
|
toolBlocked: {},
|
|
sessions: [],
|
|
lastUpdated: Date.now(),
|
|
};
|
|
}
|
|
|
|
function emptySessionSummary(sessionId: string, model?: string): SessionSummary {
|
|
return {
|
|
sessionId,
|
|
startedAt: Date.now(),
|
|
model,
|
|
toolCalls: 0,
|
|
agentTurns: 0,
|
|
errors: 0,
|
|
blocked: 0,
|
|
tokensIn: 0,
|
|
tokensOut: 0,
|
|
cost: 0,
|
|
peakContextPercent: 0,
|
|
};
|
|
}
|
|
|
|
// ── Token/Cost computation (follows tool-counter.ts pattern) ───────────
|
|
|
|
function computeTokensAndCost(ctx: ExtensionContext): { tokensIn: number; tokensOut: number; cost: number } {
|
|
let tokensIn = 0;
|
|
let tokensOut = 0;
|
|
let cost = 0;
|
|
try {
|
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
const m = entry.message as AssistantMessage;
|
|
tokensIn += m.usage.input;
|
|
tokensOut += m.usage.output;
|
|
cost += m.usage.cost.total;
|
|
}
|
|
}
|
|
} catch {
|
|
// Session not yet ready — return zeros
|
|
}
|
|
return { tokensIn, tokensOut, cost };
|
|
}
|
|
|
|
// ── Extension ──────────────────────────────────────────────────────────
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
// ── State ──────────────────────────────────────────────────────────
|
|
|
|
let obsDir = "";
|
|
let eventsPath = "";
|
|
let summaryPath = "";
|
|
|
|
let sessionId = "";
|
|
let sessionStartTs = 0;
|
|
let summary: ObsSummary = emptySummary();
|
|
let currentSession: SessionSummary | null = null;
|
|
let sessionEvents: ObsEvent[] = [];
|
|
|
|
let widgetCtx: ExtensionContext | null = null;
|
|
|
|
// Tool call timing: Map<toolName, startTs[]> — FIFO queue per tool
|
|
const toolTimers: Map<string, number[]> = new Map();
|
|
|
|
// Agent turn tracking
|
|
let agentTurnCount = 0;
|
|
let agentTurnStartTs = 0;
|
|
|
|
// Previous token/cost values for delta-based cross-session accumulation
|
|
let prevTokensIn = 0;
|
|
let prevTokensOut = 0;
|
|
let prevCost = 0;
|
|
|
|
// Debounced summary flush
|
|
let summaryDirty = false;
|
|
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// ── Storage Layer ──────────────────────────────────────────────────
|
|
|
|
function ensureDir() {
|
|
try {
|
|
if (!fs.existsSync(obsDir)) {
|
|
fs.mkdirSync(obsDir, { recursive: true });
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function loadSummary() {
|
|
try {
|
|
if (fs.existsSync(summaryPath)) {
|
|
const raw = fs.readFileSync(summaryPath, "utf-8");
|
|
const loaded = JSON.parse(raw) as ObsSummary;
|
|
// Merge loaded with defaults for any missing fields
|
|
summary = { ...emptySummary(), ...loaded };
|
|
// Cap stored durations
|
|
for (const tool of Object.keys(summary.toolDurations)) {
|
|
if (summary.toolDurations[tool].length > 200) {
|
|
summary.toolDurations[tool] = summary.toolDurations[tool].slice(-200);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
summary = emptySummary();
|
|
}
|
|
}
|
|
|
|
function saveSummary() {
|
|
try {
|
|
summary.lastUpdated = Date.now();
|
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), "utf-8");
|
|
summaryDirty = false;
|
|
} catch {}
|
|
}
|
|
|
|
function markDirty() {
|
|
summaryDirty = true;
|
|
}
|
|
|
|
function startFlushTimer() {
|
|
if (flushTimer) return;
|
|
flushTimer = setInterval(() => {
|
|
if (summaryDirty) saveSummary();
|
|
}, 5000);
|
|
}
|
|
|
|
function stopFlushTimer() {
|
|
if (flushTimer) {
|
|
clearInterval(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (summaryDirty) saveSummary();
|
|
}
|
|
|
|
function appendEvent(evt: ObsEvent) {
|
|
try {
|
|
ensureDir();
|
|
fs.appendFileSync(eventsPath, JSON.stringify(evt) + "\n", "utf-8");
|
|
} catch {}
|
|
sessionEvents.push(evt);
|
|
// Keep in-memory events bounded
|
|
if (sessionEvents.length > 500) {
|
|
sessionEvents = sessionEvents.slice(-400);
|
|
}
|
|
summary.totalEvents++;
|
|
markDirty();
|
|
}
|
|
|
|
function clearAllData() {
|
|
try {
|
|
if (fs.existsSync(eventsPath)) fs.unlinkSync(eventsPath);
|
|
if (fs.existsSync(summaryPath)) fs.unlinkSync(summaryPath);
|
|
} catch {}
|
|
summary = emptySummary();
|
|
sessionEvents = [];
|
|
toolTimers.clear();
|
|
agentTurnCount = 0;
|
|
currentSession = emptySessionSummary(sessionId, widgetCtx?.model?.id);
|
|
summary.sessions.push(currentSession);
|
|
summary.totalSessions = summary.sessions.length;
|
|
markDirty();
|
|
}
|
|
|
|
// ── Context snapshot ───────────────────────────────────────────────
|
|
|
|
function getContextPercent(): number {
|
|
try {
|
|
const usage = widgetCtx?.getContextUsage();
|
|
return usage ? Math.round(usage.percent ?? 0) : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// ── Widget rendering ───────────────────────────────────────────────
|
|
|
|
function updateWidget() {
|
|
if (!widgetCtx) return;
|
|
|
|
try {
|
|
widgetCtx.ui.setWidget("observatory", (_tui, theme) => {
|
|
const container = new Container();
|
|
const borderFn = (s: string) => theme.fg("accent", s);
|
|
|
|
container.addChild(new DynamicBorder(borderFn));
|
|
|
|
const headerText = new Text("", 1, 0);
|
|
container.addChild(headerText);
|
|
|
|
const liveText = new Text("", 1, 0);
|
|
container.addChild(liveText);
|
|
|
|
const hintText = new Text("", 1, 0);
|
|
container.addChild(hintText);
|
|
|
|
container.addChild(new DynamicBorder(borderFn));
|
|
|
|
return {
|
|
render(width: number): string[] {
|
|
const elapsed = fmtDuration(Date.now() - sessionStartTs);
|
|
const toolCount = currentSession?.toolCalls || 0;
|
|
const turns = currentSession?.agentTurns || 0;
|
|
const ctxPct = getContextPercent();
|
|
|
|
// Update peak context
|
|
if (currentSession && ctxPct > currentSession.peakContextPercent) {
|
|
currentSession.peakContextPercent = ctxPct;
|
|
}
|
|
|
|
// Line 1: session info
|
|
const sid = theme.fg("accent", `#${sessionId}`);
|
|
const line1 =
|
|
theme.fg("dim", " SESSION ") + sid +
|
|
theme.fg("dim", " │ ⏱ ") + theme.fg("success", elapsed) +
|
|
theme.fg("dim", " │ 🔧 ") + theme.fg("accent", `${toolCount}`) + theme.fg("dim", " tools") +
|
|
theme.fg("dim", " │ 🔄 ") + theme.fg("accent", `${turns}`) + theme.fg("dim", " turns") +
|
|
theme.fg("dim", " │ 📊 ") + theme.fg(ctxPct > 80 ? "error" : ctxPct > 60 ? "warning" : "success", `${ctxPct}%`) + theme.fg("dim", " ctx");
|
|
headerText.setText(truncateToWidth(line1, width - 4));
|
|
|
|
// Line 2: tool frequency bar (top 8 tools)
|
|
const tc = summary.toolCounts;
|
|
const sorted = Object.entries(tc).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
const parts = sorted.map(
|
|
([name, count]) =>
|
|
theme.fg("accent", name) + theme.fg("dim", "(") + theme.fg("success", `${count}`) + theme.fg("dim", ")")
|
|
);
|
|
const liveLine = parts.length > 0
|
|
? theme.fg("dim", " LIVE ") + theme.fg("success", "● ") + parts.join(theme.fg("dim", " "))
|
|
: theme.fg("dim", " LIVE ● waiting for tools…");
|
|
liveText.setText(truncateToWidth(liveLine, width - 4));
|
|
|
|
// Line 3: hint + tokens + cost
|
|
const { tokensIn, tokensOut, cost } = computeTokensAndCost(widgetCtx!);
|
|
const hintLine =
|
|
theme.fg("dim", " /obs") + theme.fg("muted", " — dashboard") +
|
|
theme.fg("dim", " │ tokens: ") +
|
|
theme.fg("success", fmtTokens(tokensIn)) + theme.fg("dim", " in · ") +
|
|
theme.fg("accent", fmtTokens(tokensOut)) + theme.fg("dim", " out") +
|
|
theme.fg("dim", " │ ") + theme.fg("warning", fmtCost(cost));
|
|
hintText.setText(truncateToWidth(hintLine, width - 4));
|
|
|
|
return container.render(width);
|
|
},
|
|
invalidate() {
|
|
container.invalidate();
|
|
},
|
|
};
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
// ── Footer rendering ───────────────────────────────────────────────
|
|
|
|
function updateFooter(ctx: ExtensionContext) {
|
|
try {
|
|
ctx.ui.setFooter((_tui, theme) => ({
|
|
dispose: () => {},
|
|
invalidate() {},
|
|
render(width: number): string[] {
|
|
const model = ctx.model?.id || "no-model";
|
|
const ctxPct = getContextPercent();
|
|
const evtCount = summary.totalEvents;
|
|
|
|
const line =
|
|
theme.fg("accent", " 🔭 Observatory") +
|
|
theme.fg("dim", " │ ") + theme.fg("muted", model) +
|
|
theme.fg("dim", " │ ctx: ") +
|
|
theme.fg(ctxPct > 80 ? "error" : ctxPct > 60 ? "warning" : "success", `${ctxPct}%`) +
|
|
theme.fg("dim", " │ ") + theme.fg("success", `${evtCount}`) + theme.fg("dim", " events") +
|
|
theme.fg("dim", " │ /obs for dashboard ");
|
|
|
|
return [truncateToWidth(line, width)];
|
|
},
|
|
}));
|
|
} catch {}
|
|
}
|
|
|
|
// ── Dashboard Overlay ──────────────────────────────────────────────
|
|
|
|
async function openOverlay(ctx: ExtensionContext, initialView: number = 0) {
|
|
if (!ctx.hasUI) return;
|
|
|
|
let currentView = initialView; // 0=Feed, 1=Agents, 2=Tools, 3=Timeline
|
|
let scrollOffset = 0;
|
|
|
|
const viewNames = ["1:Feed", "2:Agents", "3:Tools", "4:Timeline"];
|
|
|
|
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
return {
|
|
render(width: number): string[] {
|
|
const lines: string[] = [];
|
|
const innerW = width - 2;
|
|
|
|
// ── Header ──
|
|
lines.push("");
|
|
const tabs = viewNames.map((name, i) =>
|
|
i === currentView
|
|
? theme.fg("accent", theme.bold(`[${name}]`))
|
|
: theme.fg("dim", `[${name}]`)
|
|
).join(" ");
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("accent", theme.bold("🔭 Observatory Dashboard")) +
|
|
" ".repeat(Math.max(1, innerW - 24 - viewNames.join(" ").length - 2)) +
|
|
tabs,
|
|
width,
|
|
));
|
|
lines.push(theme.fg("dim", "─".repeat(width)));
|
|
|
|
// ── View content ──
|
|
const contentLines = renderView(currentView, width, theme, scrollOffset);
|
|
lines.push(...contentLines);
|
|
|
|
// ── Footer controls ──
|
|
lines.push("");
|
|
lines.push(theme.fg("dim", "─".repeat(width)));
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("dim", "1-4/Tab: views │ ↑↓/j/k: scroll │ PgUp/PgDn: page │ c: clear │ q/Esc: close"),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
return lines;
|
|
},
|
|
handleInput(data: string) {
|
|
if (matchesKey(data, "escape") || data === "q") {
|
|
done(undefined);
|
|
return;
|
|
}
|
|
if (data === "1") { currentView = 0; scrollOffset = 0; }
|
|
else if (data === "2") { currentView = 1; scrollOffset = 0; }
|
|
else if (data === "3") { currentView = 2; scrollOffset = 0; }
|
|
else if (data === "4") { currentView = 3; scrollOffset = 0; }
|
|
else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; }
|
|
else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); }
|
|
else if (matchesKey(data, "down") || data === "j") { scrollOffset++; }
|
|
else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); }
|
|
else if (matchesKey(data, "pageDown")) { scrollOffset += 20; }
|
|
else if (data === "c") {
|
|
clearAllData();
|
|
scrollOffset = 0;
|
|
}
|
|
_tui.requestRender();
|
|
},
|
|
invalidate() {},
|
|
};
|
|
}, {
|
|
overlay: true,
|
|
overlayOptions: { width: "90%", anchor: "center" },
|
|
});
|
|
}
|
|
|
|
// ── View Renderers ─────────────────────────────────────────────────
|
|
|
|
function renderView(view: number, width: number, theme: any, offset: number): string[] {
|
|
switch (view) {
|
|
case 0: return renderFeedView(width, theme, offset);
|
|
case 1: return renderAgentsView(width, theme, offset);
|
|
case 2: return renderToolsView(width, theme, offset);
|
|
case 3: return renderTimelineView(width, theme, offset);
|
|
default: return [];
|
|
}
|
|
}
|
|
|
|
// ── View 1: Live Feed ──────────────────────────────────────────────
|
|
|
|
function renderFeedView(width: number, theme: any, offset: number): string[] {
|
|
const lines: string[] = [];
|
|
const events = sessionEvents.slice(-100);
|
|
|
|
if (events.length === 0) {
|
|
lines.push("");
|
|
lines.push(truncateToWidth(" " + theme.fg("dim", "No events yet. Start working and events will appear here."), width));
|
|
return lines;
|
|
}
|
|
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("muted", `Showing last ${events.length} events from session #${sessionId}`),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
const iconMap: Record<string, string> = {
|
|
session_start: "🚀", session_switch: "🔀", session_fork: "🔱",
|
|
tool_call: "🔧", tool_end: "✅", agent_start: "🤖", agent_end: "🏁",
|
|
dispatch: "📤", error: "❌", blocked: "🚫",
|
|
};
|
|
const colorMap: Record<string, string> = {
|
|
session_start: "success", session_switch: "accent", session_fork: "accent",
|
|
tool_call: "accent", tool_end: "success", agent_start: "warning", agent_end: "success",
|
|
dispatch: "accent", error: "error", blocked: "error",
|
|
};
|
|
|
|
const visible = events.slice(offset);
|
|
for (const evt of visible) {
|
|
const time = theme.fg("dim", `[${fmtTime(evt.ts)}]`);
|
|
const icon = iconMap[evt.type] || "●";
|
|
const color = colorMap[evt.type] || "muted";
|
|
let detail = "";
|
|
|
|
if (evt.type === "tool_call" && evt.tool) {
|
|
detail = theme.fg(color, `${evt.tool}`) +
|
|
(evt.contextPercent !== undefined ? theme.fg("dim", ` ctx:${evt.contextPercent}%`) : "");
|
|
} else if (evt.type === "tool_end" && evt.tool) {
|
|
detail = theme.fg(color, `${evt.tool}`) +
|
|
(evt.durationMs !== undefined ? theme.fg("dim", ` ${fmtDuration(evt.durationMs)}`) : "");
|
|
} else if (evt.type === "agent_start") {
|
|
detail = theme.fg(color, `turn #${evt.agentTurn || "?"}`) +
|
|
(evt.contextPercent !== undefined ? theme.fg("dim", ` ctx:${evt.contextPercent}%`) : "");
|
|
} else if (evt.type === "agent_end") {
|
|
detail = theme.fg(color, `turn #${evt.agentTurn || "?"}`) +
|
|
(evt.durationMs !== undefined ? theme.fg("dim", ` ${fmtDuration(evt.durationMs)}`) : "") +
|
|
(evt.tokensIn !== undefined ? theme.fg("dim", ` tok:${fmtTokens(evt.tokensIn)}in`) : "") +
|
|
(evt.cost !== undefined ? theme.fg("dim", ` ${fmtCost(evt.cost)}`) : "");
|
|
} else if (evt.type === "error") {
|
|
detail = theme.fg(color, evt.error || "unknown error");
|
|
} else if (evt.type === "blocked") {
|
|
detail = theme.fg(color, `${evt.tool || "?"}: ${evt.blockReason || "blocked"}`);
|
|
} else {
|
|
detail = theme.fg(color, evt.type);
|
|
}
|
|
|
|
lines.push(truncateToWidth(` ${time} ${icon} ${detail}`, width));
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
// ── View 2: Agent Performance ──────────────────────────────────────
|
|
|
|
function renderAgentsView(width: number, theme: any, offset: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
// Cross-session summary
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("accent", theme.bold("Cross-Session Summary")),
|
|
width,
|
|
));
|
|
lines.push(truncateToWidth(
|
|
" " +
|
|
theme.fg("muted", "Total turns: ") + theme.fg("success", `${summary.totalAgentTurns}`) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Total sessions: ") + theme.fg("success", `${summary.totalSessions}`) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Total cost: ") + theme.fg("warning", fmtCost(summary.totalCost)),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
// Current session agent turns
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("accent", theme.bold("Current Session Turns")) +
|
|
theme.fg("dim", ` (#${sessionId})`),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
// Collect agent turn events
|
|
const turnStarts: ObsEvent[] = sessionEvents.filter(e => e.type === "agent_start");
|
|
const turnEnds: ObsEvent[] = sessionEvents.filter(e => e.type === "agent_end");
|
|
|
|
if (turnStarts.length === 0) {
|
|
lines.push(truncateToWidth(" " + theme.fg("dim", "No agent turns recorded yet."), width));
|
|
return lines;
|
|
}
|
|
|
|
// Table header
|
|
const hdr =
|
|
theme.fg("accent", " Turn") +
|
|
theme.fg("accent", " │ Duration ") +
|
|
theme.fg("accent", " │ Tools ") +
|
|
theme.fg("accent", " │ Context ") +
|
|
theme.fg("accent", " │ Tokens In ") +
|
|
theme.fg("accent", " │ Cost ");
|
|
lines.push(truncateToWidth(hdr, width));
|
|
lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(70, width - 4))), width));
|
|
|
|
// Build turn rows — pair starts with ends
|
|
const turnRows: string[] = [];
|
|
let prevCtx = 0;
|
|
for (let i = 0; i < turnStarts.length; i++) {
|
|
const start = turnStarts[i];
|
|
const end = turnEnds[i]; // May be undefined if still running
|
|
const turn = start.agentTurn || (i + 1);
|
|
const dur = end?.durationMs !== undefined ? fmtDuration(end.durationMs) : "running…";
|
|
|
|
// Count tools between this turn start and end (or now)
|
|
const startTs = start.ts;
|
|
const endTs = end?.ts || Date.now();
|
|
const toolsInTurn = sessionEvents.filter(
|
|
e => e.type === "tool_call" && e.ts >= startTs && e.ts <= endTs
|
|
).length;
|
|
|
|
const ctxNow = end?.contextPercent ?? start.contextPercent ?? 0;
|
|
const ctxDelta = ctxNow - prevCtx;
|
|
prevCtx = ctxNow;
|
|
|
|
const tokIn = end?.tokensIn ?? 0;
|
|
const cost = end?.cost ?? 0;
|
|
|
|
const row =
|
|
theme.fg("success", ` #${String(turn).padStart(2)}`) +
|
|
theme.fg("dim", " │ ") + theme.fg("muted", dur.padEnd(9)) +
|
|
theme.fg("dim", " │ ") + theme.fg("accent", String(toolsInTurn).padStart(5)) + " " +
|
|
theme.fg("dim", " │ ") + theme.fg(ctxDelta > 15 ? "warning" : "muted", `${ctxDelta >= 0 ? "+" : ""}${ctxDelta}%`.padStart(7)) + " " +
|
|
theme.fg("dim", " │ ") + theme.fg("muted", fmtTokens(tokIn).padStart(9)) + " " +
|
|
theme.fg("dim", " │ ") + theme.fg("warning", fmtCost(cost).padStart(8));
|
|
turnRows.push(row);
|
|
}
|
|
|
|
const visible = turnRows.slice(offset);
|
|
for (const row of visible) {
|
|
lines.push(truncateToWidth(row, width));
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
// ── View 3: Tool Analytics ─────────────────────────────────────────
|
|
|
|
function renderToolsView(width: number, theme: any, offset: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("accent", theme.bold("Tool Analytics")) +
|
|
theme.fg("dim", " (all sessions)"),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
const tc = summary.toolCounts;
|
|
const entries = Object.entries(tc).sort((a, b) => b[1] - a[1]);
|
|
|
|
if (entries.length === 0) {
|
|
lines.push(truncateToWidth(" " + theme.fg("dim", "No tool usage recorded yet."), width));
|
|
return lines;
|
|
}
|
|
|
|
const maxCount = entries[0][1];
|
|
const barWidth = Math.min(25, Math.floor(width * 0.3));
|
|
const blocks = "▏▎▍▌▋▊▉█";
|
|
|
|
const visible = entries.slice(offset);
|
|
for (const [toolName, count] of visible) {
|
|
// Bar
|
|
const ratio = maxCount > 0 ? count / maxCount : 0;
|
|
const fullBlocks = Math.floor(ratio * barWidth);
|
|
const remainder = (ratio * barWidth) - fullBlocks;
|
|
const partialIdx = Math.floor(remainder * 8);
|
|
let bar = "█".repeat(fullBlocks);
|
|
if (fullBlocks < barWidth && partialIdx > 0) {
|
|
bar += blocks[partialIdx - 1];
|
|
}
|
|
|
|
// Duration stats
|
|
const durations = summary.toolDurations[toolName] || [];
|
|
let avgDur = "—";
|
|
let minDur = "—";
|
|
let maxDur = "—";
|
|
if (durations.length > 0) {
|
|
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
|
const min = Math.min(...durations);
|
|
const max = Math.max(...durations);
|
|
avgDur = fmtDuration(Math.round(avg));
|
|
minDur = fmtDuration(min);
|
|
maxDur = fmtDuration(max);
|
|
}
|
|
|
|
const blocked = summary.toolBlocked[toolName] || 0;
|
|
|
|
const namePad = toolName.padEnd(12);
|
|
const line =
|
|
" " +
|
|
theme.fg("accent", namePad) + " " +
|
|
theme.fg("success", bar.padEnd(barWidth)) + " " +
|
|
theme.fg("muted", String(count).padStart(5)) +
|
|
theme.fg("dim", " avg: ") + theme.fg("muted", avgDur.padEnd(6)) +
|
|
theme.fg("dim", " min: ") + theme.fg("muted", minDur.padEnd(6)) +
|
|
theme.fg("dim", " max: ") + theme.fg("muted", maxDur.padEnd(6)) +
|
|
(blocked > 0
|
|
? theme.fg("dim", " blocked: ") + theme.fg("error", `${blocked}`)
|
|
: theme.fg("dim", " blocked: ") + theme.fg("muted", "0"));
|
|
|
|
lines.push(truncateToWidth(line, width));
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
// ── View 4: Timeline ───────────────────────────────────────────────
|
|
|
|
function renderTimelineView(width: number, theme: any, offset: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
lines.push(truncateToWidth(
|
|
" " + theme.fg("accent", theme.bold("Session Timeline")) +
|
|
theme.fg("dim", ` (${summary.sessions.length} sessions)`),
|
|
width,
|
|
));
|
|
lines.push("");
|
|
|
|
if (summary.sessions.length === 0) {
|
|
lines.push(truncateToWidth(" " + theme.fg("dim", "No sessions recorded yet."), width));
|
|
return lines;
|
|
}
|
|
|
|
// Show sessions newest first
|
|
const sessionsDesc = [...summary.sessions].reverse();
|
|
const cardLines: string[] = [];
|
|
|
|
for (const sess of sessionsDesc) {
|
|
const dur = sess.endedAt
|
|
? fmtDuration(sess.endedAt - sess.startedAt)
|
|
: fmtDuration(Date.now() - sess.startedAt);
|
|
const date = fmtDate(sess.startedAt);
|
|
const time = fmtTime(sess.startedAt);
|
|
const isCurrent = sess.sessionId === sessionId;
|
|
|
|
const topBorder = theme.fg("dim", " ┌─ ") +
|
|
theme.fg("accent", `Session ${sess.sessionId}`) +
|
|
(isCurrent ? theme.fg("success", " (current)") : "") +
|
|
theme.fg("dim", ` ─ ${date} ${time} `) +
|
|
theme.fg("dim", "─".repeat(Math.max(0, width - 50)));
|
|
cardLines.push(truncateToWidth(topBorder, width));
|
|
|
|
const line1 =
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Duration: ") + theme.fg("success", dur) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Model: ") + theme.fg("accent", sess.model || "?") +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Tools: ") + theme.fg("success", `${sess.toolCalls}`) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Turns: ") + theme.fg("success", `${sess.agentTurns}`);
|
|
cardLines.push(truncateToWidth(line1, width));
|
|
|
|
const line2 =
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Cost: ") + theme.fg("warning", fmtCost(sess.cost)) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Peak Context: ") + theme.fg(
|
|
sess.peakContextPercent > 80 ? "error" : sess.peakContextPercent > 60 ? "warning" : "success",
|
|
`${sess.peakContextPercent}%`,
|
|
) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Errors: ") + theme.fg(sess.errors > 0 ? "error" : "muted", `${sess.errors}`) +
|
|
theme.fg("dim", " │ ") +
|
|
theme.fg("muted", "Tokens: ") +
|
|
theme.fg("success", fmtTokens(sess.tokensIn)) + theme.fg("dim", " in / ") +
|
|
theme.fg("accent", fmtTokens(sess.tokensOut)) + theme.fg("dim", " out");
|
|
cardLines.push(truncateToWidth(line2, width));
|
|
|
|
const botBorder = theme.fg("dim", " └" + "─".repeat(Math.max(0, width - 5)) + "┘");
|
|
cardLines.push(truncateToWidth(botBorder, width));
|
|
cardLines.push("");
|
|
}
|
|
|
|
const visible = cardLines.slice(offset);
|
|
lines.push(...visible);
|
|
|
|
return lines;
|
|
}
|
|
|
|
// ── Export Report ──────────────────────────────────────────────────
|
|
|
|
function exportReport(ctx: ExtensionContext) {
|
|
try {
|
|
ensureDir();
|
|
const { tokensIn, tokensOut, cost } = computeTokensAndCost(ctx);
|
|
const elapsed = fmtDuration(Date.now() - sessionStartTs);
|
|
const model = ctx.model?.id || "unknown";
|
|
|
|
const toolRows = Object.entries(summary.toolCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([name, count]) => {
|
|
const durations = summary.toolDurations[name] || [];
|
|
const avg = durations.length > 0
|
|
? fmtDuration(Math.round(durations.reduce((a, b) => a + b, 0) / durations.length))
|
|
: "—";
|
|
const blocked = summary.toolBlocked[name] || 0;
|
|
return `| ${name} | ${count} | ${avg} | ${blocked} |`;
|
|
})
|
|
.join("\n");
|
|
|
|
const sessionRows = summary.sessions.map(sess => {
|
|
const dur = sess.endedAt
|
|
? fmtDuration(sess.endedAt - sess.startedAt)
|
|
: fmtDuration(Date.now() - sess.startedAt);
|
|
return `| ${sess.sessionId} | ${fmtDate(sess.startedAt)} | ${dur} | ${sess.model || "?"} | ${sess.toolCalls} | ${sess.agentTurns} | ${fmtCost(sess.cost)} | ${sess.peakContextPercent}% |`;
|
|
}).join("\n");
|
|
|
|
const report = `# 🔭 Observatory Report
|
|
Generated: ${new Date().toISOString()}
|
|
|
|
## Current Session
|
|
- **ID:** ${sessionId}
|
|
- **Duration:** ${elapsed}
|
|
- **Model:** ${model}
|
|
- **Tool Calls:** ${currentSession?.toolCalls || 0}
|
|
- **Agent Turns:** ${currentSession?.agentTurns || 0}
|
|
- **Tokens:** ${fmtTokens(tokensIn)} in / ${fmtTokens(tokensOut)} out
|
|
- **Cost:** ${fmtCost(cost)}
|
|
- **Peak Context:** ${currentSession?.peakContextPercent || 0}%
|
|
|
|
## Tool Usage
|
|
| Tool | Count | Avg Duration | Blocked |
|
|
|------|-------|-------------|---------|
|
|
${toolRows || "| (none) | — | — | — |"}
|
|
|
|
## Cross-Session Summary
|
|
- **Total Sessions:** ${summary.totalSessions}
|
|
- **Total Events:** ${summary.totalEvents}
|
|
- **Total Tool Calls:** ${summary.totalToolCalls}
|
|
- **Total Agent Turns:** ${summary.totalAgentTurns}
|
|
- **Total Errors:** ${summary.totalErrors}
|
|
- **Total Blocked:** ${summary.totalBlocked}
|
|
- **Total Tokens In:** ${fmtTokens(summary.totalTokensIn)}
|
|
- **Total Tokens Out:** ${fmtTokens(summary.totalTokensOut)}
|
|
- **Total Cost:** ${fmtCost(summary.totalCost)}
|
|
|
|
## Session History
|
|
| Session | Date | Duration | Model | Tools | Turns | Cost | Peak Ctx |
|
|
|---------|------|----------|-------|-------|-------|------|----------|
|
|
${sessionRows || "| (none) | — | — | — | — | — | — | — |"}
|
|
`;
|
|
|
|
const reportPath = path.join(obsDir, "report.md");
|
|
fs.writeFileSync(reportPath, report, "utf-8");
|
|
ctx.ui.notify(`📄 Report exported to .pi/observatory/report.md`, "success");
|
|
} catch (err) {
|
|
ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
}
|
|
}
|
|
|
|
// ── Commands ───────────────────────────────────────────────────────
|
|
|
|
pi.registerCommand("obs", {
|
|
description: "Open Observatory dashboard. Args: clear | export | agents | tools | timeline",
|
|
handler: async (args, ctx) => {
|
|
widgetCtx = ctx;
|
|
const arg = (args || "").trim().toLowerCase();
|
|
|
|
if (arg === "clear") {
|
|
clearAllData();
|
|
ctx.ui.notify("🔭 Observatory: All data cleared.", "info");
|
|
updateWidget();
|
|
return;
|
|
}
|
|
if (arg === "export") {
|
|
exportReport(ctx);
|
|
return;
|
|
}
|
|
|
|
let view = 0;
|
|
if (arg === "agents") view = 1;
|
|
else if (arg === "tools") view = 2;
|
|
else if (arg === "timeline") view = 3;
|
|
|
|
await openOverlay(ctx, view);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("observatory", {
|
|
description: "Open Observatory dashboard (alias for /obs)",
|
|
handler: async (_args, ctx) => {
|
|
widgetCtx = ctx;
|
|
await openOverlay(ctx, 0);
|
|
},
|
|
});
|
|
|
|
// ── Event Handlers ─────────────────────────────────────────────────
|
|
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
try {
|
|
applyExtensionDefaults(import.meta.url, ctx);
|
|
|
|
widgetCtx = ctx;
|
|
sessionId = shortId();
|
|
sessionStartTs = Date.now();
|
|
agentTurnCount = 0;
|
|
sessionEvents = [];
|
|
toolTimers.clear();
|
|
prevTokensIn = 0;
|
|
prevTokensOut = 0;
|
|
prevCost = 0;
|
|
|
|
// Initialize storage
|
|
obsDir = path.join(ctx.cwd, ".pi", "observatory");
|
|
eventsPath = path.join(obsDir, "events.jsonl");
|
|
summaryPath = path.join(obsDir, "summary.json");
|
|
ensureDir();
|
|
loadSummary();
|
|
|
|
// Create session summary
|
|
currentSession = emptySessionSummary(sessionId, ctx.model?.id);
|
|
summary.sessions.push(currentSession);
|
|
summary.totalSessions = summary.sessions.length;
|
|
|
|
// Log event
|
|
appendEvent({
|
|
ts: Date.now(),
|
|
type: "session_start",
|
|
sessionId,
|
|
contextPercent: getContextPercent(),
|
|
meta: { model: ctx.model?.id },
|
|
});
|
|
|
|
// Start flush timer
|
|
startFlushTimer();
|
|
|
|
// Set up UI
|
|
updateWidget();
|
|
updateFooter(ctx);
|
|
} catch {}
|
|
});
|
|
|
|
pi.on("tool_call", async (event, _ctx) => {
|
|
try {
|
|
const toolName = event.toolName;
|
|
|
|
// Record start time (FIFO queue per tool name)
|
|
if (!toolTimers.has(toolName)) {
|
|
toolTimers.set(toolName, []);
|
|
}
|
|
toolTimers.get(toolName)!.push(Date.now());
|
|
|
|
// Update counts
|
|
summary.toolCounts[toolName] = (summary.toolCounts[toolName] || 0) + 1;
|
|
summary.totalToolCalls++;
|
|
if (currentSession) currentSession.toolCalls++;
|
|
|
|
// Log event
|
|
appendEvent({
|
|
ts: Date.now(),
|
|
type: "tool_call",
|
|
sessionId,
|
|
tool: toolName,
|
|
contextPercent: getContextPercent(),
|
|
});
|
|
|
|
updateWidget();
|
|
} catch {}
|
|
|
|
// IMPORTANT: Never block — completely passive
|
|
return undefined;
|
|
});
|
|
|
|
pi.on("tool_execution_end", async (event) => {
|
|
try {
|
|
const toolName = event.toolName;
|
|
|
|
// Calculate duration (pop earliest timer for this tool)
|
|
let durationMs: number | undefined;
|
|
const timers = toolTimers.get(toolName);
|
|
if (timers && timers.length > 0) {
|
|
const startTs = timers.shift()!;
|
|
durationMs = Date.now() - startTs;
|
|
|
|
// Store duration (cap at 200 per tool)
|
|
if (!summary.toolDurations[toolName]) {
|
|
summary.toolDurations[toolName] = [];
|
|
}
|
|
summary.toolDurations[toolName].push(durationMs);
|
|
if (summary.toolDurations[toolName].length > 200) {
|
|
summary.toolDurations[toolName] = summary.toolDurations[toolName].slice(-200);
|
|
}
|
|
}
|
|
|
|
// Log event
|
|
appendEvent({
|
|
ts: Date.now(),
|
|
type: "tool_end",
|
|
sessionId,
|
|
tool: toolName,
|
|
durationMs,
|
|
});
|
|
|
|
updateWidget();
|
|
} catch {}
|
|
});
|
|
|
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
try {
|
|
agentTurnCount++;
|
|
agentTurnStartTs = Date.now();
|
|
|
|
summary.totalAgentTurns++;
|
|
if (currentSession) currentSession.agentTurns++;
|
|
|
|
const ctxPct = getContextPercent();
|
|
|
|
appendEvent({
|
|
ts: Date.now(),
|
|
type: "agent_start",
|
|
sessionId,
|
|
agentTurn: agentTurnCount,
|
|
contextPercent: ctxPct,
|
|
});
|
|
|
|
updateWidget();
|
|
} catch {}
|
|
|
|
// Don't modify the system prompt — return undefined
|
|
return undefined;
|
|
});
|
|
|
|
pi.on("agent_end", async (_event, ctx) => {
|
|
try {
|
|
widgetCtx = ctx;
|
|
const turnDuration = Date.now() - agentTurnStartTs;
|
|
const { tokensIn, tokensOut, cost } = computeTokensAndCost(ctx);
|
|
const ctxPct = getContextPercent();
|
|
|
|
// Update session summary
|
|
if (currentSession) {
|
|
currentSession.tokensIn = tokensIn;
|
|
currentSession.tokensOut = tokensOut;
|
|
currentSession.cost = cost;
|
|
if (ctxPct > currentSession.peakContextPercent) {
|
|
currentSession.peakContextPercent = ctxPct;
|
|
}
|
|
}
|
|
|
|
// Update global summary totals using deltas (so cross-session values accumulate)
|
|
const deltaIn = tokensIn - prevTokensIn;
|
|
const deltaOut = tokensOut - prevTokensOut;
|
|
const deltaCost = cost - prevCost;
|
|
summary.totalTokensIn += deltaIn;
|
|
summary.totalTokensOut += deltaOut;
|
|
summary.totalCost += deltaCost;
|
|
prevTokensIn = tokensIn;
|
|
prevTokensOut = tokensOut;
|
|
prevCost = cost;
|
|
|
|
appendEvent({
|
|
ts: Date.now(),
|
|
type: "agent_end",
|
|
sessionId,
|
|
agentTurn: agentTurnCount,
|
|
durationMs: turnDuration,
|
|
contextPercent: ctxPct,
|
|
tokensIn,
|
|
tokensOut,
|
|
cost,
|
|
});
|
|
|
|
// Flush summary on every agent end
|
|
markDirty();
|
|
saveSummary();
|
|
|
|
updateWidget();
|
|
updateFooter(ctx);
|
|
} catch {}
|
|
});
|
|
|
|
}
|