Files
calvana/extensions/observatory.ts
Omair Saleh 2fb612b1df feat: calvana application microsite + ship-log extension
- 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
2026-03-02 18:03:22 +08:00

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 {}
});
}