#!/usr/bin/env npx tsx import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; interface UsageCost { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number; } interface Usage { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; totalTokens?: number; cost?: UsageCost; } interface AssistantMessage { role?: string; provider?: string; usage?: Usage; timestamp?: number; } interface SessionEntry { type?: string; timestamp?: string; message?: AssistantMessage; } interface Totals { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; costInput: number; costOutput: number; costCacheRead: number; costCacheWrite: number; costTotal: number; assistantMessages: number; sessions: Set; } interface DayStats extends Totals { providers: Map; } interface Args { days: number; cwd: string; sessionsBase: string; } function createTotals(): Totals { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costInput: 0, costOutput: 0, costCacheRead: 0, costCacheWrite: 0, costTotal: 0, assistantMessages: 0, sessions: new Set(), }; } function createDayStats(): DayStats { return { ...createTotals(), providers: new Map(), }; } function addUsage(totals: Totals, usage: Usage, sessionFile: string): void { const cost = usage.cost ?? {}; totals.input += usage.input ?? 0; totals.output += usage.output ?? 0; totals.cacheRead += usage.cacheRead ?? 0; totals.cacheWrite += usage.cacheWrite ?? 0; totals.totalTokens += usage.totalTokens ?? 0; totals.costInput += cost.input ?? 0; totals.costOutput += cost.output ?? 0; totals.costCacheRead += cost.cacheRead ?? 0; totals.costCacheWrite += cost.cacheWrite ?? 0; totals.costTotal += cost.total ?? 0; totals.assistantMessages += 1; totals.sessions.add(sessionFile); } function encodeSessionDir(cwd: string): string { const normalized = cwd.startsWith("/") ? cwd.slice(1) : cwd; return `--${normalized.replace(/\//g, "-")}--`; } function localDayKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function parseArgs(): Args { const args = process.argv.slice(2); let days = 7; let cwd = process.cwd(); let sessionsBase = join(homedir(), ".pi", "agent", "sessions"); for (let i = 0; i < args.length; i++) { const arg = args[i]; if ((arg === "--days" || arg === "-n") && args[i + 1]) { days = Number.parseInt(args[++i], 10); } else if ((arg === "--cwd" || arg === "--dir" || arg === "-d") && args[i + 1]) { cwd = resolve(args[++i]); } else if (arg === "--sessions-base" && args[i + 1]) { sessionsBase = resolve(args[++i]); } else if (arg === "--help" || arg === "-h") { console.log(`Usage: scripts/stats.ts [options] Options: -n, --days Number of local calendar days to include (default: 7) -d, --dir, --cwd Project cwd to inspect (default: current cwd) --sessions-base Sessions base directory (default: ~/.pi/agent/sessions) -h, --help Show this help`); process.exit(0); } } if (!Number.isInteger(days) || days <= 0) { throw new Error("--days must be a positive integer"); } return { days, cwd: resolve(cwd), sessionsBase }; } function formatInt(value: number): string { return Math.round(value).toLocaleString("en-US"); } function formatCost(value: number): string { return `$${value.toFixed(4)}`; } function printTotals(label: string, totals: Totals): void { console.log( `${label.padEnd(16)} messages: ${String(totals.assistantMessages).padStart(5)} sessions: ${String(totals.sessions.size).padStart(3)} ` + `input: ${formatInt(totals.input).padStart(12)} output: ${formatInt(totals.output).padStart(10)} ` + `cache read: ${formatInt(totals.cacheRead).padStart(13)} cache write: ${formatInt(totals.cacheWrite).padStart(10)} ` + `total: ${formatInt(totals.totalTokens).padStart(13)} cost: ${formatCost(totals.costTotal).padStart(10)}`, ); } const { days, cwd, sessionsBase } = parseArgs(); const sessionsDir = join(sessionsBase, encodeSessionDir(cwd)); if (!existsSync(sessionsDir)) { throw new Error(`Sessions directory not found: ${sessionsDir}`); } const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); start.setDate(start.getDate() - days + 1); const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const stats = new Map(); const grandTotal = createTotals(); for (const file of readdirSync(sessionsDir)) { if (!file.endsWith(".jsonl")) continue; const path = join(sessionsDir, file); const lines = readFileSync(path, "utf8").split("\n"); for (const line of lines) { if (!line.trim()) continue; let entry: SessionEntry; try { entry = JSON.parse(line) as SessionEntry; } catch { continue; } if (entry.type !== "message" || entry.message?.role !== "assistant" || !entry.message.usage) continue; const timestamp = entry.message.timestamp !== undefined ? new Date(entry.message.timestamp) : new Date(entry.timestamp ?? 0); if (timestamp < start || timestamp >= end) continue; const dayKey = localDayKey(timestamp); let dayStats = stats.get(dayKey); if (!dayStats) { dayStats = createDayStats(); stats.set(dayKey, dayStats); } const provider = entry.message.provider ?? "unknown"; let providerStats = dayStats.providers.get(provider); if (!providerStats) { providerStats = createTotals(); dayStats.providers.set(provider, providerStats); } addUsage(dayStats, entry.message.usage, file); addUsage(providerStats, entry.message.usage, file); addUsage(grandTotal, entry.message.usage, file); } } console.log(`Usage for ${cwd}`); console.log(`Sessions: ${sessionsDir}`); console.log(`Period: ${localDayKey(start)} through ${localDayKey(new Date(end.getTime() - 1))} (${days} local days)`); console.log("".padEnd(160, "=")); for (const day of [...stats.keys()].sort()) { const dayStats = stats.get(day)!; printTotals(day, dayStats); for (const provider of [...dayStats.providers.keys()].sort()) { printTotals(` ${provider}`, dayStats.providers.get(provider)!); } } console.log("".padEnd(160, "-")); printTotals("TOTAL", grandTotal);