codeburn/src/export.ts
ozymandiashh a55251ce8f
feat(export): subagentType and model on per-record exports; --by-agent follow-ups (#712)
Follow-ups to #710, completing issue #708's second ask plus the two review
notes:

- export: new records.csv with one row per call incl. subagentType and
  model; sessions.csv gains both fields APPENDED after all legacy columns
  so by-index consumers are unaffected; JSON fields additive; formula-
  injection escaping preserved
- models --by-agent: the main-session bucket sentinel becomes '(main)' -
  agentType is free-form text, so a real subagent literally named 'main'
  no longer merges into the main bucket (test-pinned across all formats)
- README: note that the --min-cost 0.01 default can hide sub-cent agent
  buckets; --min-cost 0 shows the complete agent inventory
2026-07-16 16:35:33 -07:00

458 lines
18 KiB
TypeScript

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<string, string | number | undefined>
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<string>
}
function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
const daily: Record<string, DailyAgg> = {}
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<string, { turns: number; cost: number }> = {}
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<string, { calls: number; cost: number; savings: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
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 !== '<synthetic>')
.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<string, number> = {}
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<string, number> = {}
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<string, number> = {}
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<boolean> {
const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null)
return markerStat?.isFile() ?? false
}
async function clearCodeburnExportFolder(path: string): Promise<void> {
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<string> {
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<string> {
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
}