/** * 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; } interface ObsSummary { totalSessions: number; totalEvents: number; totalToolCalls: number; totalAgentTurns: number; totalErrors: number; totalBlocked: number; totalTokensIn: number; totalTokensOut: number; totalCost: number; toolCounts: Record; toolDurations: Record; toolBlocked: Record; 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 — FIFO queue per tool const toolTimers: Map = 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 | 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 = { session_start: "🚀", session_switch: "🔀", session_fork: "🔱", tool_call: "🔧", tool_end: "✅", agent_start: "🤖", agent_end: "🏁", dispatch: "📤", error: "❌", blocked: "🚫", }; const colorMap: Record = { 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 {} }); }