mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 07:43:09 +00:00
Merge main into feat/cline-provider to resolve conflicts
This commit is contained in:
commit
59a4d95b18
75 changed files with 6743 additions and 1475 deletions
987
src/cli.ts
987
src/cli.ts
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
147
src/fs-utils.ts
147
src/fs-utils.ts
|
|
@ -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
988
src/main.ts
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}`
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1334
src/parser.ts
1334
src/parser.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
59
src/providers/ibm-bob.ts
Normal 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()
|
||||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export type ParsedProviderCall = {
|
|||
deduplicationKey: string
|
||||
userMessage: string
|
||||
sessionId: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
|
|
|
|||
|
|
@ -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
319
src/session-cache.ts
Normal 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 {}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue