// lib/stats/token-usage.mjs // SKAL-2 — pure token-usage parser + cache-aware cost derivation for Voyage // observability. Captures MAIN-CONTEXT token/cost from the Claude Code // transcript (transcript_path). Each assistant record carries message.usage // with a per-REQUEST snapshot. // // Verified 2026-06-26 against a real local transcript (216 lines): 70 // assistant records collapse to 31 distinct requestIds — records duplicate // per requestId (streamed snapshots), so we dedup by requestId keeping the // LAST occurrence (GH #28197 streaming-placeholder mitigation), then sum // across requests. The main transcript carried zero isSidechain:true records. // // v1 scope = MAIN-CONTEXT only. Sub-agent (swarm) turns live in separate // agent-*.jsonl siblings and are a documented v2 follow-on — so the // main-transcript sum UNDER-counts total Voyage cost. Every record is stamped // scope:'main-context' so no reader mistakes cost_usd for the session total. // // The four core functions (parseTranscriptUsage, deriveCost, buildRecord, // upsertSessionRecord) are PURE (no I/O). captureTokenUsage is the impure // shell that wires them to the filesystem (read transcript → upsert jsonl). // // Zero npm dependencies. Node stdlib only. import { readFileSync, existsSync, writeFileSync, renameSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; // Per-Mtok USD prices, resolved 2026-06-26 via the claude-api skill reference: // base input/output from the model table; cache rates from the prompt-caching // doc multipliers (cache_read 0.1x, write_5m 1.25x, write_1h 2.0x of input). // claude-fable-5 resolved 2026-07-02 from the official platform pricing docs. export const PRICE_TABLE = Object.freeze({ 'claude-opus-4-8': Object.freeze({ input: 5.0, output: 25.0, cache_read: 0.5, cache_write_5m: 6.25, cache_write_1h: 10.0, }), 'claude-fable-5': Object.freeze({ input: 10.0, output: 50.0, cache_read: 1.0, cache_write_5m: 12.5, cache_write_1h: 20.0, }), }); // Date the PRICE_TABLE values were resolved/verified. Bump when prices change. export const PRICE_TABLE_VERSION = '2026-07-02'; function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0; } /** * Single pass over transcript JSONL text. Keeps only main-chain assistant * records (type==='assistant', isSidechain!==true), dedups by requestId * keeping the LAST occurrence (per-request usage snapshots duplicate across * streamed records), and sums the four usage token fields. Malformed lines * are skipped. * * @returns {{tokens_input:number, tokens_output:number, * tokens_cache_creation:number, tokens_cache_read:number}} */ export function parseTranscriptUsage(text) { const lines = (text || '').split('\n'); // requestId -> usage (last occurrence wins). Records without a requestId // get a unique sentinel key so each is still counted exactly once. const byRequest = new Map(); let anon = 0; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') continue; let obj; try { obj = JSON.parse(trimmed); } catch { continue; } if (!obj || obj.type !== 'assistant') continue; if (obj.isSidechain === true) continue; const usage = obj.message && obj.message.usage; if (!usage || typeof usage !== 'object') continue; const key = typeof obj.requestId === 'string' && obj.requestId ? obj.requestId : `__anon_${anon++}`; byRequest.set(key, usage); // last occurrence wins } const totals = { tokens_input: 0, tokens_output: 0, tokens_cache_creation: 0, tokens_cache_read: 0, }; for (const usage of byRequest.values()) { totals.tokens_input += num(usage.input_tokens); totals.tokens_output += num(usage.output_tokens); totals.tokens_cache_creation += num(usage.cache_creation_input_tokens); totals.tokens_cache_read += num(usage.cache_read_input_tokens); } return totals; } /** * Cache-aware USD cost. Lumped cache_creation is priced at the 5m write rate * (Claude Code's default cache TTL; verified the dominant case on a real * transcript — the 1h split is a documented v2 refinement). When the model is * absent from the table we REFUSE to estimate: {cost_usd:null, is_estimate:true}. * * @returns {{cost_usd:number|null, is_estimate:boolean}} */ export function deriveCost(totals, model, priceTable = PRICE_TABLE) { const price = priceTable && priceTable[model]; if (!price) return { cost_usd: null, is_estimate: true }; const t = totals || {}; const cost = (num(t.tokens_input) * price.input + num(t.tokens_output) * price.output + num(t.tokens_cache_creation) * price.cache_write_5m + num(t.tokens_cache_read) * price.cache_read) / 1_000_000; return { cost_usd: cost, is_estimate: false }; } /** * Build the flat numeric record. Stamps scope:'main-context' and * price_table_version. session_id is written for upsert keying but MUST be * stripped at export (CWE-212, enforced by the field allowlist in Step 2). * * @param {{sessionId:string, model:string, totals:object, * priceTable?:object, now:string}} args */ export function buildRecord({ sessionId, model, totals, priceTable = PRICE_TABLE, now }) { const t = totals || {}; const { cost_usd, is_estimate } = deriveCost(t, model, priceTable); return { ts: now, session_id: sessionId, scope: 'main-context', model, tokens_input: num(t.tokens_input), tokens_output: num(t.tokens_output), tokens_cache_creation: num(t.tokens_cache_creation), tokens_cache_read: num(t.tokens_cache_read), cost_usd, is_estimate, price_table_version: PRICE_TABLE_VERSION, }; } /** * Pure read-modify-write. Parses existing JSONL lines, REPLACES the line whose * session_id matches the record (dropping any further same-session dupes), * else APPENDS. Returns the new file text (one line per session, trailing * newline). One-line-per-session is what keeps cross-session sums from * double-counting (vs append-and-grow). */ export function upsertSessionRecord(existingText, record) { const lines = (existingText || '').split('\n'); const out = []; let replaced = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') continue; let obj; try { obj = JSON.parse(trimmed); } catch { out.push(trimmed); continue; } // preserve unparseable lines verbatim if (obj && obj.session_id === record.session_id) { if (!replaced) { out.push(JSON.stringify(record)); replaced = true; } // else: duplicate same-session line — drop it } else { out.push(trimmed); } } if (!replaced) out.push(JSON.stringify(record)); return out.join('\n') + '\n'; } /** * Last main-chain (non-sidechain) assistant model in the transcript. Used to * pick the price-table key. Returns null when no model is found (→ deriveCost * refuses to estimate). Pure. */ export function lastMainChainModel(text) { const lines = (text || '').split('\n'); let model = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') continue; let obj; try { obj = JSON.parse(trimmed); } catch { continue; } if (!obj || obj.type !== 'assistant' || obj.isSidechain === true) continue; const m = obj.message && obj.message.model; if (typeof m === 'string' && m) model = m; } return model; } /** * Impure capture shell (Step 4). Reads the transcript, derives main-context * token totals + cost, and UPSERTS a one-line-per-session record into * {dataDir}/token-usage-stats.jsonl via an atomic temp+rename write * (EXDEV mitigation: tmp lives in the same dir as the target). * * Returns the written record, or null when skipped (no path / no dataDir / * transcript not a readable regular file). Throws propagate to the caller — * the Stop hook wraps this in try/catch so capture stays fail-open. * * @param {{transcriptPath:string, sessionId:string, dataDir:string, * now?:string}} args */ export function captureTokenUsage({ transcriptPath, sessionId, dataDir, now }) { if (!transcriptPath || !dataDir) return null; let st; try { st = statSync(transcriptPath); } catch { return null; } if (!st.isFile()) return null; const text = readFileSync(transcriptPath, 'utf-8'); const totals = parseTranscriptUsage(text); const model = lastMainChainModel(text); const record = buildRecord({ sessionId, model, totals, now: now || new Date().toISOString(), }); const outPath = join(dataDir, 'token-usage-stats.jsonl'); const existing = existsSync(outPath) ? readFileSync(outPath, 'utf-8') : ''; const updated = upsertSessionRecord(existing, record); const tmpPath = join(dirname(outPath), '.token-usage-stats.jsonl.tmp'); writeFileSync(tmpPath, updated); renameSync(tmpPath, outPath); return record; }