/** * Agregação pura do consumo local pi-code a partir de linhas JSONL de sessão. * * Uma linha relevante tem a forma: * {"type":"message","timestamp":"...ISO...","message":{"role":"assistant", * "provider":"...","model":"...","usage":{"input":n,"output":n, * "cacheRead":n,"cacheWrite":n,"totalTokens":n,"cost":{"total":n}}}} * * Funções puras e determinísticas: o instante "agora" é sempre parâmetro. */ import { localMidnightMs, parseInstantMs, toNumber, zonedDateParts } from "./format.ts"; export type PeriodKey = "today" | "week" | "month"; export interface UsageTotals { messages: number; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; } export interface ParsedUsageLine { timestampMs: number; provider: string; model: string; usage: UsageTotals; } export interface SessionAggregate { generatedAtMs: number; timeZone: string; files: number; filesSkipped: number; periods: Record; byProvider: Record; byModel: Record; total: UsageTotals; } export function emptyTotals(): UsageTotals { return { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; } function addTotals(target: UsageTotals, usage: UsageTotals): void { target.messages += usage.messages; target.cost += usage.cost; target.input += usage.input; target.output += usage.output; target.cacheRead += usage.cacheRead; target.cacheWrite += usage.cacheWrite; target.totalTokens += usage.totalTokens; } /** * Parse tolerante de uma linha JSONL. Retorna null para linhas irrelevantes * ou malformadas. Rejeição rápida: linhas sem a substring '"usage"' nem * passam pelo JSON.parse. */ export function parseSessionLine(line: string): ParsedUsageLine | null { if (!line.includes('"usage"')) return null; let entry: unknown; try { entry = JSON.parse(line); } catch { return null; } if (typeof entry !== "object" || entry === null) return null; const e = entry as Record; if (e.type !== "message") return null; const message = e.message as Record | undefined; if (!message || message.role !== "assistant") return null; const usage = message.usage as Record | undefined; if (!usage || typeof usage !== "object") return null; const timestampMs = parseInstantMs(e.timestamp) ?? parseInstantMs(message.timestamp); if (timestampMs === null) return null; const cost = usage.cost as Record | undefined; return { timestampMs, provider: typeof message.provider === "string" && message.provider ? message.provider : "desconhecido", model: typeof message.model === "string" && message.model ? message.model : "desconhecido", usage: { messages: 1, cost: toNumber(cost?.total), input: toNumber(usage.input), output: toNumber(usage.output), cacheRead: toNumber(usage.cacheRead), cacheWrite: toNumber(usage.cacheWrite), totalTokens: toNumber(usage.totalTokens ?? usage.total), }, }; } export interface PeriodBounds { todayStartMs: number; weekStartMs: number; monthStartMs: number; } /** Limites de hoje / semana (segunda) / mês no timezone informado, em ms UTC. */ export function periodBounds(nowMs: number, timeZone: string): PeriodBounds { const todayStartMs = localMidnightMs(nowMs, timeZone); const { y, m } = zonedDateParts(nowMs, timeZone); const monthStartMs = localMidnightMs(Date.UTC(y, m - 1, 1, 12), timeZone); // Segunda-feira da semana corrente: weekday local (0=dom..6=sáb). const weekdayName = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(new Date(nowMs)); const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(weekdayName); const daysSinceMonday = ((weekday < 0 ? 0 : weekday) + 6) % 7; const weekStartMs = localMidnightMs(todayStartMs - daysSinceMonday * 86_400_000 + 43_200_000, timeZone); return { todayStartMs, weekStartMs, monthStartMs }; } export function createAggregator(nowMs: number, timeZone: string) { const bounds = periodBounds(nowMs, timeZone); const agg: SessionAggregate = { generatedAtMs: nowMs, timeZone, files: 0, filesSkipped: 0, periods: { today: emptyTotals(), week: emptyTotals(), month: emptyTotals() }, byProvider: {}, byModel: {}, total: emptyTotals(), }; function add(parsed: ParsedUsageLine): void { addTotals(agg.total, parsed.usage); if (parsed.timestampMs >= bounds.monthStartMs) addTotals(agg.periods.month, parsed.usage); if (parsed.timestampMs >= bounds.weekStartMs) addTotals(agg.periods.week, parsed.usage); if (parsed.timestampMs >= bounds.todayStartMs) addTotals(agg.periods.today, parsed.usage); const byProvider = (agg.byProvider[parsed.provider] ??= emptyTotals()); addTotals(byProvider, parsed.usage); const modelKey = `${parsed.provider}/${parsed.model}`; const byModel = (agg.byModel[modelKey] ??= emptyTotals()); addTotals(byModel, parsed.usage); } function addLine(line: string): void { const parsed = parseSessionLine(line); if (parsed) add(parsed); } return { agg, add, addLine, bounds }; }