// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation // reads `~/.claude/projects//.jsonl` and sums the token counts each assistant // message reports; this ports the token extraction and DROPS the per-message account-attribution // timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so // there is nothing to attribute per message. // // The fields are ported from the port map: assistant lines carry // `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`, // `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`. import { createInterface } from "node:readline"; import { createReadStream } from "node:fs"; /** One session's totals — the session-grain usage row ADR 0054 fixes. */ export interface SessionUsage { sessionId: string; model: string | null; gitBranch: string | null; cwd: string | null; turns: number; inputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; outputTokens: number; costUSD: number; startedAt: string | null; lastActive: string | null; } interface Line { type?: string; timestamp?: string; cwd?: string; gitBranch?: string; costUSD?: number; message?: { model?: string; usage?: { input_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; output_tokens?: number; }; }; } function empty(sessionId: string): SessionUsage { return { sessionId, model: null, gitBranch: null, cwd: null, turns: 0, inputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUSD: 0, startedAt: null, lastActive: null, }; } /** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */ export function foldLine(acc: SessionUsage, raw: string): SessionUsage { const line = parse(raw); if (!line) return acc; if (line.timestamp) { if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp; if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp; } if (line.type === "user") { if (line.cwd) acc.cwd = line.cwd; if (line.gitBranch) acc.gitBranch = line.gitBranch; } if (line.type === "assistant") { acc.turns += 1; const u = line.message?.usage ?? {}; acc.inputTokens += u.input_tokens ?? 0; acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0; acc.cacheReadTokens += u.cache_read_input_tokens ?? 0; acc.outputTokens += u.output_tokens ?? 0; acc.costUSD += line.costUSD ?? 0; if (!acc.model && line.message?.model) acc.model = line.message.model; } return acc; } function parse(raw: string): Line | null { const trimmed = raw.trim(); if (!trimmed) return null; try { return JSON.parse(trimmed) as Line; } catch { // A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and // a half-written last line is ordinary. return null; } } /** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */ export function foldTranscript(sessionId: string, text: string): SessionUsage { return text.split("\n").reduce(foldLine, empty(sessionId)); } /** Stream one `.jsonl` file line by line, so a large transcript never loads whole. */ export async function readSessionFile(path: string, sessionId: string): Promise { const acc = empty(sessionId); const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); for await (const line of rl) foldLine(acc, line); return acc; }