import { writeFile, mkdir, readdir, open, stat, rm } from 'fs/promises' import { dirname, join, resolve } from 'path' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' import { getCurrency, convertCost, roundForActiveCurrency } from './currency.js' import { dateKey } from './day-aggregator.js' import { aggregateModelEfficiency } from './model-efficiency.js' function escCsv(s: string): string { const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) { return `"${sanitized.replace(/"/g, '""')}"` } return sanitized } type Row = Record function rowsToCsv(rows: Row[]): string { if (rows.length === 0) return '' const headers = Object.keys(rows[0]) const lines = [headers.map(escCsv).join(',')] for (const row of rows) { lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(',')) } return lines.join('\n') + '\n' } function round2(n: number): number { return Math.round(n * 100) / 100 } function pct(n: number, total: number): number { return total > 0 ? round2((n / total) * 100) : 0 } type DailyAgg = { cost: number savings: number calls: number input: number output: number cacheRead: number cacheWrite: number sessions: Set } function buildDailyRows(projects: ProjectSummary[], period: string): Row[] { const daily: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const turn of session.turns) { if (!turn.timestamp) continue const day = dateKey(turn.timestamp) if (!daily[day]) { daily[day] = { cost: 0, savings: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() } } daily[day].sessions.add(session.sessionId) for (const call of turn.assistantCalls) { daily[day].cost += call.costUSD daily[day].savings += call.savingsUSD ?? 0 daily[day].calls++ daily[day].input += call.usage.inputTokens daily[day].output += call.usage.outputTokens daily[day].cacheRead += call.usage.cacheReadInputTokens daily[day].cacheWrite += call.usage.cacheCreationInputTokens } } } } const { code } = getCurrency() return Object.entries(daily).sort().map(([date, d]) => ({ Period: period, Date: date, [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), [`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)), 'API Calls': d.calls, Sessions: d.sessions.size, 'Input Tokens': d.input, 'Output Tokens': d.output, 'Cache Read Tokens': d.cacheRead, 'Cache Write Tokens': d.cacheWrite, })) } function buildRecordRows(projects: ProjectSummary[]): Row[] { const rows: Row[] = [] for (const project of projects) { for (const session of project.sessions) { for (const turn of session.turns) { for (const call of turn.assistantCalls) { rows.push({ project: project.projectPath, sessionId: session.sessionId, timestamp: call.timestamp || turn.timestamp || undefined, category: turn.category, provider: call.provider, subagentType: session.agentType?.trim() || undefined, model: call.model || undefined, inputTokens: call.usage.inputTokens, outputTokens: call.usage.outputTokens, reasoningTokens: call.usage.reasoningTokens, cacheWriteTokens: call.usage.cacheCreationInputTokens, cacheReadTokens: Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens), cost: roundForActiveCurrency(convertCost(call.costUSD)), savings: roundForActiveCurrency(convertCost(call.savingsUSD ?? 0)), }) } } } } return rows } function buildActivityRows(projects: ProjectSummary[], period: string): Row[] { const catTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [cat, d] of Object.entries(session.categoryBreakdown)) { if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0 } catTotals[cat].turns += d.turns catTotals[cat].cost += d.costUSD } } } const totalCost = Object.values(catTotals).reduce((s, d) => s + d.cost, 0) const { code } = getCurrency() return Object.entries(catTotals) .sort(([, a], [, b]) => b.cost - a.cost) .map(([cat, d]) => ({ Period: period, Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat, [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), 'Share (%)': pct(d.cost, totalCost), Turns: d.turns, })) } function buildModelRows(projects: ProjectSummary[], period: string): Row[] { const modelTotals: Record = {} const modelEfficiency = aggregateModelEfficiency(projects) for (const project of projects) { for (const session of project.sessions) { for (const [model, d] of Object.entries(session.modelBreakdown)) { if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savings: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } modelTotals[model].calls += d.calls modelTotals[model].cost += d.costUSD modelTotals[model].savings += d.savingsUSD modelTotals[model].input += d.tokens.inputTokens modelTotals[model].output += d.tokens.outputTokens modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0 modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0 } } } const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0) const { code } = getCurrency() return Object.entries(modelTotals) .filter(([name]) => name !== '') .sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)) .map(([model, d]) => { const efficiency = modelEfficiency.get(model) return { Period: period, Model: model, [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), [`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)), 'Share (%)': pct(d.cost, totalCost), 'API Calls': d.calls, 'Edit Turns': efficiency?.editTurns ?? 0, 'One-shot Rate (%)': efficiency?.oneShotRate ?? '', 'Retries/Edit': efficiency?.retriesPerEdit ?? '', [`Cost/Edit (${code})`]: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined ? roundForActiveCurrency(convertCost(efficiency.costPerEditUSD)) : '', 'Input Tokens': d.input, 'Output Tokens': d.output, 'Cache Read Tokens': d.cacheRead, 'Cache Write Tokens': d.cacheWrite, } }) } function buildToolRows(projects: ProjectSummary[]): Row[] { const toolTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [tool, d] of Object.entries(session.toolBreakdown)) { toolTotals[tool] = (toolTotals[tool] ?? 0) + d.calls } } } const total = Object.values(toolTotals).reduce((s, n) => s + n, 0) return Object.entries(toolTotals) .sort(([, a], [, b]) => b - a) .map(([tool, calls]) => ({ Tool: tool, Calls: calls, 'Share (%)': pct(calls, total), })) } function buildMcpRows(projects: ProjectSummary[]): Row[] { const mcpTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [server, d] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + d.calls } } } const total = Object.values(mcpTotals).reduce((s, n) => s + n, 0) return Object.entries(mcpTotals) .sort(([, a], [, b]) => b - a) .map(([server, calls]) => ({ Server: server, Calls: calls, 'Share (%)': pct(calls, total), })) } function buildBashRows(projects: ProjectSummary[]): Row[] { const bashTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [cmd, d] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + d.calls } } } const total = Object.values(bashTotals).reduce((s, n) => s + n, 0) return Object.entries(bashTotals) .sort(([, a], [, b]) => b - a) .map(([cmd, calls]) => ({ Command: cmd, Calls: calls, 'Share (%)': pct(calls, total), })) } function buildProjectRows(projects: ProjectSummary[]): Row[] { const { code } = getCurrency() const total = projects.reduce((s, p) => s + p.totalCostUSD, 0) return projects .slice() .sort((a, b) => (b.totalCostUSD + b.totalSavingsUSD) - (a.totalCostUSD + a.totalSavingsUSD)) .map(p => ({ Project: p.projectPath, [`Cost (${code})`]: roundForActiveCurrency(convertCost(p.totalCostUSD)), [`Saved (${code})`]: roundForActiveCurrency(convertCost(p.totalSavingsUSD)), [`Avg/Session (${code})`]: p.sessions.length > 0 ? roundForActiveCurrency(convertCost(p.totalCostUSD / p.sessions.length)) : '', 'Share (%)': pct(p.totalCostUSD, total), 'API Calls': p.totalApiCalls, Sessions: p.sessions.length, })) } function buildSessionRows(projects: ProjectSummary[]): Row[] { const { code } = getCurrency() const rows: Row[] = [] for (const p of projects) { for (const s of p.sessions) { const models = new Set( s.turns.flatMap(turn => turn.assistantCalls.map(call => call.model).filter(Boolean)), ) rows.push({ Project: p.projectPath, 'Session ID': s.sessionId, 'Started At': s.firstTimestamp ?? '', [`Cost (${code})`]: roundForActiveCurrency(convertCost(s.totalCostUSD)), [`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)), 'API Calls': s.apiCalls, Turns: s.turns.length, subagentType: s.agentType?.trim() || undefined, model: models.size === 1 ? [...models][0] : undefined, }) } } return rows.sort((a, b) => ((b[`Cost (${code})`] as number) + (b[`Saved (${code})`] as number)) - ((a[`Cost (${code})`] as number) + (a[`Saved (${code})`] as number))) } export type PeriodExport = { label: string projects: ProjectSummary[] } function buildSummaryRows(periods: PeriodExport[]): Row[] { const { code } = getCurrency() return periods.map(p => { const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0) const savings = p.projects.reduce((s, proj) => s + proj.totalSavingsUSD, 0) const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0) const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0) const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0 || proj.totalSavingsUSD > 0).length return { Period: p.label, [`Cost (${code})`]: roundForActiveCurrency(convertCost(cost)), [`Saved (${code})`]: roundForActiveCurrency(convertCost(savings)), 'API Calls': calls, Sessions: sessions, Projects: projectCount, } }) } function buildReadme(periods: PeriodExport[]): string { const { code } = getCurrency() const generated = new Date().toISOString() const lines = [ 'CodeBurn Usage Export', '====================', '', `Generated: ${generated}`, `Currency: ${code}`, `Periods: ${periods.map(p => p.label).join(', ')}`, '', 'Files', '-----', ' summary.csv One row per period. Headline totals.', ' daily.csv Day-by-day breakdown, Period column distinguishes the window.', ' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).', ' models.csv Spend per model with token totals and cache usage.', ' records.csv One row per API call, including optional subagentType and model.', ' projects.csv Spend per project folder for the selected detail period.', ' sessions.csv One row per session for the selected detail period.', ' tools.csv Tool invocations and share for the selected detail period.', ' mcp.csv MCP server invocations and share for the selected detail period.', ' shell-commands.csv Shell commands executed via Bash tool for the selected detail period.', '', 'Notes', '-----', ' Every cost column is already converted to the active currency. Tokens are raw integer', ' counts from provider telemetry. Share (%) is relative to the period/table total.', '', ] return lines.join('\n') } /// Sentinel file dropped into every folder we create so we can safely overwrite an older /// codeburn export without ever deleting a user's unrelated files by accident. const EXPORT_MARKER_FILE = '.codeburn-export' async function isCodeburnExportFolder(path: string): Promise { const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null) return markerStat?.isFile() ?? false } async function clearCodeburnExportFolder(path: string): Promise { const entries = await readdir(path) for (const entry of entries) { await rm(join(path, entry), { recursive: true, force: true }) } } /// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it /// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a /// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't /// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally). export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] let folder = resolve(outputPath) if (folder.toLowerCase().endsWith('.csv')) { folder = folder.slice(0, -4) } const existingStat = await stat(folder).catch(() => null) if (existingStat?.isFile()) { throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`) } if (existingStat?.isDirectory()) { if (!(await isCodeburnExportFolder(folder))) { throw new Error( `Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` + `Delete it manually or pick a different -o path.` ) } await clearCodeburnExportFolder(folder) } await mkdir(folder, { recursive: true }) await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8') const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label)) const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label)) const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label)) await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8') await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8') await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8') await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8') await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8') await writeFile(join(folder, 'records.csv'), rowsToCsv(buildRecordRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'mcp.csv'), rowsToCsv(buildMcpRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8') return folder } export async function exportJson(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] const { code, rate, symbol } = getCurrency() const data = { schema: 'codeburn.export.v2', generated: new Date().toISOString(), currency: { code, rate, symbol }, summary: buildSummaryRows(periods), periods: periods.map(p => ({ label: p.label, daily: buildDailyRows(p.projects, p.label), activity: buildActivityRows(p.projects, p.label), models: buildModelRows(p.projects, p.label), })), projects: buildProjectRows(thirtyDayProjects), sessions: buildSessionRows(thirtyDayProjects), records: buildRecordRows(thirtyDayProjects), tools: buildToolRows(thirtyDayProjects), mcp: buildMcpRows(thirtyDayProjects), shellCommands: buildBashRows(thirtyDayProjects), } const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`) // Refuse to overwrite an existing file that wasn't produced by codeburn // export. CSV path has the same guard via the .codeburn-export marker; JSON // was missing it, so a stray `-o ~/important.json` would silently clobber. const existing = await stat(target).catch(() => null) if (existing?.isFile()) { // Read just the first 4KB to look for the schema marker. The schema key // is the first field in the JSON object so a partial read is enough; // loading the whole file (potentially gigabytes) into memory could OOM // on Node's ~512MB string limit. const fh = await open(target, 'r') try { const buf = Buffer.alloc(4096) const { bytesRead } = await fh.read(buf, 0, buf.length, 0) const head = buf.toString('utf-8', 0, bytesRead) if (!head.includes('"schema": "codeburn.export.v')) { throw new Error( `Refusing to overwrite ${target}: file does not look like a codeburn export. ` + `Delete it manually or pick a different -o path.` ) } } finally { await fh.close() } } if (existing?.isDirectory()) { throw new Error(`Refusing to overwrite directory at ${target}. Pass a file path instead.`) } await mkdir(dirname(target), { recursive: true }) await writeFile(target, JSON.stringify(data, null, 2), 'utf-8') return target }