UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

223 lines (222 loc) • 8.89 kB
// Session replay: render the append-only event log as a readable timeline // (CONCEPT.md §4, problem ③). Pure functions over the events so they are easy // to test; the CLI wires them to stdout. import fs from "node:fs"; import path from "node:path"; import { EventLog, sessionDir } from "./events.js"; import { extractRateLimit, formatRateLimit } from "./ratelimit.js"; import { summarizeActions, formatActionSummary } from "./policy.js"; import { formatCost } from "./pricing.js"; const C = { dim: "\x1b[2m", reset: "\x1b[0m", bold: "\x1b[1m", cyan: "\x1b[36m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", gray: "\x1b[90m", }; function relTime(fromMs, toMs) { const s = Math.max(0, (toMs - fromMs) / 1000); if (s < 10) return `+${s.toFixed(1)}s`; if (s < 600) return `+${Math.round(s)}s`; return `+${Math.round(s / 60)}m`; } function tally(names) { const counts = new Map(); for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1); return [...counts.entries()].map(([n, k]) => (k > 1 ? `${n}×${k}` : n)).join(", "); } function fmtTokens(n) { if (n < 1000) return String(n); if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + "k"; return (n / 1_000_000).toFixed(1) + "M"; } // Paths inside the project are shown relative to it; the log may come from // Windows or POSIX, so both separators count. export function relativeTarget(target, project) { if (!project) return target; const root = project.replace(/[\\/]+$/, ""); const lower = (x) => (/^[a-z]:/i.test(x) ? x.toLowerCase() : x); if (lower(target).startsWith(lower(root)) && /[\\/]/.test(target.charAt(root.length))) { return target.slice(root.length + 1); } return target; } // The same for project paths anywhere inside a sentence. export function shortenPaths(text, project) { if (!project) return text; const root = project.replace(/[\\/]+$/, ""); return text.split(root + "/").join("").split(root + "\\").join(""); } export function summarize(sessionId, events) { const s = { sessionId, agent: null, isolated: false, startedAt: null, requests: 0, turns: 0, messages: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, unpriced: 0, exitCode: undefined, }; let lastPrompt; for (const e of events) { if (e.type === "session_start") { s.agent = e.agent ?? null; s.startedAt = e.ts; } else if (e.type === "session_end") { s.exitCode = e.exitCode; } else if (e.type === "usage") { s.requests += 1; if (!e.background) { s.turns += 1; if (e.prompt && e.prompt !== lastPrompt) s.messages += 1; lastPrompt = e.prompt ?? lastPrompt; } s.input += e.in; s.output += e.out; s.cacheRead += e.cache_read; s.cacheWrite += e.cache_write; if (e.cost_usd === null) s.unpriced += 1; else s.costUsd += e.cost_usd; } } return s; } // A full, colorized timeline of one session. export function renderTimeline(events, color = true) { const c = color ? C : new Proxy({}, { get: () => "" }); const start = events.find((e) => e.type === "session_start"); const startMs = start ? Date.parse(start.ts) : events[0] ? Date.parse(events[0].ts) : Date.now(); const lines = []; let step = 0; const project = start?.type === "session_start" ? start.project : undefined; let lastQuota = null; const allTools = []; for (const e of events) { const at = `${c.gray}${relTime(startMs, Date.parse(e.ts)).padStart(6)}${c.reset}`; switch (e.type) { case "session_start": lines.push(`${c.bold}session start${c.reset} ${c.dim}· agent ${e.agent ?? "?"}${c.reset}`); break; case "usage": { const model = e.model ?? "?"; const cost = e.cost_usd === null ? "$? (price unknown)" : `$${e.cost_usd.toFixed(4)}`; // A call the agent made on its own: one dim line, so its cost is // visible without it posing as a step of the conversation. if (e.background) { lines.push(`${at} ${c.dim}· background call ${model} · in ${fmtTokens(e.in)} · out ${fmtTokens(e.out)} · ${cost}${c.reset}`); break; } step += 1; lines.push(`${at} ${c.cyan}turn ${step}${c.reset} ${c.dim}${model}${c.reset} · ` + `in ${fmtTokens(e.in)} · out ${c.green}${fmtTokens(e.out)}${c.reset} · ` + `cache ${fmtTokens(e.cache_read)}${e.cache_write ? `r/${fmtTokens(e.cache_write)}w` : ""} · ${cost}`); if (e.prompt) lines.push(` ${c.dim}prompt:${c.reset} ${e.prompt}`); if (e.text) lines.push(` ${c.dim}reply:${c.reset} ${e.text}`); if (e.tools && e.tools.length) { allTools.push(...e.tools); // With targets when recorded: "Edit src/app.ts, Bash npm test". const shown = e.calls ? e.calls.map((k) => (k.target ? `${k.tool} ${relativeTarget(k.target, project)}` : k.tool)).join(", ") : tally(e.tools); lines.push(` ${c.dim}tools:${c.reset} ${shown}`); } break; } case "ratelimit": { const snap = extractRateLimit(e.raw); const line = snap ? formatRateLimit(snap, Date.parse(e.ts)) : null; if (line && line !== lastQuota) { lines.push(`${at} ${c.yellow}${line}${c.reset}`); lastQuota = line; } break; } case "secret": lines.push(`${at} ${c.red}secret sent to the API${c.reset} ${e.kind} (${e.masked}) ${c.dim}· from ${shortenPaths(e.source, project)}${c.reset}`); break; case "decision": lines.push(`${at} ${e.decision === "deny" ? `${c.red}blocked` : `${c.yellow}asked you about`}${c.reset} ` + `${e.tool}${e.target ? ` ${relativeTarget(e.target, project)}` : ""} ${c.dim}· ${e.reason}${c.reset}`); break; case "budget": lines.push(e.state === "reached" ? `${at} ${c.red}budget reached${c.reset} ${c.dim}· ${e.reason} · actions need approval${c.reset}` : `${at} ${c.green}budget cleared${c.reset} ${c.dim}· ${e.reason}${c.reset}`); break; case "session_end": { const s = summarize("", events); lines.push(`${at} ${c.bold}session end${c.reset} ${c.dim}· ${s.turns} turn(s) · ` + `in ${fmtTokens(s.input)} · out ${fmtTokens(s.output)} · ` + `cache ${fmtTokens(s.cacheRead)}r/${fmtTokens(s.cacheWrite)}w · ` + formatCost(s.costUsd, s.unpriced, s.requests) + (e.exitCode != null ? ` · exit ${e.exitCode}` : "") + c.reset); const actions = formatActionSummary(summarizeActions(allTools)); if (actions) lines.push(` ${c.dim}actions:${c.reset} ${actions}`); break; } } } return lines.join("\n"); } // List sessions found on disk, newest first. export function listSessions(cwd) { const base = path.join(cwd, ".vantage", "sessions"); if (!fs.existsSync(base)) return []; const out = []; for (const id of fs.readdirSync(base)) { const log = new EventLog(path.join(sessionDir(cwd, id), "events.jsonl")); let events; try { events = log.readAll(); } catch { continue; } if (events.length === 0) continue; const summary = summarize(id, events); const meta = readMetaIsolated(cwd, id); if (meta != null) summary.isolated = meta; out.push(summary); } return out.sort((a, z) => (z.startedAt ?? "").localeCompare(a.startedAt ?? "")); } function readMetaIsolated(cwd, id) { const p = path.join(sessionDir(cwd, id), "meta.json"); if (!fs.existsSync(p)) return null; try { return Boolean(JSON.parse(fs.readFileSync(p, "utf8")).isolated); } catch { return null; } }