codeburn/src/overview.ts

354 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Chalk, type ChalkInstance } from 'chalk'
import { homedir } from 'os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost as baseCost, getCurrency } from './currency.js'
import { findUnpricedModels, getShortModelName } from './models.js'
import { markEstimated } from './format.js'
import { dateKey } from './day-aggregator.js'
import type { DailyEntry } from './daily-cache.js'
import type { BudgetStatus, BudgetTier } from './budget.js'
// Display-only helpers. The shared formatters omit thousands separators and
// abbreviate; here we show full, comma-grouped numbers so the tables read like
// a precise statement. Aggregation uses raw numbers; these only affect render.
function formatCost(usd: number): string {
return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
}
function formatDisplayCost(amount: number): string {
const { rate } = getCurrency()
return formatCost(rate > 0 ? amount / rate : amount)
}
function formatTokens(n: number): string {
// Pin the locale so grouping is deterministic regardless of the host's
// locale (e.g. en-IN groups as 2,00,20,00,000 instead of 2,002,000,000).
return Math.round(n).toLocaleString('en-US')
}
// Integer counts (calls, sessions, turns, tool uses) — same locale pin so the
// overview output is byte-identical across machines.
function formatCount(n: number): string {
return n.toLocaleString('en-US')
}
function isAbsoluteProjectPath(path: string): boolean {
return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(path)
}
function projectName(p: ProjectSummary): string {
const path = p.projectPath
if (path) {
if (path === homedir()) return 'Home'
if (!isAbsoluteProjectPath(path)) return p.project || path
const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop()
if (base) return base
}
return p.project.split('-').filter(Boolean).pop() || p.project
}
type Col = { header: string; right?: boolean }
type OverviewBudget = {
tier: BudgetTier
status: BudgetStatus
inProgress: boolean
}
// Visible width, ignoring ANSI color codes, so padding stays aligned.
function vlen(s: string): number {
// eslint-disable-next-line no-control-regex
return s.replace(/\[[0-9;]*m/g, '').length
}
function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string {
const widths = cols.map((col, i) =>
Math.max(vlen(col.header), ...rows.map((r) => vlen(r[i] ?? ''))),
)
const pad = (s: string, w: number, right?: boolean): string => {
const fill = ' '.repeat(Math.max(0, w - vlen(s)))
return right ? fill + s : s + fill
}
const gap = ' ' // 2-space cell padding so columns breathe
const sep = gap + c.dim('│') + gap
const edge = c.dim('│')
const bar = (l: string, mid: string, r: string): string =>
c.dim(l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r)
const line = (cells: string[], header = false): string =>
edge + gap + cells.map((cell, i) => {
const padded = pad(cell, widths[i]!, cols[i]!.right)
return header ? c.bold(padded) : padded
}).join(sep) + gap + edge
return [
bar('┌', '┬', '┐'),
line(cols.map((col) => col.header), true),
bar('├', '┼', '┤'),
...rows.map((r) => line(r)),
bar('└', '┴', '┘'),
].join('\n')
}
/// The durable slice renderOverview needs: headline totals + the day set behind
/// them + how much of the total came from carried (expired-source) days.
export type OverviewDurable = {
cost: number
savingsUSD: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
}
export function renderOverview(
projects: ProjectSummary[],
opts: { label: string; color: boolean; budget?: OverviewBudget; durable?: OverviewDurable },
): string {
const c = new Chalk(opts.color ? {} : { level: 0 })
const heading = (text: string): string => c.cyan.bold(text)
const out: string[] = []
const durable = opts.durable
out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label))
out.push('')
if (projects.length === 0 && !(durable && durable.cost > 0)) {
out.push(c.dim(`No usage found for ${opts.label}.`))
return out.join('\n') + '\n'
}
let cost = 0, savings = 0, calls = 0, sessions = 0
let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0
const byProvider = new Map<string, { cost: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number; estimatedCost: number }>()
const byCat = new Map<string, { cost: number; turns: number }>()
const byTool = new Map<string, number>()
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
const byProject = new Map<string, { cost: number; sessions: number }>()
for (const p of projects) {
cost += p.totalCostUSD
savings += p.totalSavingsUSD
calls += p.totalApiCalls
sessions += p.sessions.length
const pname = projectName(p)
const pe = byProject.get(pname) ?? { cost: 0, sessions: 0 }
pe.cost += p.totalCostUSD
pe.sessions += p.sessions.length
byProject.set(pname, pe)
for (const s of p.sessions) {
inTok += s.totalInputTokens
outTok += s.totalOutputTokens
cacheR += s.totalCacheReadTokens
cacheW += s.totalCacheWriteTokens
for (const [m, d] of Object.entries(s.modelBreakdown)) {
const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0, estimatedCost: 0 }
e.cost += d.costUSD
e.calls += d.calls
e.estimatedCost += d.estimatedCostUSD ?? 0
e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
byModel.set(m, e)
}
for (const [cat, d] of Object.entries(s.categoryBreakdown)) {
const e = byCat.get(cat) ?? { cost: 0, turns: 0 }
e.cost += d.costUSD
e.turns += d.turns
byCat.set(cat, e)
}
for (const [tool, d] of Object.entries(s.toolBreakdown)) {
byTool.set(tool, (byTool.get(tool) ?? 0) + d.calls)
}
for (const t of s.turns) {
const day = dateKey(t.timestamp || t.assistantCalls[0]?.timestamp || '')
for (const call of t.assistantCalls) {
const tk = call.usage.inputTokens + call.usage.outputTokens + call.usage.cacheReadInputTokens + call.usage.cacheCreationInputTokens
const pv = byProvider.get(call.provider) ?? { cost: 0, tokens: 0 }
pv.cost += call.costUSD
pv.tokens += tk
byProvider.set(call.provider, pv)
if (day) {
const dd = byDay.get(day) ?? { cost: 0, tokens: 0, providers: new Set<string>() }
dd.cost += call.costUSD
dd.tokens += tk
dd.providers.add(call.provider)
byDay.set(day, dd)
}
}
}
}
}
// Headline totals and the day-resolved views (Daily, Highest-value days) come
// from the durable daily cache so they match the menubar exactly, carried
// (expired-source) days included. The per-tool / per-model / per-project
// breakdowns above stay live: they need surviving session detail.
if (durable) {
cost = durable.cost
savings = durable.savingsUSD
calls = durable.calls
sessions = durable.sessions
inTok = durable.inputTokens
outTok = durable.outputTokens
cacheR = durable.cacheReadTokens
cacheW = durable.cacheWriteTokens
byDay.clear()
for (const d of durable.days) {
byDay.set(d.date, {
cost: d.cost,
tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens,
providers: new Set(Object.keys(d.providers)),
})
}
}
const totalTokens = inTok + outTok + cacheR + cacheW
const cacheHitDenom = inTok + cacheR
const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0
// Totals
out.push(heading('Totals'))
const kv = (k: string, v: string): string => ' ' + c.dim(k.padEnd(11)) + v
out.push(kv('Cost', c.bold(formatCost(cost))))
out.push(kv('Tokens', formatTokens(totalTokens) + c.dim(' (breakdown below)')))
out.push(kv('Calls', formatCount(calls) + c.dim(' sessions ') + formatCount(sessions)))
out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`))
if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)')))
const unpriced = findUnpricedModels(
[...byModel.entries()].map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })),
)
if (unpriced.length > 0) {
const shown = unpriced.slice(0, 3)
.map((u) => `${u.model} (${formatTokens(u.tokens)} tok)`)
.join(', ')
const more = unpriced.length > 3 ? ` +${unpriced.length - 3} more` : ''
out.push(kv('Unpriced', c.yellow(`${unpriced.length} model${unpriced.length === 1 ? '' : 's'} at $0: `) + shown + more))
out.push(kv('', c.dim('Fix: codeburn model-alias "<model>" <known-model>')))
}
if (opts.budget) {
const label = opts.budget.tier === 'daily'
? 'Daily'
: opts.budget.tier === 'weekly'
? 'Weekly'
: 'Monthly'
const status = opts.budget.status
const pct = `${Math.floor(status.pct)}%`
const statusColor = status.state === 'over' ? c.red : status.state === 'warn' ? c.yellow : c.green
const projected = opts.budget.inProgress
? c.dim(` projected ${formatDisplayCost(status.projected)} by ${opts.budget.tier === 'monthly' ? 'month' : opts.budget.tier === 'weekly' ? 'week' : 'day'} end`)
: ''
out.push(' ' + statusColor(`${label} budget: ${formatDisplayCost(status.spent)} of ${formatDisplayCost(status.budget)} (${pct})`) + projected)
}
out.push('')
// Tokens breakdown: input / output / cache in (written) / cache out (read)
if (totalTokens > 0) {
const share = (n: number): string => `${Math.round((n / totalTokens) * 100)}%`
out.push(heading('Tokens'))
out.push(renderTable(c,
[{ header: 'Type' }, { header: 'Tokens', right: true }, { header: 'Share', right: true }],
[
['Input', formatTokens(inTok), share(inTok)],
['Output', formatTokens(outTok), share(outTok)],
['Cache in', formatTokens(cacheW), share(cacheW)],
['Cache out', formatTokens(cacheR), share(cacheR)],
['Total', formatTokens(totalTokens), '100%'],
],
))
out.push('')
}
// By tool (provider)
const providerRows = [...byProvider.entries()]
.filter(([, v]) => v.cost > 0 || v.tokens > 0)
.sort((a, b) => b[1].cost - a[1].cost)
if (providerRows.length) {
out.push(heading('By tool'))
out.push(renderTable(c,
[{ header: 'Tool' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Share', right: true }],
providerRows.map(([name, v]) => [name, formatCost(v.cost), formatTokens(v.tokens), cost > 0 ? `${Math.round((v.cost / cost) * 100)}%` : '0%']),
))
out.push('')
}
// Top models
const modelRows = [...byModel.entries()].filter(([, v]) => v.cost > 0 || v.tokens > 0).sort((a, b) => b[1].cost - a[1].cost).slice(0, 10)
if (modelRows.length) {
out.push(heading('Top models'))
out.push(renderTable(c,
[{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }],
modelRows.map(([m, v]) => [getShortModelName(m), markEstimated(formatCost(v.cost), v.estimatedCost > 0), formatCount(v.calls), formatTokens(v.tokens)]),
))
if (modelRows.some(([, v]) => v.estimatedCost > 0)) {
out.push(' ' + c.dim('~ estimated cost (priced from estimated tokens)'))
}
out.push('')
}
// Highest-value days
const topDays = [...byDay.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 5)
if (topDays.length) {
out.push(heading('Highest-value days'))
out.push(renderTable(c,
[{ header: '#' }, { header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }],
topDays.map(([d, v], i) => [String(i + 1), d, formatCost(v.cost), formatTokens(v.tokens)]),
))
out.push('')
}
// Top projects
const projRows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10)
if (projRows.length) {
out.push(heading('Top projects'))
out.push(renderTable(c,
[{ header: 'Project' }, { header: 'Cost', right: true }, { header: 'Sessions', right: true }],
projRows.map(([name, v]) => [name, formatCost(v.cost), formatCount(v.sessions)]),
))
out.push('')
}
// Daily
const dailyRows = [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
if (dailyRows.length) {
out.push(heading('Daily'))
out.push(renderTable(c,
[{ header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Providers' }],
dailyRows.map(([d, v]) => [d, formatCost(v.cost), formatTokens(v.tokens), [...v.providers].sort().join(', ')]),
))
out.push('')
}
// By activity
const catRows = [...byCat.entries()].filter(([, v]) => v.cost > 0 || v.turns > 0).sort((a, b) => b[1].cost - a[1].cost)
if (catRows.length) {
out.push(heading('By activity'))
out.push(renderTable(c,
[{ header: 'Activity' }, { header: 'Cost', right: true }, { header: 'Turns', right: true }],
catRows.map(([cat, v]) => [CATEGORY_LABELS[cat as TaskCategory] ?? cat, formatCost(v.cost), formatCount(v.turns)]),
))
out.push('')
}
// Tools
const toolRows = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)
if (toolRows.length) {
out.push(heading('Tools'))
out.push(renderTable(c,
[{ header: 'Tool' }, { header: 'Calls', right: true }],
toolRows.map(([t, n]) => [t, formatCount(n)]),
))
out.push('')
}
const topTool = providerRows[0]?.[0]
const topModel = modelRows[0] ? getShortModelName(modelRows[0][0]) : ''
const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : ''
out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`)
// When some of the period's total came from days whose session logs have since
// expired, say so once. The figure is real (preserved in the durable daily
// cache); it just can't be re-derived from surviving files anymore.
if (durable && durable.carriedCostUSD > 0) {
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}
return out.join('\n') + '\n'
}