Merge main into feat/cline-provider to resolve conflicts

This commit is contained in:
iamtoruk 2026-05-16 05:58:10 -07:00
commit 59a4d95b18
75 changed files with 6743 additions and 1475 deletions

View file

@ -1,978 +1,15 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { renderDashboard } from './dashboard.js'
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
async function hydrateCache() {
try {
return await ensureCacheHydrated(
(range) => parseAllSessions(range, 'all'),
aggregateProjectsIntoDays,
)
} catch {
return emptyCache()
}
#!/usr/bin/env node
// This launcher must stay parseable by Node 18. Do NOT add static imports.
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 13)) {
process.stderr.write(
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
'Upgrade at https://nodejs.org/\n',
)
process.exit(1)
}
function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
}
function parseNumber(value: string): number {
return Number(value)
}
function parseInteger(value: string): number {
return parseInt(value, 10)
}
type JsonPlanSummary = {
id: PlanId
budget: number
spent: number
percentUsed: number
status: 'under' | 'near' | 'over'
projectedMonthEnd: number
daysUntilReset: number
periodStart: string
periodEnd: string
}
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
return {
id: planUsage.plan.id,
budget: convertCost(planUsage.budgetUsd),
spent: convertCost(planUsage.spentApiEquivalentUsd),
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
status: planUsage.status,
projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
daysUntilReset: planUsage.daysUntilReset,
periodStart: planUsage.periodStart.toISOString(),
periodEnd: planUsage.periodEnd.toISOString(),
}
}
function assertFormat(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
report.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(report, null, 2))
}
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
.option('--timezone <zone>', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
program.hook('preAction', async (thisCommand) => {
const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
if (tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
process.exit(1)
}
process.env.TZ = tz
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
await loadCurrency()
import('./main.js').catch((err) => {
process.stderr.write(String(err?.message ?? err) + '\n')
process.exit(1)
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
// Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
// consumer summing daily[].editTurns over a period gets the same total as
// sum(activities[].editTurns) for that period: every turn counts once for
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
// Prefer the user-message timestamp on the turn; fall back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions where the JSONL begins mid-conversation). Previously these
// turns dropped from daily but stayed in activities, breaking the
// sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].calls += 1
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
calls: d.calls,
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
// Pre-computed convenience for dashboards that don't want to do the math.
// null when there are no edit turns (the rate is undefined, not zero —
// a day where the user only had Q&A turns shouldn't read as 0% one-shot).
oneShotRate: d.editTurns > 0
? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
: null,
}))
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
oneShotRate: efficiency?.oneShotRate ?? null,
retriesPerEdit: efficiency?.retriesPerEdit ?? null,
costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined
? convertCost(efficiency.costPerEditUSD)
: null,
}
})
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
}))
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
}
for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
}
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
}
const sortedMap = (m: Record<string, number>) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
return {
generated: new Date().toISOString(),
currency: code,
period,
periodKey,
overview: {
cost: convertCost(totalCostUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
tokens: {
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
},
},
daily,
projects: projectList,
models,
activities,
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const period = toPeriod(opts.period)
if (opts.format === 'json') {
await loadPricing()
await hydrateCache()
if (customRange) {
const label = formatDateRangeLabel(opts.from, opts.to)
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
await hydrateCache()
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
inputTokens += sess.totalInputTokens
outputTokens += sess.totalOutputTokens
cacheReadTokens += sess.totalCacheReadTokens
cacheWriteTokens += sess.totalCacheWriteTokens
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
catTotals[cat].turns += d.turns
catTotals[cat].cost += d.costUSD
catTotals[cat].editTurns += d.editTurns
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
}
}
return {
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, ...d })),
}
}
program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
const periodInfo = getDateRange(opts.period)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const isAllProviders = pf === 'all'
const cache = await hydrateCache()
// CURRENT PERIOD DATA
// - .all provider: assemble from cache + today (fast)
// - specific provider: parse the period range with provider filter (correct, but slower)
let currentData: PeriodData
let scanProjects: ProjectSummary[]
let scanRange: DateRange
if (isAllProviders) {
// Parse only today's sessions; historical data comes from cache to avoid double-counting
const todayRange: DateRange = { start: todayStart, end: new Date() }
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
const todayDays = aggregateProjectsIntoDays(todayProjects)
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = periodInfo.range
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
}
// PROVIDERS
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
// For specific: just this single provider with its scoped cost.
const allProviders = await getAllProviders()
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
const providers: ProviderCost[] = []
if (isAllProviders) {
// Parse only today; historical provider costs come from cache
const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
const rangeStartStr = toDateString(periodInfo.range.start)
const todayStr = toDateString(todayStart)
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
...todayDaysForProviders.filter(d => d.date === todayStr),
]
const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {
providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
}
}
for (const [name, cost] of Object.entries(providerTotals)) {
providers.push({ name: displayNameByName.get(name) ?? name, cost })
}
for (const p of allProviders) {
if (providers.some(pc => pc.name === p.displayName)) continue
const sources = await p.discoverSessions()
if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
}
} else {
const display = displayNameByName.get(pf) ?? pf
providers.push({ name: display, cost: currentData.cost })
}
// DAILY HISTORY (last 365 days)
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
// Parse only today for history; historical days come from cache
const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
const todayStrForHistory = toDateString(todayStart)
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, 5)
.map(([name, m]) => ({
name,
cost: m.cost,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
}))
return {
date: d.date,
cost: d.cost,
calls: d.calls,
inputTokens: d.inputTokens,
outputTokens: d.outputTokens,
cacheReadTokens: d.cacheReadTokens,
cacheWriteTokens: d.cacheWriteTokens,
topModels,
}
}
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
return
}
if (opts.format === 'json') {
await hydrateCache()
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const { code, rate } = getCurrency()
const payload: {
currency: string
today: { cost: number; calls: number }
month: { cost: number; calls: number }
plan?: JsonPlanSummary
} = {
currency: code,
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
}
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
payload.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(payload))
return
}
await hydrateCache()
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'today')
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await hydrateCache()
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'month')
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
await hydrateCache()
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('export')
.description('Export usage data to CSV or JSON')
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
assertFormat(opts.format, ['csv', 'json'], 'export')
await loadPricing()
await hydrateCache()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const periods: PeriodExport[] = customRange
? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
: [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
if (periods.every(p => p.projects.length === 0)) {
console.log('\n No usage data found.\n')
return
}
const defaultName = `codeburn-${toDateString(new Date())}`
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string
try {
if (opts.format === 'json') {
savedPath = await exportJson(periods, outputPath)
} else {
savedPath = await exportCsv(periods, outputPath)
}
} catch (err) {
// Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
// throw with a user-readable message. Print just the message, not the stack, so the CLI
// doesn't spray its internals at the user.
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Export failed: ${message}\n`)
process.exit(1)
}
const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
})
program
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')
.option('--force', 'Reinstall even if an older copy is already in ~/Applications')
.action(async (opts: { force?: boolean }) => {
try {
const result = await installMenubarApp({ force: opts.force })
console.log(`\n Ready. ${result.installedPath}\n`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Menubar install failed: ${message}\n`)
process.exit(1)
}
})
program
.command('currency [code]')
.description('Set display currency (e.g. codeburn currency GBP)')
.option('--symbol <symbol>', 'Override the currency symbol')
.option('--reset', 'Reset to USD (removes currency config)')
.action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => {
if (opts?.reset) {
const config = await readConfig()
delete config.currency
await saveConfig(config)
console.log('\n Currency reset to USD.\n')
return
}
if (!code) {
const { code: activeCode, rate, symbol } = getCurrency()
if (activeCode === 'USD' && rate === 1) {
console.log('\n Currency: USD (default)')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log(`\n Currency: ${activeCode}`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${activeCode}`)
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
const upperCode = code.toUpperCase()
if (!isValidCurrencyCode(upperCode)) {
console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`)
process.exitCode = 1
return
}
const config = await readConfig()
config.currency = {
code: upperCode,
...(opts?.symbol ? { symbol: opts.symbol } : {}),
}
await saveConfig(config)
await loadCurrency()
const { rate, symbol } = getCurrency()
console.log(`\n Currency set to ${upperCode}.`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${upperCode}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('model-alias [from] [to]')
.description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
.option('--remove <from>', 'Remove an alias')
.option('--list', 'List configured aliases')
.action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const aliases = config.modelAliases ?? {}
if (opts?.list || (!from && !opts?.remove)) {
const entries = Object.entries(aliases)
if (entries.length === 0) {
console.log('\n No model aliases configured.')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log('\n Model aliases:')
for (const [src, dst] of entries) {
console.log(` ${src} -> ${dst}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!(opts.remove in aliases)) {
console.error(`\n Alias not found: ${opts.remove}\n`)
process.exitCode = 1
return
}
delete aliases[opts.remove]
config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined
await saveConfig(config)
console.log(`\n Removed alias: ${opts.remove}\n`)
return
}
if (!from || !to) {
console.error('\n Usage: codeburn model-alias <from> <to>\n')
process.exitCode = 1
return
}
aliases[from] = to
config.modelAliases = aliases
await saveConfig(config)
console.log(`\n Alias saved: ${from} -> ${to}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')
.option('--format <format>', 'Output format: text or json', 'text')
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
const mode = action ?? 'show'
if (mode === 'show') {
const plan = await readPlan()
const displayPlan = !plan || plan.id === 'none'
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
: {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt,
}
if (opts?.format === 'json') {
console.log(JSON.stringify(displayPlan))
return
}
if (!plan || plan.id === 'none') {
console.log('\n Plan: none')
console.log(' API-pricing view is active.')
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Provider: ${plan.provider}`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
console.log(` Set at: ${plan.setAt}`)
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
if (mode === 'reset') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (mode !== 'set') {
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
process.exitCode = 1
return
}
if (!id || !isPlanId(id)) {
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
process.exitCode = 1
return
}
const resetDay = opts?.resetDay ?? 1
if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
process.exitCode = 1
return
}
if (id === 'none') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (id === 'custom') {
if (opts?.monthlyUsd === undefined) {
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
process.exitCode = 1
return
}
const monthlyUsd = opts.monthlyUsd
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
process.exitCode = 1
return
}
const provider = opts?.provider ?? 'all'
if (!isPlanProvider(provider)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
process.exitCode = 1
return
}
await savePlan({
id: 'custom',
monthlyUsd,
provider,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
return
}
const preset = getPresetPlan(id)
if (!preset) {
console.error(`\n Unknown preset "${id}".\n`)
process.exitCode = 1
return
}
await savePlan({
...preset,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
console.log(` Provider: ${preset.provider}`)
console.log(` Reset day: ${resetDay}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('optimize')
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
await hydrateCache()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
await runOptimize(projects, label, range)
})
program
.command('compare')
.description('Compare two AI models side-by-side')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
await hydrateCache()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
})
program
.command('models')
.description('Per-model token + cost table, optionally exploded by task type')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--task <category>', 'Filter to one task type (e.g. feature, debugging, refactoring)')
.option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()
await hydrateCache()
let range
if (opts.from || opts.to) {
const customRange = parseDateRangeFlags(opts.from, opts.to)
if (!customRange) {
process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n')
process.exit(1)
}
range = customRange
} else {
range = getDateRange(opts.period).range
}
const projects = await parseAllSessions(range, opts.provider)
const rows = await aggregateModels(projects, {
byTask: !!opts.byTask,
taskFilter: opts.task,
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
})
const fmt = (opts.format ?? 'table').toLowerCase()
if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
process.stdout.write('No model usage found for the selected period.\n')
return
}
if (fmt === 'json') {
process.stdout.write(renderJson(rows) + '\n')
} else if (fmt === 'csv') {
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
} else if (fmt === 'markdown' || fmt === 'md') {
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
}
})
program
.command('yield')
.description('Track which AI spend shipped to main vs reverted/abandoned (experimental)')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'week')
.action(async (opts) => {
const { computeYield, formatYieldSummary } = await import('./yield.js')
await loadPricing()
await hydrateCache()
const { range, label } = getDateRange(opts.period)
console.log(`\n Analyzing yield for ${label}...\n`)
const summary = await computeYield(range, process.cwd())
console.log(formatYieldSummary(summary))
})
program.parse()

View file

@ -5,24 +5,19 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 5 alongside the Cursor per-project breakdown: prior daily
// entries recorded every Cursor session under a single 'cursor' project
// label. After the upgrade, the breakdown produces per-workspace project
// labels for new days; without invalidation the dashboard would show
// 'cursor' for historical days and `-Users-you-myproject` for new ones
// in the same window, producing a confusing mixed projection.
export const DAILY_CACHE_VERSION = 5
// MIN_SUPPORTED_VERSION bumped to 5 too. The migration path
// Bumped to 6 alongside the Claude 1-hour cache-write pricing fix: prior
// daily entries priced all Claude cache writes at the 5-minute rate, so
// cached historical cost/model/provider/category totals would remain
// under-reported unless discarded and recomputed from raw sessions.
export const DAILY_CACHE_VERSION = 6
// MIN_SUPPORTED_VERSION bumped to 6 too. The migration path
// (isMigratableCache + migrateDays) only fills in missing default fields;
// it does NOT recompute the providers / categories / models rollups from
// session data, because those raw sessions are not stored in the cache.
// So a migrated v2/v3/v4 cache would carry forward stale provider totals
// (single 'cursor' bucket instead of per-workspace) for the full cache
// retention window. Setting the floor to 5 forces those older caches to
// be discarded and recomputed cleanly. Confirmed by live test:
// menubar-json --period all reported cursor=$3.78 against a migrated
// v4 cache but $4.08 (correct) after the cache was discarded.
const MIN_SUPPORTED_VERSION = 5
// So a migrated v5 cache would carry forward stale pricing totals for
// the full cache retention window. Setting the floor to 6 forces older
// caches to be discarded and recomputed cleanly.
const MIN_SUPPORTED_VERSION = 6
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {

View file

@ -9,13 +9,12 @@ import { parseAllSessions, filterProjectsByName } from './parser.js'
import { loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js'
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
import { join } from 'path'
import { patchStdoutForWindows } from './ink-win.js'
type View = 'dashboard' | 'optimize' | 'compare'
@ -25,6 +24,7 @@ const ORANGE = '#FF8C42'
const DIM = '#555555'
const GOLD = '#FFD700'
const PLAN_BAR_WIDTH = 10
const HEAVY_PERIODS = new Set<Period>(['30days', 'month', 'all'])
const LANG_DISPLAY_NAMES: Record<string, string> = {
javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python',
@ -52,6 +52,7 @@ const PROVIDER_COLORS: Record<string, string> = {
claude: '#FF8C42',
codex: '#5BF5A0',
cursor: '#00B4D8',
'ibm-bob': '#0F62FE',
opencode: '#A78BFA',
pi: '#F472B6',
all: '#FF8C42',
@ -100,6 +101,14 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range
}
function isHeavyPeriod(period: Period): boolean {
return HEAVY_PERIODS.has(period)
}
function nextTick(): Promise<void> {
return new Promise(resolve => setImmediate(resolve))
}
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
function getLayout(columns?: number): Layout {
@ -247,16 +256,19 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
)
}
const _homeEncoded = homedir().replace(/\//g, '-')
const _home = homedir()
const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
function shortProject(encoded: string): string {
let path = encoded.replace(/^-/, '')
if (path.startsWith(_homeEncoded.replace(/^-/, ''))) {
path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '')
}
path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '')
export function shortProject(absPath: string): string {
const normalized = absPath.replace(/\\/g, '/')
let path: string
if (normalized === _home) path = ''
else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length)
else path = normalized
path = path.replace(/^\/+/, '')
path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '')
if (!path) return 'home'
const parts = path.split('-').filter(Boolean)
const parts = path.split('/').filter(Boolean)
if (parts.length <= 3) return parts.join('/')
return parts.slice(-3).join('/')
}
@ -282,7 +294,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm
return (
<Text key={`${project.project}-${i}`} wrap="truncate-end">
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(shortProject(project.project), nw)}</Text>
<Text dimColor> {fit(shortProject(project.projectPath), nw)}</Text>
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
<Text>{String(project.sessions.length).padStart(6)}</Text>
@ -442,7 +454,7 @@ const TOP_SESSIONS_CALLS_COL = 6
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const allSessions = projects.flatMap(p =>
p.sessions.map(s => ({ ...s, projectName: p.project }))
p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))
)
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
@ -460,7 +472,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
const date = session.firstTimestamp
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
: '----------'
const label = `${date} ${shortProject(session.projectName)}`
const label = `${date} ${shortProject(session.projectPath)}`
return (
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
@ -513,6 +525,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
'ibm-bob': 'IBM Bob',
opencode: 'OpenCode',
pi: 'Pi',
}
@ -654,8 +667,8 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
</>
)}
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
{!isOptimize && optimizeAvailable && (
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</>
)}
{!isOptimize && compareAvailable && (
<><Text dimColor> </Text><Text color={ORANGE} bold>c</Text><Text dimColor> compare</Text></>
@ -711,6 +724,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
const [view, setView] = useState<View>('dashboard')
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
const [optimizeLoading, setOptimizeLoading] = useState(false)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
// Cursor for the OptimizeView's findings window. Reset whenever the user
@ -721,13 +735,16 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
const optimizeAvailable = !isCustomRange && (activeProvider === 'all' || activeProvider === 'claude')
const modelCount = new Set(
projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown)))
).size
const compareAvailable = modelCount >= 2
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadGenerationRef = useRef(0)
const reloadInFlightRef = useRef(false)
const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null)
const pendingReloadRef = useRef<{ period: Period; provider: string } | null>(null)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
@ -744,13 +761,11 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
useEffect(() => {
let cancelled = false
async function loadBudgets() {
const claudeDir = join(homedir(), '.claude', 'projects')
const budgets = new Map<string, ContextBudget>()
for (const project of projects.slice(0, 8)) {
if (cancelled) return
const cwd = await discoverProjectCwd(join(claudeDir, project.project))
if (!cwd) continue
budgets.set(project.project, await estimateContextBudget(cwd))
if (!project.projectPath.startsWith('/')) continue
budgets.set(project.project, await estimateContextBudget(project.projectPath))
}
if (!cancelled) setProjectBudgets(budgets)
}
@ -758,23 +773,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return () => { cancelled = true }
}, [projects])
useEffect(() => {
if (!optimizeAvailable) { setOptimizeResult(null); return }
let cancelled = false
async function scan() {
if (projects.length === 0) { setOptimizeResult(null); return }
const result = await scanAndDetect(projects, getPeriodRange(period))
if (!cancelled) setOptimizeResult(result)
}
scan()
return () => { cancelled = true }
}, [projects, period, optimizeAvailable])
const reloadData = useCallback(async (p: Period, prov: string) => {
if (reloadInFlightRef.current) {
const current = currentReloadRef.current
if (current?.period === p && current.provider === prov) {
pendingReloadRef.current = null
return
}
reloadGenerationRef.current++
pendingReloadRef.current = { period: p, provider: prov }
return
}
reloadInFlightRef.current = true
currentReloadRef.current = { period: p, provider: prov }
const generation = ++reloadGenerationRef.current
setLoading(true)
setOptimizeLoading(false)
setOptimizeResult(null)
try {
if (isHeavyPeriod(p)) {
setProjects([])
setProjectBudgets(new Map())
await nextTick()
if (reloadGenerationRef.current !== generation) return
}
const range = getPeriodRange(p)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return
@ -792,11 +814,37 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
if (reloadGenerationRef.current === generation) {
setLoading(false)
}
reloadInFlightRef.current = false
currentReloadRef.current = null
const pending = pendingReloadRef.current
pendingReloadRef.current = null
if (pending) {
void reloadData(pending.period, pending.provider)
}
}
}, [projectFilter, excludeFilter])
const loadOptimizeResult = useCallback(async () => {
if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return
setView('optimize')
setFindingsCursor(0)
if (optimizeResult) return
const generation = reloadGenerationRef.current
setOptimizeLoading(true)
try {
const result = await scanAndDetect(projects, getPeriodRange(period))
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
} catch (error) {
console.error(error)
} finally {
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
}
}, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
if (isHeavyPeriod(period)) return
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, reloadData])
@ -826,7 +874,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
useInput((input, key) => {
if (input === 'q') { exit(); return }
if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return }
if (input === 'o' && view === 'dashboard' && optimizeAvailable) { void loadOptimizeResult(); return }
if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); setFindingsCursor(0); return }
if (view === 'optimize') {
const total = optimizeResult?.findings.length ?? 0
@ -864,7 +912,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period]
if (loading) {
if (loading || optimizeLoading) {
return (
<Box flexDirection="column" width={dashWidth}>
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
@ -877,7 +925,9 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
<Text dimColor>Loading {headerLabel} model data...</Text>
</Box>
</Box>
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
: view === 'optimize'
? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel>
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} />}
</Box>
)

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,11 @@
import { readFile, stat } from 'fs/promises'
import { readFileSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
// Hard cap well below V8's 512 MB string limit even with split('\n') doubling.
// Stream threshold chosen as empirical breakeven between readFile+split peak
// memory and createReadStream+readline overhead for typical session files.
// Hard cap well below V8's 512 MB string limit. Callers that need line-by-line
// processing should use readSessionLines(), which avoids materializing the
// whole file and can return large lines as Buffers.
export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024
export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024
export const LARGE_STREAM_LINE_BYTES = 32 * 1024
// Line-by-line streaming has bounded memory (one line at a time) and is not
// constrained by V8's string limit, so it can safely handle multi-GB session
@ -23,14 +22,6 @@ function warn(msg: string): void {
if (verbose()) process.stderr.write(`codeburn: ${msg}\n`)
}
async function readViaStream(filePath: string): Promise<string> {
const chunks: string[] = []
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
for await (const line of rl) chunks.push(line)
return chunks.join('\n')
}
export async function readSessionFile(filePath: string): Promise<string | null> {
let size: number
try {
@ -46,7 +37,6 @@ export async function readSessionFile(filePath: string): Promise<string | null>
}
try {
if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath)
return await readFile(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
@ -76,7 +66,29 @@ export function readSessionFileSync(filePath: string): string | null {
}
}
export async function* readSessionLines(filePath: string): AsyncGenerator<string> {
export type SessionLine = string | Buffer
type ReadSessionLinesOptions = {
largeLineAsBuffer?: boolean
largeLineThresholdBytes?: number
startByteOffset?: number
byteOffsetTracker?: { lastCompleteLineOffset: number }
}
export function readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
): AsyncGenerator<string>
export function readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
options?: ReadSessionLinesOptions & { largeLineAsBuffer: true },
): AsyncGenerator<SessionLine>
export async function* readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
options: ReadSessionLinesOptions = {},
): AsyncGenerator<SessionLine> {
let size: number
try {
size = (await stat(filePath)).size
@ -92,10 +104,109 @@ export async function* readSessionLines(filePath: string): AsyncGenerator<string
return
}
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
const stream = createReadStream(
filePath,
options.startByteOffset !== undefined ? { start: options.startByteOffset } : undefined,
)
const SKIP_HEAD = 2048
const largeLineThreshold = options.largeLineThresholdBytes ?? LARGE_STREAM_LINE_BYTES
const formatLine = (buf: Buffer, lineLen: number, head?: string): SessionLine => {
if (options.largeLineAsBuffer && lineLen > largeLineThreshold) return buf
return head !== undefined && lineLen <= SKIP_HEAD ? head : buf.toString('utf-8')
}
let parts: Buffer[] = []
let len = 0
let skipping = false
let headChecked = false
let chunkBase = options.startByteOffset ?? 0
const tracker = options.byteOffsetTracker
try {
for await (const line of rl) yield line
for await (const raw of stream) {
const chunk = raw as Buffer
let pos = 0
while (pos < chunk.length) {
const nl = chunk.indexOf(0x0a, pos)
if (skipping) {
if (nl === -1) {
pos = chunk.length
} else {
if (tracker) tracker.lastCompleteLineOffset = chunkBase + nl + 1
skipping = false
pos = nl + 1
}
continue
}
if (nl !== -1) {
if (pos < nl) {
parts.push(chunk.subarray(pos, nl))
len += nl - pos
}
pos = nl + 1
if (tracker) tracker.lastCompleteLineOffset = chunkBase + pos
if (len === 0) {
parts = []
headChecked = false
continue
}
const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len)
const lineLen = len
parts = []
len = 0
headChecked = false
if (shouldSkipHead) {
const head = lineLen > SKIP_HEAD
? buf.subarray(0, SKIP_HEAD).toString('utf-8')
: buf.toString('utf-8')
if (shouldSkipHead(head)) continue
yield formatLine(buf, lineLen, head)
} else {
yield formatLine(buf, lineLen)
}
} else {
const slice = chunk.subarray(pos)
parts.push(slice)
len += slice.length
pos = chunk.length
// Mid-line skip: once we have enough bytes to check the head,
// enter scanning mode — just look for \n without accumulating.
if (shouldSkipHead && !headChecked && len >= SKIP_HEAD) {
headChecked = true
const headBuf = parts.length === 1
? parts[0]!.subarray(0, SKIP_HEAD)
: Buffer.concat(parts, len).subarray(0, SKIP_HEAD)
if (shouldSkipHead(headBuf.toString('utf-8'))) {
skipping = true
parts = []
len = 0
}
}
}
}
chunkBase += chunk.length
}
if (!skipping && len > 0) {
const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len)
const lineLen = len
if (shouldSkipHead) {
const head = lineLen > SKIP_HEAD
? buf.subarray(0, SKIP_HEAD).toString('utf-8')
: buf.toString('utf-8')
if (!shouldSkipHead(head)) {
yield formatLine(buf, lineLen, head)
}
} else {
yield formatLine(buf, lineLen)
}
}
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
} finally {

988
src/main.ts Normal file
View file

@ -0,0 +1,988 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { renderDashboard } from './dashboard.js'
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
async function hydrateCache() {
try {
return await ensureCacheHydrated(
(range) => parseAllSessions(range, 'all'),
aggregateProjectsIntoDays,
)
} catch {
return emptyCache()
}
}
function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
}
function parseNumber(value: string): number {
return Number(value)
}
function parseInteger(value: string): number {
return parseInt(value, 10)
}
type JsonPlanSummary = {
id: PlanId
budget: number
spent: number
percentUsed: number
status: 'under' | 'near' | 'over'
projectedMonthEnd: number
daysUntilReset: number
periodStart: string
periodEnd: string
}
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
return {
id: planUsage.plan.id,
budget: convertCost(planUsage.budgetUsd),
spent: convertCost(planUsage.spentApiEquivalentUsd),
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
status: planUsage.status,
projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
daysUntilReset: planUsage.daysUntilReset,
periodStart: planUsage.periodStart.toISOString(),
periodEnd: planUsage.periodEnd.toISOString(),
}
}
function assertFormat(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
report.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(report, null, 2))
}
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
.option('--timezone <zone>', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
program.hook('preAction', async (thisCommand) => {
const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
if (tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
process.exit(1)
}
process.env.TZ = tz
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
await loadCurrency()
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
// Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
// consumer summing daily[].editTurns over a period gets the same total as
// sum(activities[].editTurns) for that period: every turn counts once for
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
// Prefer the user-message timestamp on the turn; fall back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions where the JSONL begins mid-conversation). Previously these
// turns dropped from daily but stayed in activities, breaking the
// sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].calls += 1
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
calls: d.calls,
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
// Pre-computed convenience for dashboards that don't want to do the math.
// null when there are no edit turns (the rate is undefined, not zero —
// a day where the user only had Q&A turns shouldn't read as 0% one-shot).
oneShotRate: d.editTurns > 0
? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
: null,
}))
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
oneShotRate: efficiency?.oneShotRate ?? null,
retriesPerEdit: efficiency?.retriesPerEdit ?? null,
costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined
? convertCost(efficiency.costPerEditUSD)
: null,
}
})
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
}))
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
}
for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
}
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
}
const sortedMap = (m: Record<string, number>) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
return {
generated: new Date().toISOString(),
currency: code,
period,
periodKey,
overview: {
cost: convertCost(totalCostUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
tokens: {
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
},
},
daily,
projects: projectList,
models,
activities,
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const period = toPeriod(opts.period)
if (opts.format === 'json') {
await loadPricing()
if (customRange) {
const label = formatDateRangeLabel(opts.from, opts.to)
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
inputTokens += sess.totalInputTokens
outputTokens += sess.totalOutputTokens
cacheReadTokens += sess.totalCacheReadTokens
cacheWriteTokens += sess.totalCacheWriteTokens
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
catTotals[cat].turns += d.turns
catTotals[cat].cost += d.costUSD
catTotals[cat].editTurns += d.editTurns
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
}
}
return {
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, ...d })),
}
}
program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
const periodInfo = getDateRange(opts.period)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayRange: DateRange = { start: todayStart, end: now }
const todayStr = toDateString(todayStart)
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const isAllProviders = pf === 'all'
const cache = await hydrateCache()
let todayAllProjects: ProjectSummary[] | null = null
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
const getTodayAllProjects = async (): Promise<ProjectSummary[]> => {
if (!todayAllProjects) {
todayAllProjects = fp(await parseAllSessions(todayRange, 'all'))
}
return todayAllProjects
}
const getTodayAllDays = async (): Promise<ReturnType<typeof aggregateProjectsIntoDays>> => {
if (!todayAllDays) {
todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects())
}
return todayAllDays
}
// CURRENT PERIOD DATA
// - .all provider: assemble from cache + today (fast)
// - specific provider: parse the period range with provider filter (correct, but slower)
let currentData: PeriodData
let scanProjects: ProjectSummary[]
let scanRange: DateRange
if (isAllProviders) {
// Parse today's all-provider sessions once; historical data comes from cache to avoid
// double-counting. Reusing the same parsed object is important for the menubar path:
// large active sessions can OOM if this command retains multiple near-identical scans.
const todayProjects = await getTodayAllProjects()
const todayDays = await getTodayAllDays()
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = periodInfo.range
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
}
// PROVIDERS
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
// For specific: just this single provider with its scoped cost.
const allProviders = await getAllProviders()
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
const providers: ProviderCost[] = []
if (isAllProviders) {
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
...(await getTodayAllDays()).filter(d => d.date === todayStr),
]
const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {
providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
}
}
for (const [name, cost] of Object.entries(providerTotals)) {
providers.push({ name: displayNameByName.get(name) ?? name, cost })
}
for (const p of allProviders) {
if (providers.some(pc => pc.name === p.displayName)) continue
const sources = await p.discoverSessions()
if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
}
} else {
const display = displayNameByName.get(pf) ?? pf
providers.push({ name: display, cost: currentData.cost })
}
// DAILY HISTORY (last 365 days)
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, 5)
.map(([name, m]) => ({
name,
cost: m.cost,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
}))
return {
date: d.date,
cost: d.cost,
calls: d.calls,
inputTokens: d.inputTokens,
outputTokens: d.outputTokens,
cacheReadTokens: d.cacheReadTokens,
cacheWriteTokens: d.cacheWriteTokens,
topModels,
}
}
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
return
}
if (opts.format === 'json') {
const todayProjects = fp(await parseAllSessions(getDateRange('today').range, pf))
const todayData = buildPeriodData('today', todayProjects)
clearSessionCache()
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
const monthData = buildPeriodData('month', monthProjects)
clearSessionCache()
const { code, rate } = getCurrency()
const payload: {
currency: string
today: { cost: number; calls: number }
month: { cost: number; calls: number }
plan?: JsonPlanSummary
} = {
currency: code,
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
}
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
payload.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(payload))
return
}
const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf))
clearSessionCache()
console.log(renderStatusBar(monthProjects2))
})
program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'today')
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'month')
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('export')
.description('Export usage data to CSV or JSON')
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
assertFormat(opts.format, ['csv', 'json'], 'export')
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
let periods: PeriodExport[]
if (customRange) {
periods = [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
clearSessionCache()
} else {
const thirtyDayProjects = fp(await parseAllSessions(getDateRange('30days').range, pf))
clearSessionCache()
periods = [
{ label: 'Today', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('today').range) },
{ label: '7 Days', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('week').range) },
{ label: '30 Days', projects: thirtyDayProjects },
]
}
if (periods.every(p => p.projects.length === 0)) {
console.log('\n No usage data found.\n')
return
}
const defaultName = `codeburn-${toDateString(new Date())}`
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string
try {
if (opts.format === 'json') {
savedPath = await exportJson(periods, outputPath)
} else {
savedPath = await exportCsv(periods, outputPath)
}
} catch (err) {
// Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
// throw with a user-readable message. Print just the message, not the stack, so the CLI
// doesn't spray its internals at the user.
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Export failed: ${message}\n`)
process.exit(1)
}
const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
})
program
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')
.option('--force', 'Reinstall even if an older copy is already in ~/Applications')
.action(async (opts: { force?: boolean }) => {
try {
const result = await installMenubarApp({ force: opts.force })
console.log(`\n Ready. ${result.installedPath}\n`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Menubar install failed: ${message}\n`)
process.exit(1)
}
})
program
.command('currency [code]')
.description('Set display currency (e.g. codeburn currency GBP)')
.option('--symbol <symbol>', 'Override the currency symbol')
.option('--reset', 'Reset to USD (removes currency config)')
.action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => {
if (opts?.reset) {
const config = await readConfig()
delete config.currency
await saveConfig(config)
console.log('\n Currency reset to USD.\n')
return
}
if (!code) {
const { code: activeCode, rate, symbol } = getCurrency()
if (activeCode === 'USD' && rate === 1) {
console.log('\n Currency: USD (default)')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log(`\n Currency: ${activeCode}`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${activeCode}`)
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
const upperCode = code.toUpperCase()
if (!isValidCurrencyCode(upperCode)) {
console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`)
process.exitCode = 1
return
}
const config = await readConfig()
config.currency = {
code: upperCode,
...(opts?.symbol ? { symbol: opts.symbol } : {}),
}
await saveConfig(config)
await loadCurrency()
const { rate, symbol } = getCurrency()
console.log(`\n Currency set to ${upperCode}.`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${upperCode}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('model-alias [from] [to]')
.description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
.option('--remove <from>', 'Remove an alias')
.option('--list', 'List configured aliases')
.action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const aliases = config.modelAliases ?? {}
if (opts?.list || (!from && !opts?.remove)) {
const entries = Object.entries(aliases)
if (entries.length === 0) {
console.log('\n No model aliases configured.')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log('\n Model aliases:')
for (const [src, dst] of entries) {
console.log(` ${src} -> ${dst}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!(opts.remove in aliases)) {
console.error(`\n Alias not found: ${opts.remove}\n`)
process.exitCode = 1
return
}
delete aliases[opts.remove]
config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined
await saveConfig(config)
console.log(`\n Removed alias: ${opts.remove}\n`)
return
}
if (!from || !to) {
console.error('\n Usage: codeburn model-alias <from> <to>\n')
process.exitCode = 1
return
}
aliases[from] = to
config.modelAliases = aliases
await saveConfig(config)
console.log(`\n Alias saved: ${from} -> ${to}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')
.option('--format <format>', 'Output format: text or json', 'text')
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
const mode = action ?? 'show'
if (mode === 'show') {
const plan = await readPlan()
const displayPlan = !plan || plan.id === 'none'
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
: {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt,
}
if (opts?.format === 'json') {
console.log(JSON.stringify(displayPlan))
return
}
if (!plan || plan.id === 'none') {
console.log('\n Plan: none')
console.log(' API-pricing view is active.')
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Provider: ${plan.provider}`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
console.log(` Set at: ${plan.setAt}`)
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
if (mode === 'reset') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (mode !== 'set') {
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
process.exitCode = 1
return
}
if (!id || !isPlanId(id)) {
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
process.exitCode = 1
return
}
const resetDay = opts?.resetDay ?? 1
if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
process.exitCode = 1
return
}
if (id === 'none') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (id === 'custom') {
if (opts?.monthlyUsd === undefined) {
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
process.exitCode = 1
return
}
const monthlyUsd = opts.monthlyUsd
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
process.exitCode = 1
return
}
const provider = opts?.provider ?? 'all'
if (!isPlanProvider(provider)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
process.exitCode = 1
return
}
await savePlan({
id: 'custom',
monthlyUsd,
provider,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
return
}
const preset = getPresetPlan(id)
if (!preset) {
console.error(`\n Unknown preset "${id}".\n`)
process.exitCode = 1
return
}
await savePlan({
...preset,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
console.log(` Provider: ${preset.provider}`)
console.log(` Reset day: ${resetDay}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('optimize')
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
await runOptimize(projects, label, range)
})
program
.command('compare')
.description('Compare two AI models side-by-side')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
})
program
.command('models')
.description('Per-model token + cost table, optionally exploded by task type')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--task <category>', 'Filter to one task type (e.g. feature, debugging, refactoring)')
.option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()
let range
if (opts.from || opts.to) {
const customRange = parseDateRangeFlags(opts.from, opts.to)
if (!customRange) {
process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n')
process.exit(1)
}
range = customRange
} else {
range = getDateRange(opts.period).range
}
const projects = await parseAllSessions(range, opts.provider)
const rows = await aggregateModels(projects, {
byTask: !!opts.byTask,
taskFilter: opts.task,
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
})
const fmt = (opts.format ?? 'table').toLowerCase()
if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
process.stdout.write('No model usage found for the selected period.\n')
return
}
if (fmt === 'json') {
process.stdout.write(renderJson(rows) + '\n')
} else if (fmt === 'csv') {
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
} else if (fmt === 'markdown' || fmt === 'md') {
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
}
})
program
.command('yield')
.description('Track which AI spend shipped to main vs reverted/abandoned (experimental)')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'week')
.action(async (opts) => {
const { computeYield, formatYieldSummary } = await import('./yield.js')
await loadPricing()
const { range, label } = getDateRange(opts.period)
console.log(`\n Analyzing yield for ${label}...\n`)
const summary = await computeYield(range, process.cwd())
console.log(formatYieldSummary(summary))
})
program.parse()

View file

@ -1,26 +1,29 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createWriteStream } from 'node:fs'
import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
/// newest tagged release; we filter its assets list for our zipped .app bundle.
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest'
/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share
/// the repository, so we scan recent releases and choose the newest `mac-v*` release
/// that actually contains the menubar zip.
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20'
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar'
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
const APP_PROCESS_NAME = 'CodeBurnMenubar'
const SUPPORTED_OS = 'darwin'
const MIN_MACOS_MAJOR = 14
const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
export type InstallResult = { installedPath: string; launched: boolean }
export type ReleaseAsset = { name: string; browser_download_url: string }
export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
export type ResolvedAssets = { zip: ReleaseAsset; checksum: ReleaseAsset | null }
export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset }
export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
@ -30,8 +33,23 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA
`Check https://github.com/getagentseal/codeburn/releases.`
)
}
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) ?? null
return { zip, checksum }
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`)
if (!checksum) {
throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`)
}
return { release, zip, checksum }
}
export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets {
for (const release of releases) {
if (!release.tag_name.startsWith('mac-v')) continue
try {
return resolveMenubarReleaseAssets(release)
} catch {
continue
}
}
throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.')
}
function userApplicationsDir(): string {
@ -81,8 +99,8 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
if (!response.ok) {
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
}
const body = await response.json() as ReleaseResponse
return resolveMenubarReleaseAssets(body)
const body = await response.json() as ReleaseResponse[]
return resolveLatestMenubarReleaseAssets(body)
}
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
@ -131,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise<void> {
})
}
async function captureCommand(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let out = ''
let err = ''
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() })
proc.on('error', reject)
proc.on('close', (code) => {
if (code === 0) resolve(out.trim())
else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`))
})
})
}
async function verifyBundleIdentity(appPath: string): Promise<void> {
const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [
'-c',
'Print :CFBundleIdentifier',
join(appPath, 'Contents', 'Info.plist'),
])
if (bundleID !== EXPECTED_BUNDLE_ID) {
throw new Error(`Unexpected menubar bundle id ${bundleID}; expected ${EXPECTED_BUNDLE_ID}.`)
}
await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath])
}
async function resolvePersistentCodeburnPath(): Promise<string> {
const path = await captureCommand('/usr/bin/env', [
'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin',
'which',
'codeburn',
])
if (!path.startsWith('/')) {
throw new Error('Resolved codeburn path is not absolute.')
}
if (path.includes('/_npx/') || path.includes('/.npm/_npx/')) {
throw new Error(
'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn'
)
}
return path
}
async function persistCodeburnPath(): Promise<void> {
const cliPath = await resolvePersistentCodeburnPath()
await mkdir(join(homedir(), 'Library', 'Application Support', 'CodeBurn'), { recursive: true, mode: 0o700 })
await writeFile(PERSISTED_CLI_PATH, `${cliPath}\n`, { mode: 0o600 })
await chmod(PERSISTED_CLI_PATH, 0o600)
}
async function isAppRunning(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
@ -153,6 +222,7 @@ async function killRunningApp(): Promise<void> {
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
await ensureSupportedPlatform()
await persistCodeburnPath()
const appsDir = userApplicationsDir()
const targetPath = join(appsDir, APP_BUNDLE_NAME)
@ -174,12 +244,8 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom
console.log(`Downloading ${zip.name}...`)
await downloadToFile(zip.browser_download_url, archivePath)
if (checksum) {
console.log('Verifying checksum...')
await verifyChecksum(archivePath, checksum.browser_download_url)
} else {
console.log('Warning: no checksum file found in release, skipping verification.')
}
console.log('Verifying checksum...')
await verifyChecksum(archivePath, checksum.browser_download_url)
console.log('Unpacking...')
await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir])
@ -189,6 +255,9 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom
throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`)
}
console.log('Verifying app bundle...')
await verifyBundleIdentity(unpackedApp)
// Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the
// "cannot verify developer" prompt even for a signed + notarized app when the bundle
// was delivered via curl/fetch instead of the Mac App Store.

View file

@ -43,7 +43,7 @@ type Bucket = {
}
type ModelKey = string
type CategoryKey = string
type CategoryKey = TaskCategory
function bucketKey(provider: string, model: string, category: TaskCategory | null): string {
return `${provider} ${model} ${category ?? ''}`

View file

@ -25,6 +25,7 @@ type SnapshotEntry = [number, number, number | null, number | null]
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const WEB_SEARCH_COST = 0.01
const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
const FAST_MULTIPLIERS: Record<string, number> = {
'claude-opus-4-7': 6,
@ -166,6 +167,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
'copilot-auto': 'claude-sonnet-4-5',
'copilot-openai-auto': 'gpt-5.3-codex',
'copilot-anthropic-auto': 'claude-sonnet-4-5',
'ibm-bob-auto': 'claude-sonnet-4-5',
'kiro-auto': 'claude-sonnet-4-5',
'cline-auto': 'claude-sonnet-4-5',
'openclaw-auto': 'claude-sonnet-4-5',
@ -310,6 +312,7 @@ export function calculateCost(
cacheReadTokens: number,
webSearchRequests: number,
speed: 'standard' | 'fast' = 'standard',
oneHourCacheCreationTokens = 0,
): number {
const costs = getModelCosts(model)
if (!costs) {
@ -335,11 +338,15 @@ export function calculateCost(
// from real spend in aggregate totals. NaN is also handled here; the
// arithmetic below short-circuits to 0 when any operand is non-finite.
const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0)
const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens)
const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation)
const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation)
return multiplier * (
safe(inputTokens) * costs.inputCostPerToken +
safe(outputTokens) * costs.outputCostPerToken +
safe(cacheCreationTokens) * costs.cacheWriteCostPerToken +
safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken +
safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE +
safe(cacheReadTokens) * costs.cacheReadCostPerToken +
safe(webSearchRequests) * costs.webSearchCostPerRequest
)
@ -351,6 +358,7 @@ const autoModelNames: Record<string, string> = {
'copilot-auto': 'Copilot (auto)',
'copilot-openai-auto': 'Copilot (OpenAI)',
'copilot-anthropic-auto': 'Copilot (Anthropic)',
'ibm-bob-auto': 'IBM Bob (auto)',
'kiro-auto': 'Kiro (auto)',
'cline-auto': 'Cline (auto)',
'openclaw-auto': 'OpenClaw (auto)',

View file

@ -6,6 +6,7 @@ import { homedir } from 'os'
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
import { discoverAllSessions } from './providers/index.js'
import { parseJsonlLine, shouldSkipLine } from './parser.js'
import type { DateRange, ProjectSummary } from './types.js'
import { formatCost } from './currency.js'
import { formatTokens } from './format.js'
@ -141,6 +142,8 @@ const SHELL_PROFILES = ['.zshrc', '.bashrc', '.bash_profile', '.profile']
const TOP_ITEMS_PREVIEW = 3
const GHOST_NAMES_PREVIEW = 5
const GHOST_CLEANUP_COMMANDS_LIMIT = 10
const OPTIMIZE_TEXT_CAP = 2000
const OPTIMIZE_FIELD_CAP = 500
// ============================================================================
// Types
@ -209,7 +212,33 @@ type ScanData = {
// JSONL scanner
// ============================================================================
const FILE_READ_CONCURRENCY = 16
function cappedString(value: unknown, cap = OPTIMIZE_FIELD_CAP): string | undefined {
return typeof value === 'string' ? value.slice(0, cap) : undefined
}
function compactOptimizeInput(name: string, input: unknown): Record<string, unknown> {
if (!input || typeof input !== 'object') return {}
const raw = input as Record<string, unknown>
if (isReadTool(name)) {
const filePath = cappedString(raw['file_path'], OPTIMIZE_TEXT_CAP)
return filePath ? { file_path: filePath } : {}
}
if (name === 'Agent' || name === 'Task') {
const subagentType = cappedString(raw['subagent_type'])
return subagentType ? { subagent_type: subagentType } : {}
}
if (name === 'Skill') {
const skill = cappedString(raw['skill'])
const skillName = cappedString(raw['name'])
return {
...(skill ? { skill } : {}),
...(skillName ? { name: skillName } : {}),
}
}
return {}
}
const FILE_READ_CONCURRENCY = 4
const RESULT_CACHE_TTL_MS = 60_000
const RECENT_WINDOW_HOURS = 48
const RECENT_WINDOW_MS = RECENT_WINDOW_HOURS * 60 * 60 * 1000
@ -286,10 +315,19 @@ export async function scanJsonlFile(
const sessionId = basename(filePath, '.jsonl')
let lastVersion = ''
for await (const line of readSessionLines(filePath)) {
if (!line.trim()) continue
let entry: Record<string, unknown>
try { entry = JSON.parse(line) } catch { continue }
const skipThreshold = dateRange
? new Date(dateRange.start.getTime() - 86_400_000).toISOString()
: null
const skipFn = dateRange
? (head: string) => shouldSkipLine(head, skipThreshold!)
: undefined
const lines = readSessionLines(filePath, skipFn, { largeLineAsBuffer: true })
for await (const line of lines) {
if (typeof line === 'string' && !line.trim()) continue
if (Buffer.isBuffer(line) && line.length === 0) continue
const parsed = parseJsonlLine(line)
if (!parsed) continue
const entry = parsed as Record<string, unknown>
if (entry.version && typeof entry.version === 'string') lastVersion = entry.version
@ -304,11 +342,15 @@ export async function scanJsonlFile(
const msg = entry.message as Record<string, unknown> | undefined
const msgContent = msg?.content
if (typeof msgContent === 'string') {
userMessages.push(msgContent)
userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP))
} else if (Array.isArray(msgContent)) {
let remaining = OPTIMIZE_TEXT_CAP
for (const block of msgContent) {
if (remaining <= 0) break
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
userMessages.push(block.text)
const text = block.text.slice(0, remaining)
userMessages.push(text)
remaining -= text.length
}
}
}
@ -330,9 +372,10 @@ export async function scanJsonlFile(
for (const block of blocks) {
if (block.type !== 'tool_use') continue
const name = typeof block.name === 'string' ? block.name : ''
calls.push({
name: block.name as string,
input: (block.input as Record<string, unknown>) ?? {},
name,
input: compactOptimizeInput(name, block.input),
sessionId,
project,
recent,

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ const CACHE_VERSION = 2
const RPC_TIMEOUT_MS = 5000
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
type ServerInfo = {
export type ServerInfo = {
port: number
csrfToken: string
}
@ -31,7 +31,7 @@ type UsageEntry = {
responseId?: string
}
type GeneratorMetadata = {
export type GeneratorMetadata = {
stepIndices?: number[]
chatModel?: {
model: string
@ -42,6 +42,20 @@ type GeneratorMetadata = {
}
}
type ModelMapResponse = {
models?: Record<string, { model?: string }>
response?: {
models?: Record<string, { model?: string }>
}
}
type GeneratorMetadataResponse = {
generatorMetadata?: GeneratorMetadata[]
response?: {
generatorMetadata?: GeneratorMetadata[]
}
}
type CachedCascade = {
mtimeMs: number
sizeBytes: number
@ -59,6 +73,9 @@ let memCache: AntigravityCache | null = null
let cacheDirty = false
let httpsAgent: https.Agent | undefined
const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port']
const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token']
function getAgent(): https.Agent {
if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false })
return httpsAgent
@ -72,6 +89,72 @@ function getCachePath(): string {
return join(getCacheDir(), 'antigravity-results.json')
}
function execFileText(command: string, args: string[], timeout = 3000): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => {
if (err) reject(err)
else resolve(stdout)
})
})
}
function getFlagValue(line: string, names: string[]): string | null {
for (const name of names) {
const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i'))
const value = match?.[1] ?? match?.[2] ?? match?.[3]
if (value && !value.startsWith('--')) return value
}
return null
}
function isLikelyCsrfToken(value: string): boolean {
return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value)
}
export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | null {
const lower = line.toLowerCase()
if (!lower.includes('language_server') || !lower.includes('antigravity')) return null
const rawPort = getFlagValue(line, SERVER_PORT_FLAGS)
const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS)
if (!rawPort || !csrfToken) return null
if (!isLikelyCsrfToken(csrfToken)) return null
const port = Number(rawPort)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
return { port, csrfToken }
}
export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null {
for (const line of lines) {
const server = parseAntigravityServerInfoFromLine(line)
if (server) return server
}
return null
}
export function extractAntigravityModelMap(resp: unknown): ModelMap {
if (!resp || typeof resp !== 'object') return {}
const data = resp as ModelMapResponse
const models = data.response?.models ?? data.models
const map: ModelMap = {}
if (!models) return map
for (const [key, info] of Object.entries(models)) {
if (info && typeof info === 'object' && typeof info.model === 'string') {
map[info.model] = key
}
}
return map
}
export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] {
if (!resp || typeof resp !== 'object') return []
const data = resp as GeneratorMetadataResponse
const metadata = data.response?.generatorMetadata ?? data.generatorMetadata
return Array.isArray(metadata) ? metadata : []
}
async function loadCache(): Promise<AntigravityCache> {
if (memCache) return memCache
try {
@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set<string>): Promise<void> {
} catch { /* best-effort */ }
}
async function readProcessCommandLines(): Promise<string[]> {
if (process.platform === 'win32') {
const script = [
"$ErrorActionPreference = 'SilentlyContinue'",
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }",
].join('; ')
const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000)
return output.split(/\r?\n/)
}
const output = await execFileText('ps', ['-ww', '-eo', 'args'])
return output.split('\n')
}
async function detectServer(): Promise<ServerInfo | null> {
if (cachedServer !== undefined) return cachedServer
try {
const output = await new Promise<string>((resolve, reject) => {
execFile('ps', ['-eo', 'args'], { encoding: 'utf-8', timeout: 3000 }, (err, stdout) => {
if (err) reject(err)
else resolve(stdout)
})
})
for (const line of output.split('\n')) {
if (!line.includes('language_server') || !line.includes('antigravity')) continue
if (!line.includes('--https_server_port')) continue
const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]{32,})/)
const portMatch = line.match(/--https_server_port\s+(\d+)/)
if (csrfMatch && portMatch) {
cachedServer = { csrfToken: csrfMatch[1]!, port: parseInt(portMatch[1]!, 10) }
return cachedServer
}
}
} catch { /* ps failed or timed out */ }
cachedServer = parseAntigravityServerInfo(await readProcessCommandLines())
return cachedServer
} catch { /* process discovery failed or timed out */ }
cachedServer = null
return null
}
@ -199,20 +282,12 @@ async function rpc(server: ServerInfo, method: string, body: Record<string, unkn
async function getModelMap(server: ServerInfo): Promise<ModelMap> {
if (cachedModelMap) return cachedModelMap
const map: ModelMap = {}
try {
const resp = await rpc(server, 'GetAvailableModels') as {
response?: { models?: Record<string, { model?: string }> }
}
const models = resp?.response?.models
if (models) {
for (const [key, info] of Object.entries(models)) {
if (info.model) map[info.model] = key
}
}
cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels'))
return cachedModelMap
} catch { /* best-effort */ }
cachedModelMap = map
return map
cachedModelMap = {}
return cachedModelMap
}
// Strip Antigravity-specific suffixes so the pricing DB can match
@ -275,10 +350,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let metadata: GeneratorMetadata[]
try {
const resp = await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }) as {
generatorMetadata?: GeneratorMetadata[]
}
metadata = resp?.generatorMetadata ?? []
metadata = extractAntigravityGeneratorMetadata(
await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
)
} catch {
if (cached) {
for (const call of cached.calls) {

View file

@ -65,6 +65,8 @@ type CodexTokenUsage = {
}
const CHARS_PER_TOKEN = 4
const RAW_HEAD_BYTES = 64 * 1024
const LARGE_TEXT_CAP = 2000
function getCodexDir(override?: string): string {
return override ?? process.env['CODEX_HOME'] ?? join(homedir(), '.codex')
@ -126,6 +128,116 @@ async function isValidCodexSession(filePath: string): Promise<{ valid: boolean;
return { valid, meta: valid ? entry : undefined }
}
function getRawJsonStringField(head: string, field: string): string | undefined {
const re = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`)
const match = re.exec(head)
if (!match) return undefined
try {
return JSON.parse(`"${match[1]}"`) as string
} catch {
return match[1]
}
}
function payloadHead(head: string): string {
const idx = head.indexOf('"payload"')
return idx === -1 ? head : head.slice(idx)
}
function countJsonStringBytes(source: Buffer, valueStart: number): number {
let count = 0
for (let i = valueStart; i < source.length; i++) {
const ch = source[i]
if (ch === 0x5c) {
i++
count++
continue
}
if (ch === 0x22) return count
count++
}
return count
}
function extractFirstJsonText(source: Buffer, cap = LARGE_TEXT_CAP): string {
const key = Buffer.from('"text"')
const idx = source.indexOf(key)
if (idx === -1) return ''
const colon = source.indexOf(0x3a, idx + key.length)
if (colon === -1) return ''
const qStart = source.indexOf(0x22, colon + 1)
if (qStart === -1) return ''
const chunks: number[] = []
for (let i = qStart + 1; i < source.length && chunks.length < cap; i++) {
const ch = source[i]
if (ch === 0x5c) {
const next = source[++i]
if (next === 0x6e) chunks.push(0x0a)
else if (next === 0x72) chunks.push(0x0d)
else if (next === 0x74) chunks.push(0x09)
else if (next !== undefined) chunks.push(next)
continue
}
if (ch === 0x22) break
chunks.push(ch)
}
return Buffer.from(chunks).toString('utf-8')
}
function countFirstJsonText(source: Buffer): number {
const key = Buffer.from('"text"')
const idx = source.indexOf(key)
if (idx === -1) return 0
const colon = source.indexOf(0x3a, idx + key.length)
if (colon === -1) return 0
const qStart = source.indexOf(0x22, colon + 1)
if (qStart === -1) return 0
return countJsonStringBytes(source, qStart + 1)
}
function parseCodexLine(line: string | Buffer): CodexEntry | null {
if (typeof line === 'string') {
const trimmed = line.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed) as CodexEntry
} catch {
return null
}
}
if (line.length === 0) return null
const head = line.subarray(0, RAW_HEAD_BYTES).toString('utf-8')
const type = getRawJsonStringField(head, 'type')
if (!type) return null
const pHead = payloadHead(head)
const payloadType = getRawJsonStringField(pHead, 'type')
const role = getRawJsonStringField(pHead, 'role')
const entry: CodexEntry = {
type,
timestamp: getRawJsonStringField(head, 'timestamp'),
payload: {
type: payloadType,
role,
cwd: getRawJsonStringField(pHead, 'cwd'),
model_provider: getRawJsonStringField(pHead, 'model_provider'),
originator: getRawJsonStringField(pHead, 'originator'),
session_id: getRawJsonStringField(pHead, 'session_id'),
model: getRawJsonStringField(pHead, 'model'),
name: getRawJsonStringField(pHead, 'name'),
},
}
if (type === 'response_item' && payloadType === 'message' && role === 'user') {
entry.payload!.content = [{ type: 'input_text', text: extractFirstJsonText(line) }]
} else if (type === 'response_item' && payloadType === 'message' && role === 'assistant') {
entry.payload!.content = [{ type: 'output_text', text: 'x'.repeat(Math.min(countFirstJsonText(line), LARGE_TEXT_CAP)) }]
}
return entry
}
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
const sessionsDir = join(codexDir, 'sessions')
const sources: SessionSource[] = []
@ -224,18 +336,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// Stream the session file line by line. Heavy Codex sessions can exceed
// 250 MB on disk; reading the entire file into a string would either hit
// the readSessionFile cap or push V8 toward its 512 MB string limit
// after split('\n'). readSessionLines streams via readline so memory
// stays bounded to the longest line.
for await (const rawLine of readSessionLines(source.path)) {
// after split('\n'). readSessionLines streams raw buffers and hands
// huge lines to the compact parser without full string conversion.
for await (const rawLine of readSessionLines(source.path, undefined, { largeLineAsBuffer: true })) {
sawAnyLine = true
const line = rawLine.trim()
if (!line) continue
let entry: CodexEntry
try {
entry = JSON.parse(line) as CodexEntry
} catch {
continue
}
const entry = parseCodexLine(rawLine)
if (!entry) continue
if (entry.type === 'session_meta') {
sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl')

View file

@ -329,7 +329,8 @@ const USER_MESSAGES_QUERY = `
// the whole template. The original combined string is preserved as
// BUBBLE_QUERY_SINCE for any caller that doesn't want the cap.
const BUBBLE_QUERY_SINCE_HEAD = BUBBLE_QUERY_BASE + `
AND (json_extract(value, '$.createdAt') > ? OR json_extract(value, '$.createdAt') IS NULL)`
AND json_extract(value, '$.createdAt') IS NOT NULL
AND json_extract(value, '$.createdAt') > ?`
const BUBBLE_QUERY_SINCE_TAIL = `
ORDER BY ROWID ASC
`
@ -458,6 +459,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
}
const createdAt = row.created_at ?? ''
if (!createdAt) continue
// The JSON `conversationId` field on bubbles is empty in current
// Cursor builds. The real composerId lives in the row key
// `bubbleId:<composerId>:<bubbleUuid>`. Extract from the key so the
@ -487,7 +489,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0)
const timestamp = createdAt || new Date().toISOString()
const timestamp = createdAt
const userQuestion = takeUserMessage(userMessages, conversationId)
const assistantText = blobToText(row.user_text)
const userText = (userQuestion + ' ' + assistantText).trim()

59
src/providers/ibm-bob.ts Normal file
View file

@ -0,0 +1,59 @@
import { join } from 'path'
import { homedir } from 'os'
import { getShortModelName } from '../models.js'
import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
const PROVIDER_NAME = 'ibm-bob'
const DISPLAY_NAME = 'IBM Bob'
const EXTENSION_ID = 'ibm.bob-code'
const FALLBACK_MODEL = 'ibm-bob-auto'
export function getIBMBobGlobalStorageDirs(): string[] {
const home = homedir()
if (process.platform === 'darwin') {
return [
join(home, 'Library', 'Application Support', 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
join(home, 'Library', 'Application Support', 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
]
}
if (process.platform === 'win32') {
const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming')
return [
join(appData, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
join(appData, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
]
}
const configHome = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config')
return [
join(configHome, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
join(configHome, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
]
}
export function createIBMBobProvider(overrideDir?: string): Provider {
return {
name: PROVIDER_NAME,
displayName: DISPLAY_NAME,
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
return rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()
return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL)
},
}
}
export const ibmBob = createIBMBobProvider()

View file

@ -4,6 +4,7 @@ import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { droid } from './droid.js'
import { gemini } from './gemini.js'
import { ibmBob } from './ibm-bob.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { openclaw } from './openclaw.js'
@ -102,7 +103,7 @@ async function loadCrush(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])

View file

@ -4,7 +4,7 @@ import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import type {
Provider,
SessionSource,
@ -64,6 +64,25 @@ const toolNameMap: Record<string, string> = {
patch: 'Patch',
}
function normalizeToolName(rawTool?: string): string {
if (!rawTool) return ''
if (rawTool.startsWith('mcp__')) return rawTool
const builtIn = toolNameMap[rawTool]
if (builtIn) return builtIn
// OpenCode stores MCP calls as `<server>_<tool>` with no separate server field.
// Built-ins are handled above, and server ids are assumed not to contain `_`.
const serverSeparator = rawTool.indexOf('_')
if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) {
const server = rawTool.slice(0, serverSeparator)
const tool = rawTool.slice(serverSeparator + 1)
return `mcp__${server}__${tool}`
}
return rawTool
}
function sanitize(dir: string): string {
return dir.replace(/^\//, '').replace(/\//g, '-')
}
@ -107,7 +126,8 @@ function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult {
for (const table of required) {
try {
db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`)
} catch {
} catch (err) {
if (isSqliteBusyError(err)) throw err
missing.push(table)
}
}
@ -232,7 +252,7 @@ function createParser(
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
.map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '')
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)
const bashCommands = toolParts

View file

@ -27,6 +27,8 @@ export type ParsedProviderCall = {
deduplicationKey: string
userMessage: string
sessionId: string
project?: string
projectPath?: string
}
export type Provider = {

View file

@ -24,6 +24,23 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string {
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise<SessionSource[]> {
const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId)
return discoverClineTasksInBaseDirs([baseDir], providerName, displayName)
}
export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
const seen = new Set<string>()
for (const baseDir of baseDirs) {
for (const source of await discoverClineTasksInBaseDir(baseDir, providerName, displayName)) {
if (seen.has(source.path)) continue
seen.add(source.path)
sources.push(source)
}
}
return sources
}
async function discoverClineTasksInBaseDir(baseDir: string, providerName: string, displayName: string): Promise<SessionSource[]> {
const tasksDir = join(baseDir, 'tasks')
const sources: SessionSource[] = []
@ -50,28 +67,43 @@ export async function discoverClineTasks(extensionId: string, providerName: stri
}
const MODEL_TAG_RE = /<model>([^<]+)<\/model>/
const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/
function extractModelFromHistory(taskDir: string): Promise<string> {
type HistoryMeta = { model: string; workspace: string | null }
function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise<HistoryMeta> {
return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8')
.then(raw => {
const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }>
if (!Array.isArray(msgs)) return 'cline-auto'
if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null }
let model: string | null = null
let workspace: string | null = null
for (const msg of msgs) {
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text)
if (match) {
const raw = match[1]
return raw.includes('/') ? raw.split('/').pop()! : raw
if (typeof block.text !== 'string') continue
if (!model) {
const mm = MODEL_TAG_RE.exec(block.text)
if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1]
}
if (!workspace) {
const wm = WORKSPACE_DIR_RE.exec(block.text)
if (wm) workspace = wm[1]
}
if (model && workspace) break
}
if (model && workspace) break
}
return 'cline-auto'
return { model: model ?? fallbackModel, workspace }
})
.catch(() => 'cline-auto')
.catch(() => ({ model: fallbackModel, workspace: null }))
}
export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string): SessionParser {
function workspaceToProject(workspace: string): string {
return basename(workspace) || workspace
}
export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string, fallbackModel = 'cline-auto'): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const taskDir = source.path
@ -93,7 +125,10 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
if (!Array.isArray(uiMessages)) return
const model = await extractModelFromHistory(taskDir)
const meta = await extractHistoryMeta(taskDir, fallbackModel)
const model = meta.model
const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined
const projectPath = meta.workspace ?? undefined
let userMessage = ''
for (const msg of uiMessages) {
@ -156,6 +191,8 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
deduplicationKey: dedupKey,
userMessage: index === 0 ? userMessage : '',
sessionId: taskId,
project,
projectPath,
}
}
},

