import { join } from "node:path"; import type { UsageDeps } from "../shared/deps.ts"; export type PeriodKey = "today" | "thisWeek" | "lastWeek" | "allTime"; export interface UsageTurn { id: string; sessionId: string; timestamp: number; provider: string; model: string; input: number; output: number; cacheRead: number; cacheWrite: number; tokens: number; cost: number; project?: string; activeSkill?: string; mcpTools?: string[]; } export interface GroupTotals { key: string; sessions: Set; messages: number; input: number; output: number; cacheRead: number; cacheWrite: number; tokens: number; cost: number; } export interface PeriodStats { total: GroupTotals; providers: Map; modelsByProvider: Map>; } export interface OfflineScanResult { turns: UsageTurn[]; periods: Record; scannedFiles: number; } function mkTotals(key: string): GroupTotals { return { key, sessions: new Set(), messages: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, tokens: 0, cost: 0, }; } function addToTotals(target: GroupTotals, turn: UsageTurn): void { target.sessions.add(turn.sessionId); target.messages += 1; target.input += turn.input + turn.cacheWrite; target.output += turn.output; target.cacheRead += turn.cacheRead; target.cacheWrite += turn.cacheWrite; target.tokens += turn.input + turn.output + turn.cacheWrite; target.cost += turn.cost; } function parseTimestamp(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value > 1e12 ? value : value * 1000; } if (typeof value === "string") { const fromNum = Number(value); if (Number.isFinite(fromNum)) { return fromNum > 1e12 ? fromNum : fromNum * 1000; } const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function num(v: unknown): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; } function usageCost(v: unknown): number { if (typeof v === "number") return num(v); if (!v || typeof v !== "object") return 0; return num((v as Record).total); } function fallbackId(turn: Omit): string { return [ turn.timestamp, turn.provider, turn.model, turn.input, turn.output, turn.cacheRead, turn.cacheWrite, turn.cost, ].join("|"); } function startsOfPeriods(now: number): { today: number; thisWeek: number; lastWeek: number; } { const d = new Date(now); d.setHours(0, 0, 0, 0); const today = d.getTime(); const weekday = (d.getDay() + 6) % 7; const thisWeek = today - weekday * 24 * 60 * 60 * 1000; const lastWeek = thisWeek - 7 * 24 * 60 * 60 * 1000; return { today, thisWeek, lastWeek }; } function inPeriod( ts: number, p: PeriodKey, bounds: { today: number; thisWeek: number; lastWeek: number }, ): boolean { if (p === "allTime") return true; if (p === "today") return ts >= bounds.today; if (p === "thisWeek") return ts >= bounds.thisWeek; return ts >= bounds.lastWeek && ts < bounds.thisWeek; } async function* walkJsonlFiles( deps: UsageDeps, root: string, ): AsyncGenerator { let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean; }>; try { entries = (await deps.readDir(root, { withFileTypes: true, } as never)) as never; } catch { return; } for (const entry of entries) { const p = join(root, entry.name); if (entry.isDirectory()) { yield* walkJsonlFiles(deps, p); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { yield p; } } } function parseLine(line: string, sessionId: string): UsageTurn | null { let row: Record; try { row = JSON.parse(line) as Record; } catch { return null; } if (row?.type !== "message") return null; const message = row.message as Record | undefined; if (message?.role !== "assistant") return null; const provider = message?.provider; const model = message?.model; const usage = message?.usage as Record | undefined; if (typeof provider !== "string" || typeof model !== "string" || !usage) return null; const timestamp = parseTimestamp( row.timestamp ?? message?.timestamp ?? row.createdAt, ); if (timestamp == null) return null; const turnBase = { sessionId, timestamp, provider, model, input: num(usage.input), output: num(usage.output), cacheRead: num(usage.cacheRead), cacheWrite: num(usage.cacheWrite), tokens: num(usage.totalTokens), cost: usageCost(usage.cost), }; const id = typeof row.id === "string" && row.id.trim() ? row.id : fallbackId(turnBase); const mcpTools = extractMcpServers(message as Record); return { id, ...turnBase, mcpTools }; } const SKILL_NAME_RE = /; if (row?.type !== "message") return undefined; const message = row.message as Record | undefined; if (message?.role !== "user") return undefined; const content = message.content; if (!Array.isArray(content)) return undefined; for (const block of content) { if ( typeof block === "object" && block !== null && (block as Record).type === "text" ) { const text = (block as Record).text; if (typeof text === "string") { const match = SKILL_NAME_RE.exec(text); if (match) return match[1]; } } } } catch { // ignore parse errors } return undefined; } const BUILTIN_TOOLS = new Set([ // SDK core tools "bash", "read", "write", "edit", "grep", "ls", "find", // Agent framework tools "mcp", "subagent", "subagent_status", "get_subagent_result", "Agent", "ask_user_question", "plan_mode_question", "compress", "questionnaire", "web_search", ]); function extractMcpServers( message: Record, ): string[] | undefined { const content = message.content; if (!Array.isArray(content)) return undefined; const servers = new Set(); for (const block of content) { if ( typeof block === "object" && block !== null && (block as Record).type === "toolCall" ) { const name = (block as Record).name; if (typeof name !== "string") continue; if (BUILTIN_TOOLS.has(name)) continue; const firstSegment = name.split("_")[0]; if (firstSegment) servers.add(firstSegment); } } return servers.size > 0 ? [...servers] : undefined; } function projectFromCwd(cwd: unknown): string | undefined { if (typeof cwd !== "string" || !cwd) return undefined; const segments = cwd.replace(/\/+$/, "").split("/"); return segments[segments.length - 1] || undefined; } export async function scanOfflineUsage( deps: UsageDeps, options?: { refresh?: boolean; shouldCancel?: () => boolean }, ): Promise { void options?.refresh; const sessionsRoot = join(deps.agentDir(), "sessions"); const periods: Record = { today: { total: mkTotals("total"), providers: new Map(), modelsByProvider: new Map(), }, thisWeek: { total: mkTotals("total"), providers: new Map(), modelsByProvider: new Map(), }, lastWeek: { total: mkTotals("total"), providers: new Map(), modelsByProvider: new Map(), }, allTime: { total: mkTotals("total"), providers: new Map(), modelsByProvider: new Map(), }, }; if (!deps.exists(sessionsRoot)) { return { turns: [], periods, scannedFiles: 0 }; } const seen = new Set(); const turns: UsageTurn[] = []; let scannedFiles = 0; let processed = 0; for await (const file of walkJsonlFiles(deps, sessionsRoot)) { if (options?.shouldCancel?.()) break; scannedFiles += 1; let content = ""; try { content = await deps.readFile(file, "utf8"); } catch { continue; } const sessionId = file; let sessionProject: string | undefined; let activeSkill: string | undefined; for (const line of content.split(/\r?\n/)) { if (!line.trim()) continue; try { const parsed = JSON.parse(line) as Record; if (parsed?.type === "session" && parsed.cwd) { sessionProject = projectFromCwd(parsed.cwd); continue; } } catch { // fall through to existing parseLine logic } const skillName = extractSkillName(line); if (skillName !== undefined) { activeSkill = skillName; } const turn = parseLine(line, sessionId); if (!turn) continue; turn.project = sessionProject; turn.activeSkill = activeSkill; if (seen.has(turn.id)) continue; seen.add(turn.id); turns.push(turn); processed += 1; if (processed % 500 === 0) { await new Promise((resolve) => deps.setTimeout(resolve, 0)); } if (options?.shouldCancel?.()) break; } } const bounds = startsOfPeriods(deps.now()); for (const turn of turns) { for (const key of Object.keys(periods) as PeriodKey[]) { if (!inPeriod(turn.timestamp, key, bounds)) continue; const p = periods[key]; addToTotals(p.total, turn); let pg = p.providers.get(turn.provider); if (!pg) { pg = mkTotals(turn.provider); p.providers.set(turn.provider, pg); } addToTotals(pg, turn); let models = p.modelsByProvider.get(turn.provider); if (!models) { models = new Map(); p.modelsByProvider.set(turn.provider, models); } let mg = models.get(turn.model); if (!mg) { mg = mkTotals(turn.model); models.set(turn.model, mg); } addToTotals(mg, turn); } } return { turns, periods, scannedFiles }; } export interface InsightItem { category?: string; label: string; cost: number; detail: string; } export function buildInsights(turns: UsageTurn[]): InsightItem[] { const totalCost = turns.reduce((sum, t) => sum + t.cost, 0); const bySession = new Map(); for (const t of turns) { const list = bySession.get(t.sessionId) ?? []; list.push(t); bySession.set(t.sessionId, list); } const largeContext = turns .filter((t) => t.input + t.output + t.cacheRead + t.cacheWrite > 150_000) .reduce((s, t) => s + t.cost, 0); const largeUncached = turns .filter((t) => t.input > 100_000) .reduce((s, t) => s + t.cost, 0); const parallelWindowMs = 2 * 60 * 1000; const parallelCost = turns .filter((turn) => { const activeSessions = new Set(); for (const candidate of turns) { if ( Math.abs(candidate.timestamp - turn.timestamp) <= parallelWindowMs ) { activeSessions.add(candidate.sessionId); } if (activeSessions.size >= 4) return true; } return false; }) .reduce((sum, turn) => sum + turn.cost, 0); let longSessionCost = 0; for (const list of bySession.values()) { list.sort((a, b) => a.timestamp - b.timestamp); if ( list[list.length - 1].timestamp - list[0].timestamp >= 8 * 60 * 60 * 1000 ) { longSessionCost += list.reduce((s, t) => s + t.cost, 0); } } const top5 = [...bySession.values()] .map((list) => list.reduce((s, t) => s + t.cost, 0)) .sort((a, b) => b - a) .slice(0, 5) .reduce((a, b) => a + b, 0); const pct = (n: number) => totalCost > 0 ? `${((100 * n) / totalCost).toFixed(1)}%` : "0.0%"; const byProject = new Map(); for (const t of turns) { if (t.project) { byProject.set(t.project, (byProject.get(t.project) ?? 0) + t.cost); } } const maxCategoryEntries = 5; const allProjectEntries = [...byProject.entries()].sort( (a, b) => b[1] - a[1], ); const projectInsights: InsightItem[] = allProjectEntries .slice(0, maxCategoryEntries) .map(([project, cost]) => ({ category: "project", label: project, cost, detail: pct(cost), })); if (allProjectEntries.length > maxCategoryEntries) { const remainingCost = allProjectEntries .slice(maxCategoryEntries) .reduce((sum, [, c]) => sum + c, 0); projectInsights.push({ category: "project", label: `+${allProjectEntries.length - maxCategoryEntries} more`, cost: remainingCost, detail: pct(remainingCost), }); } // Skill insights const bySkill = new Map(); let hasAnySkill = false; for (const t of turns) { if (t.activeSkill) { hasAnySkill = true; const key = `/${t.activeSkill}`; bySkill.set(key, (bySkill.get(key) ?? 0) + t.cost); } else { bySkill.set("(no skill)", (bySkill.get("(no skill)") ?? 0) + t.cost); } } const allSkillEntries = hasAnySkill ? [...bySkill.entries()].sort((a, b) => b[1] - a[1]) : []; const skillInsights: InsightItem[] = allSkillEntries .slice(0, maxCategoryEntries) .map(([skill, cost]) => ({ category: "skill", label: skill, cost, detail: pct(cost), })); if (allSkillEntries.length > maxCategoryEntries) { const remainingCost = allSkillEntries .slice(maxCategoryEntries) .reduce((sum, [, c]) => sum + c, 0); skillInsights.push({ category: "skill", label: `+${allSkillEntries.length - maxCategoryEntries} more`, cost: remainingCost, detail: pct(remainingCost), }); } // MCP server insights const byMcp = new Map(); for (const t of turns) { if (t.mcpTools) { for (const server of t.mcpTools) { byMcp.set(server, (byMcp.get(server) ?? 0) + t.cost); } } } const allMcpEntries = [...byMcp.entries()].sort((a, b) => b[1] - a[1]); const mcpInsights: InsightItem[] = allMcpEntries .slice(0, maxCategoryEntries) .map(([server, cost]) => ({ category: "mcp", label: server, cost, detail: pct(cost), })); if (allMcpEntries.length > maxCategoryEntries) { const remainingCost = allMcpEntries .slice(maxCategoryEntries) .reduce((sum, [, c]) => sum + c, 0); mcpInsights.push({ category: "mcp", label: `+${allMcpEntries.length - maxCategoryEntries} more`, cost: remainingCost, detail: pct(remainingCost), }); } return [ ...projectInsights, ...skillInsights, ...mcpInsights, { category: "cost", label: "Parallel sessions", cost: parallelCost, detail: `${pct(parallelCost)} cost while >=4 active`, }, { category: "cost", label: "Large context", cost: largeContext, detail: `${pct(largeContext)} over 150k context`, }, { category: "cost", label: "Large uncached", cost: largeUncached, detail: `${pct(largeUncached)} over 100k input`, }, { category: "cost", label: "Long sessions", cost: longSessionCost, detail: `${pct(longSessionCost)} from 8h+ sessions`, }, { category: "cost", label: "Top-5 concentration", cost: top5, detail: `${pct(top5)} in top 5 sessions`, }, ]; }