UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

171 lines (170 loc) • 8.29 kB
// `vantage stats`: where the usage goes over days and projects. // // Everything comes from the session logs that already exist; nothing is // fetched. The quota share per project is the rise of the weekly window // during that project's sessions. The windows are account-wide, so other // Claude use at the same time (another session, claude.ai) counts too, and // the figure is labelled as approximate. import path from "node:path"; import { summarize } from "./replay.js"; import { extractRateLimit } from "./ratelimit.js"; import { formatCost } from "./pricing.js"; import { plain } from "./sanitize.js"; function windowRise(events, key) { const values = []; for (const e of events) { if (e.type !== "ratelimit") continue; const w = extractRateLimit(e.raw)?.unified?.windows.find((x) => x.key === key); if (w?.utilization != null) values.push(w.utilization); } if (values.length < 2) return 0; // A reset inside the session drops the value; count only the rises. let rise = 0; for (let i = 1; i < values.length; i++) rise += Math.max(0, values[i] - values[i - 1]); return rise; } function projectName(cwd) { return plain(cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? cwd); } export function sessionStat(ref, events) { const start = events.find((e) => e.type === "session_start"); if (!start) return null; const s = summarize(ref.sessionId, events); const end = events.find((e) => e.type === "session_end"); const firstTurn = events.find((e) => e.type === "usage" && !e.background && e.prompt); let tokens = 0; for (const e of events) if (e.type === "usage") tokens += e.in + e.out + e.cache_read + e.cache_write; return { ref, project: (start.type === "session_start" && start.project) || ref.cwd, startedMs: Date.parse(start.ts), messages: s.messages, requests: s.requests, costUsd: s.costUsd, unpriced: s.unpriced, tokens, filesChanged: end?.type === "session_end" ? (end.changes?.files.length ?? 0) : 0, firstPrompt: firstTurn?.type === "usage" ? (firstTurn.prompt ?? null) : null, quota5h: windowRise(events, "5h"), quota7d: windowRise(events, "7d"), }; } // --------------------------------------------------------------------------- // Rendering. const C = { dim: "\x1b[2m", reset: "\x1b[0m", bold: "\x1b[1m", cyan: "\x1b[36m" }; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function dayLabel(ms) { const d = new Date(ms); return `${DAYS[d.getDay()]} ${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.`; } function bar(fraction, width) { const n = Math.round(Math.min(1, Math.max(0, fraction)) * width); return "█".repeat(n) + "░".repeat(width - n); } 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"; } function pct(fraction) { return fraction >= 0.005 ? `+${Math.round(fraction * 100)}%` : "–"; } function fit(s, max) { const one = s.replace(/\s+/g, " ").trim(); return one.length > max ? one.slice(0, max - 1) + "…" : one; } function group(stats, key) { const out = new Map(); for (const s of stats) { const g = out.get(key(s)) ?? { sessions: 0, messages: 0, costUsd: 0, unpriced: 0, requests: 0, quota7d: 0, files: 0 }; g.sessions += 1; g.messages += s.messages; g.costUsd += s.costUsd; g.unpriced += s.unpriced; g.requests += s.requests; g.quota7d += s.quota7d; g.files += s.filesChanged; out.set(key(s), g); } return out; } export function renderStats(all, opts) { const c = opts.color === false ? new Proxy({}, { get: () => "" }) : C; const now = opts.nowMs ?? Date.now(); // Whole days: today and the days before it. const first = new Date(now); first.setHours(0, 0, 0, 0); first.setDate(first.getDate() - (opts.days - 1)); const stats = all.filter((s) => s.startedMs >= first.getTime() && s.startedMs <= now).sort((a, z) => a.startedMs - z.startedMs); const lines = []; const projects = new Set(stats.map((s) => s.project)); // Folder names for display; with the parent folder when two share a name. const label = new Map(); const byName = new Map(); for (const p of projects) byName.set(projectName(p), [...(byName.get(projectName(p)) ?? []), p]); for (const [n, paths] of byName) { for (const p of paths) { const parts = p.split(/[\\/]/).filter(Boolean); label.set(p, paths.length > 1 ? parts.slice(-2).join("/") : n); } } const show = (p) => label.get(p) ?? projectName(p); lines.push(`${c.bold}Usage${c.reset} ${c.dim}· last ${opts.days} day(s) · ${stats.length} session(s) in ${projects.size} project(s)${c.reset}`); if (stats.length === 0) { lines.push(""); lines.push(`${c.dim}No sessions in this period. Sessions started with \`vantage run\` show up here.${c.reset}`); return lines.join("\n"); } const total = group(stats, () => "all").get("all"); lines.push(`${c.dim}${total.messages} message(s) · ${formatCost(total.costUsd, total.unpriced, total.requests)} API-equivalent · ` + `weekly limit ≈ ${pct(total.quota7d)} · ${total.files} file change(s)${c.reset}`); // By day, every day of the period, busiest bar full width. lines.push(""); lines.push(`${c.bold}By day${c.reset}`); const byDay = group(stats, (s) => dayKey(s.startedMs)); const maxDay = Math.max(...[...byDay.values()].map((g) => g.costUsd), 0.000001); for (let d = new Date(first); d.getTime() <= now; d.setDate(d.getDate() + 1)) { const g = byDay.get(dayKey(d.getTime())); const label = dayLabel(d.getTime()).padEnd(11); if (!g) { lines.push(` ${c.dim}${label} –${c.reset}`); continue; } lines.push(` ${label} ${c.cyan}${bar(g.costUsd / maxDay, 12)}${c.reset} ${formatCost(g.costUsd, g.unpriced, g.requests).padEnd(9)} ` + `${c.dim}${String(g.sessions).padStart(2)} session(s) · ${g.messages} message(s) · weekly ${pct(g.quota7d)}${c.reset}`); } // By project, most expensive first. lines.push(""); lines.push(`${c.bold}By project${c.reset} ${c.dim}· weekly limit share is approximate: other use of the same subscription at the same time counts too${c.reset}`); const byProject = [...group(stats, (s) => s.project).entries()].sort((a, z) => z[1].costUsd - a[1].costUsd); const nameWidth = Math.min(24, Math.max(...byProject.map(([p]) => show(p).length))); for (const [project, g] of byProject) { lines.push(` ${fit(show(project), nameWidth).padEnd(nameWidth)} ${formatCost(g.costUsd, g.unpriced, g.requests).padEnd(9)} ` + `${c.dim}weekly ${pct(g.quota7d).padEnd(5)} ${String(g.sessions).padStart(2)} session(s) · ${g.messages} message(s) · ${g.files} file change(s)${c.reset}`); } // The sessions that cost the most, to see what drove the numbers. lines.push(""); lines.push(`${c.bold}Largest sessions${c.reset}`); for (const s of stats.filter((x) => x.requests > 0).sort((a, z) => z.costUsd - a.costUsd).slice(0, 5)) { const d = new Date(s.startedMs); const when = `${dayLabel(s.startedMs)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; lines.push(` ${formatCost(s.costUsd, s.unpriced, s.requests).padEnd(9)} ${c.dim}${when} ${fit(show(s.project), 16).padEnd(16)} ${fmtTokens(s.tokens).padStart(5)} tokens${c.reset} ` + `${fit(s.firstPrompt ?? "(no prompt captured)", 60)}`); lines.push(` ${c.dim}${" ".repeat(9)} vantage replay ${s.ref.sessionId}${opts.cwd && path.resolve(s.ref.cwd) === path.resolve(opts.cwd) ? "" : ` (in ${plain(s.ref.cwd)})`}${c.reset}`); } return lines.join("\n"); }