/** * Montagem do relatório agregado (texto puro e JSON). Sem dependências pi. */ import type { SessionAggregate, UsageTotals } from "./aggregate.ts"; import { formatCompact, formatCost, formatInt, formatZoned } from "./format.ts"; import { renderKimiSection, sanitize, type KimiResult } from "./kimi.ts"; export interface UsageReport { generatedAtMs: number; timeZone: string; kimi: KimiResult; piCode: | { available: true; sessionsDir: string; aggregate: SessionAggregate } | { available: false; reason: string }; } function totalsLine(label: string, t: UsageTotals): string { return ` ${label.padEnd(7)} ${formatCost(t.cost).padStart(9)} · msgs ${String(t.messages).padStart(4)} · tokens ${formatCompact(t.totalTokens).padStart(7)} (in ${formatCompact(t.input)} / out ${formatCompact(t.output)} / cache ${formatCompact(t.cacheRead + t.cacheWrite)})`; } function sortedEntries(record: Record): Array<[string, UsageTotals]> { return Object.entries(record).sort((a, b) => b[1].cost - a[1].cost); } /** Compara os campos relevantes de dois totais para deduplicar períodos aninhados. */ function sameTotals(a: UsageTotals, b: UsageTotals): boolean { return ( a.messages === b.messages && a.cost === b.cost && a.totalTokens === b.totalTokens && a.input === b.input && a.output === b.output && a.cacheRead + a.cacheWrite === b.cacheRead + b.cacheWrite ); } export function renderPiCodeSection(piCode: UsageReport["piCode"]): string[] { const lines: string[] = []; lines.push("PI-CODE (consumo local — sessões)"); if (!piCode.available) { lines.push(` ${piCode.reason}`); return lines; } const { aggregate: agg } = piCode; lines.push(` Fonte: ${piCode.sessionsDir} (${agg.files} arquivo(s)${agg.filesSkipped ? `, ${agg.filesSkipped} ilegível(is)` : ""})`); // Períodos são aninhados (Hoje ⊆ Semana ⊆ Mês ⊆ Total): só exibe o que agrega informação. lines.push(totalsLine("Hoje", agg.periods.today)); if (!sameTotals(agg.periods.week, agg.periods.today)) lines.push(totalsLine("Semana", agg.periods.week)); if (!sameTotals(agg.periods.month, agg.periods.week)) lines.push(totalsLine("Mês", agg.periods.month)); if (!sameTotals(agg.total, agg.periods.month)) lines.push(totalsLine("Total", agg.total)); const models = sortedEntries(agg.byModel); if (models.length > 0) { lines.push(" Por provider/modelo:"); for (const [key, t] of models) { lines.push(` ${key} — ${formatCost(t.cost)} · ${formatInt(t.totalTokens)} tokens · ${t.messages} msgs`); } } return lines; } export function renderReportText(report: UsageReport): string[] { const lines: string[] = []; lines.push(`USAGE — ${formatZoned(report.generatedAtMs, report.timeZone, true)} (${report.timeZone})`); lines.push(""); lines.push(...renderKimiSection(report.kimi, report.timeZone, report.generatedAtMs)); lines.push(""); lines.push(...renderPiCodeSection(report.piCode)); return lines; } /** Redação profunda: aplica sanitize() em toda string do payload (defesa em profundidade). */ function redactDeep(value: unknown, apiKey: string): unknown { if (typeof value === "string") return sanitize(value, apiKey); if (Array.isArray(value)) return value.map((v) => redactDeep(v, apiKey)); if (typeof value === "object" && value !== null) { const out: Record = {}; for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, apiKey); return out; } return value; } /** * JSON cru agregado — nunca inclui a chave da API. * Com `apiKey`, aplica redação profunda: mesmo que o servidor um dia ecoar a * chave no payload de sucesso, ela não sai no --json. Shape preservado. */ export function reportToJson(report: UsageReport, apiKey?: string): Record { const json: Record = { generatedAt: new Date(report.generatedAtMs).toISOString(), timeZone: report.timeZone, kimi: report.kimi.available ? { available: true, data: report.kimi.data } : { available: false, reason: report.kimi.reason }, piCode: report.piCode.available ? { available: true, sessionsDir: report.piCode.sessionsDir, files: report.piCode.aggregate.files, filesSkipped: report.piCode.aggregate.filesSkipped, periods: report.piCode.aggregate.periods, byProvider: report.piCode.aggregate.byProvider, byModel: report.piCode.aggregate.byModel, total: report.piCode.aggregate.total, } : { available: false, reason: report.piCode.reason }, }; return apiKey ? (redactDeep(json, apiKey) as Record) : json; }