319
src/session-cache.ts Normal file
View file

@ -0,0 +1,319 @@
import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { createHash, randomBytes } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
// ── Types ──────────────────────────────────────────────────────────────
export type CachedUsage = {
inputTokens: number
outputTokens: number
cacheCreationInputTokens: number
cacheReadInputTokens: number
cachedInputTokens: number
reasoningTokens: number
webSearchRequests: number
cacheCreationOneHourTokens: number
}
export type CachedCall = {
provider: string
model: string
usage: CachedUsage
speed: 'standard' | 'fast'
timestamp: string
tools: string[]
bashCommands: string[]
skills: string[]
deduplicationKey: string
project?: string
projectPath?: string
}
export type CachedTurn = {
timestamp: string
sessionId: string
userMessage: string
calls: CachedCall[]
}
export type FileFingerprint = {
dev: number
ino: number
mtimeMs: number
sizeBytes: number
}
export type CachedFile = {
fingerprint: FileFingerprint
lastCompleteLineOffset?: number
canonicalCwd?: string
mcpInventory: string[]
turns: CachedTurn[]
}
export type ProviderSection = {
envFingerprint: string
files: Record<string, CachedFile>
}
export type SessionCache = {
version: number
providers: Record<string, ProviderSection>
}
// ── Constants ──────────────────────────────────────────────────────────
export const CACHE_VERSION = 1
const CACHE_FILE = 'session-cache.json'
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
const PROVIDER_ENV_VARS: Record<string, string[]> = {
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'],
codex: ['CODEX_HOME'],
droid: ['FACTORY_DIR'],
cursor: ['XDG_DATA_HOME'],
'cursor-agent': ['XDG_DATA_HOME'],
opencode: ['XDG_DATA_HOME'],
goose: ['XDG_DATA_HOME'],
crush: ['XDG_DATA_HOME'],
antigravity: ['CODEBURN_CACHE_DIR'],
qwen: ['QWEN_DATA_DIR'],
'ibm-bob': ['XDG_CONFIG_HOME'],
}
// ── Cache Dir ──────────────────────────────────────────────────────────
function getCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), CACHE_FILE)
}
// ── Env Fingerprint ────────────────────────────────────────────────────
export function computeEnvFingerprint(provider: string): string {
const vars = PROVIDER_ENV_VARS[provider] ?? []
const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`)
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
}
// ── Load / Save ────────────────────────────────────────────────────────
export function emptyCache(): SessionCache {
return { version: CACHE_VERSION, providers: {} }
}
function isNum(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v)
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(e => typeof e === 'string')
}
function isOptionalString(v: unknown): boolean {
return v === undefined || typeof v === 'string'
}
function isOptionalNum(v: unknown): boolean {
return v === undefined || isNum(v)
}
function validateFingerprint(fp: unknown): fp is FileFingerprint {
if (!fp || typeof fp !== 'object') return false
const f = fp as Record<string, unknown>
return isNum(f['dev']) && isNum(f['ino']) && isNum(f['mtimeMs']) && isNum(f['sizeBytes'])
}
function validateUsage(u: unknown): u is CachedUsage {
if (!u || typeof u !== 'object') return false
const o = u as Record<string, unknown>
return isNum(o['inputTokens']) && isNum(o['outputTokens'])
&& isNum(o['cacheCreationInputTokens']) && isNum(o['cacheReadInputTokens'])
&& isNum(o['cachedInputTokens']) && isNum(o['reasoningTokens'])
&& isNum(o['webSearchRequests']) && isNum(o['cacheCreationOneHourTokens'])
}
function validateCall(c: unknown): c is CachedCall {
if (!c || typeof c !== 'object') return false
const o = c as Record<string, unknown>
return typeof o['provider'] === 'string'
&& typeof o['model'] === 'string'
&& typeof o['deduplicationKey'] === 'string'
&& typeof o['timestamp'] === 'string'
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isStringArray(o['tools'])
&& isStringArray(o['bashCommands'])
&& isStringArray(o['skills'])
&& isOptionalString(o['project'])
&& isOptionalString(o['projectPath'])
&& validateUsage(o['usage'])
}
function validateTurn(t: unknown): t is CachedTurn {
if (!t || typeof t !== 'object') return false
const o = t as Record<string, unknown>
return typeof o['timestamp'] === 'string'
&& typeof o['sessionId'] === 'string'
&& typeof o['userMessage'] === 'string'
&& Array.isArray(o['calls'])
&& (o['calls'] as unknown[]).every(validateCall)
}
function validateCachedFile(f: unknown): f is CachedFile {
if (!f || typeof f !== 'object') return false
const o = f as Record<string, unknown>
return validateFingerprint(o['fingerprint'])
&& isOptionalNum(o['lastCompleteLineOffset'])
&& isOptionalString(o['canonicalCwd'])
&& isStringArray(o['mcpInventory'])
&& Array.isArray(o['turns'])
&& (o['turns'] as unknown[]).every(validateTurn)
}
function validateProviderSection(s: unknown): s is ProviderSection {
if (!s || typeof s !== 'object') return false
const o = s as Record<string, unknown>
if (typeof o['envFingerprint'] !== 'string') return false
if (!o['files'] || typeof o['files'] !== 'object' || Array.isArray(o['files'])) return false
return Object.values(o['files'] as Record<string, unknown>).every(validateCachedFile)
}
function validateCache(raw: unknown): raw is SessionCache {
if (!raw || typeof raw !== 'object') return false
const o = raw as Record<string, unknown>
if (o['version'] !== CACHE_VERSION) return false
if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
}
export async function loadCache(): Promise<SessionCache> {
try {
const raw = await readFile(getCachePath(), 'utf-8')
const parsed = JSON.parse(raw)
if (!validateCache(parsed)) return emptyCache()
return parsed
} catch {
return emptyCache()
}
}
export async function saveCache(cache: SessionCache): Promise<void> {
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(payload, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, finalPath)
} catch (err) {
try { await unlink(tempPath) } catch {}
throw err
}
}
// ── File Fingerprinting ────────────────────────────────────────────────
export async function fingerprintFile(filePath: string): Promise<FileFingerprint | null> {
try {
const s = await stat(filePath)
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
return null
}
}
// ── Reconciliation ─────────────────────────────────────────────────────
export type ReconcileAction =
| { action: 'unchanged' }
| { action: 'appended'; readFromOffset: number }
| { action: 'modified' }
| { action: 'new' }
export function reconcileFile(
current: FileFingerprint,
cached: CachedFile | undefined,
): ReconcileAction {
if (!cached) return { action: 'new' }
const fp = cached.fingerprint
if (
fp.dev === current.dev &&
fp.ino === current.ino &&
fp.mtimeMs === current.mtimeMs &&
fp.sizeBytes === current.sizeBytes
) {
return { action: 'unchanged' }
}
if (
cached.lastCompleteLineOffset !== undefined &&
fp.dev === current.dev &&
fp.ino === current.ino &&
current.sizeBytes > fp.sizeBytes
) {
return { action: 'appended', readFromOffset: cached.lastCompleteLineOffset }
}
return { action: 'modified' }
}
// ── Dedup Merge ────────────────────────────────────────────────────────
// When appending incremental data, streaming Claude messages can re-emit
// the same dedup key with updated usage. Merge by key: keep the earliest
// timestamp, take incoming usage/tools/bashCommands/skills (latest wins).
export function mergeCallByDedupKey(
existing: CachedCall,
incoming: CachedCall,
): CachedCall {
return {
...incoming,
timestamp: existing.timestamp < incoming.timestamp
? existing.timestamp
: incoming.timestamp,
}
}
// ── Temp Cleanup ───────────────────────────────────────────────────────
export async function cleanupOrphanedTempFiles(): Promise<void> {
const dir = getCacheDir()
if (!existsSync(dir)) return
try {
const entries = await readdir(dir)
const now = Date.now()
const prefix = 'session-cache.json.'
for (const entry of entries) {
if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue
try {
const fullPath = join(dir, entry)
const s = await stat(fullPath)
if (now - s.mtimeMs > TEMP_FILE_MAX_AGE_MS) {
await unlink(fullPath)
}
} catch {}
}
} catch {}
}

View file

@ -16,6 +16,7 @@ export type SqliteDatabase = {
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
exec?(sql: string): void
close(): void
}
@ -97,12 +98,35 @@ export function getSqliteLoadError(): string {
return loadError ?? 'SQLite driver not available'
}
export function isSqliteBusyError(err: unknown): boolean {
const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null
const code = typeof e?.code === 'string' ? e.code : ''
const errcode = typeof e?.errcode === 'number' ? e.errcode : null
const message = [
typeof e?.message === 'string' ? e.message : '',
typeof e?.errstr === 'string' ? e.errstr : '',
].join(' ')
return (
errcode === 5 ||
errcode === 6 ||
code === 'SQLITE_BUSY' ||
code === 'SQLITE_LOCKED' ||
/\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message)
)
}
export function openDatabase(path: string): SqliteDatabase {
if (!loadDriver() || DatabaseSync === null) {
throw new Error(getSqliteLoadError())
}
const db = new DatabaseSync(path, { readOnly: true })
try {
db.exec?.('PRAGMA busy_timeout = 1000')
} catch {
// Best effort. Some Node sqlite builds may not expose exec on DatabaseSync.
}
return {
query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {

View file

@ -25,6 +25,10 @@ export type ApiUsage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number
cache_creation?: {
ephemeral_5m_input_tokens?: number
ephemeral_1h_input_tokens?: number
}
cache_read_input_tokens?: number
server_tool_use?: {
web_search_requests?: number
@ -79,6 +83,7 @@ export type ParsedApiCall = {
timestamp: string
bashCommands: string[]
deduplicationKey: string
cacheCreationOneHourTokens?: number
}
export type TaskCategory =