Merge pull request #760 from getagentseal/fix/cli-durable-totals

fix(report): CLI totals read the durable daily cache, matching the menubar exactly
This commit is contained in:
Resham Joshi 2026-07-20 07:14:10 -07:00 committed by GitHub
commit 76744124ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 670 additions and 154 deletions

View file

@ -7,6 +7,7 @@ import { formatCost, formatTokens, markEstimated } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { findUnpricedModels, loadPricing } from './models.js'
import { buildDurablePeriod } from './usage-aggregator.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
@ -127,6 +128,46 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range
}
/// The durable headline totals the Overview panel renders. Sourced from the
/// carry-forward daily cache (via buildDurablePeriod) so the dashboard's top-
/// line cost/calls/tokens match the menubar and report exactly, including days
/// whose session files have expired.
export type DurableOverview = {
cost: number
savingsUSD: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
async function computeDurableOverview(
period: Period,
provider: string,
projectFilter: string[] | undefined,
excludeFilter: string[] | undefined,
customRange: DateRange | null | undefined,
day: string | null,
): Promise<DurableOverview> {
const range = day ? getDayRange(day) : customRange ?? getPeriodRange(period)
const { data } = await buildDurablePeriod(
{ range, label: PERIOD_LABELS[period] },
{ provider, project: projectFilter ?? [], exclude: excludeFilter ?? [] },
)
return {
cost: data.cost,
savingsUSD: data.savingsUSD,
calls: data.calls,
sessions: data.sessions,
inputTokens: data.inputTokens,
outputTokens: data.outputTokens,
cacheReadTokens: data.cacheReadTokens,
cacheWriteTokens: data.cacheWriteTokens,
}
}
function getDayRange(day: string): DateRange {
return parseDayFlag(day)!.range
}
@ -229,16 +270,19 @@ function planStatusText(planUsage: PlanUsage): string {
return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
}
function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[] }) {
const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
function Overview({ projects, label, width, planUsages, durable }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[]; durable?: DurableOverview }) {
// Headline totals prefer the durable daily cache (carried, expired-source days
// included) so they match the menubar and report; the live parse is the
// fallback until the durable figures land / for panels below.
const totalCost = durable ? durable.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavings = durable ? durable.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalCalls = durable ? durable.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = durable ? durable.sessions : projects.reduce((s, p) => s + p.sessions.length, 0)
const allSessions = projects.flatMap(p => p.sessions)
const totalInput = allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
const totalInput = durable ? durable.inputTokens : allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = durable ? durable.outputTokens : allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = durable ? durable.cacheReadTokens : allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = durable ? durable.cacheWriteTokens : allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
const cacheHit = allInputTokens > 0
? (totalCacheRead / allInputTokens) * 100 : 0
@ -806,7 +850,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean }) {
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
const activeLabel = label ?? PERIOD_LABELS[period]
@ -820,7 +864,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
const visiblePlanUsages = (planUsages ?? []).filter(p => p.plan.provider === (activeProvider ?? 'all'))
return (
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={visiblePlanUsages} />
<Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={visiblePlanUsages} durable={durable} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={scrollableDailyHistory ? (dailyHistoryProjects ?? []) : projects} days={days} pw={pw} bw={barWidth} scrollable={scrollableDailyHistory} cursor={dailyHistoryCursor} loading={dailyHistoryLoading} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} rows={dayMode ? 8 : period === 'all' || period === 'lifetime' ? 14 : period === 'month' || period === '30days' ? 14 : 8} /></Row>
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? (
@ -832,12 +876,13 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
)
}
function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
initialProjects: ProjectSummary[]
initialDailyHistoryProjects?: ProjectSummary[]
initialPeriod: Period
initialProvider: string
initialPlanUsages?: PlanUsage[]
initialDurable?: DurableOverview
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
@ -848,6 +893,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod)
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
const [durable, setDurable] = useState<DurableOverview | undefined>(initialDurable)
const [loading, setLoading] = useState(false)
const [activeProvider, setActiveProvider] = useState(initialProvider)
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
@ -932,6 +978,9 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
if (!day && isHeavyPeriod(p)) {
setProjects([])
setProjectBudgets(new Map())
// Drop the previous period's durable headline so it can't flash on the
// new tab before the fresh figure lands.
setDurable(undefined)
await nextTick()
if (reloadGenerationRef.current !== generation) return
}
@ -944,6 +993,12 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects)
setProjects(selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory))
// Durable headline totals (carry-forward cache + today), matching the
// menubar/report. Computed after the live parse so the panel paints
// immediately; the durable figure replaces the live one when it resolves.
const durableTotals = await computeDurableOverview(p, prov, projectFilter, excludeFilter, customRange, day)
if (reloadGenerationRef.current !== generation) return
setDurable(durableTotals)
const usage = await getPlanUsages()
if (reloadGenerationRef.current !== generation) return
setPlanUsages(usage)
@ -1142,7 +1197,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} />}
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} durable={durable} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
</Box>
)
@ -1165,13 +1220,13 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
)
}
function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode, durable }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; durable?: DurableOverview }) {
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
return (
<Box flexDirection="column" width={dashWidth}>
{dayMode ? <DayBanner label={label ?? PERIOD_LABELS[period]} width={dashWidth} /> : <PeriodTabs active={period} />}
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} durable={durable} />
</Box>
)
}
@ -1190,15 +1245,19 @@ export async function renderDashboard(period: Period = 'week', provider: string
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
const planUsages = await getPlanUsages()
// Durable headline totals for the initial paint (carry-forward cache + today),
// matching the menubar/report. The interactive tree recomputes this on every
// period/provider/refresh change; the static one-shot render uses just this.
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null)
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
)
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} />, { patchConsole: false })
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} durable={initialDurable} />, { patchConsole: false })
// Non-interactive one-shot output: ink schedules the frame through a
// throttled render, so yield a tick to let it flush to stdout before
// unmounting. Unmounting synchronously can race the flush and drop output.

View file

@ -36,12 +36,28 @@ function localDateString(d: Date): string {
return `${y}-${m}-${day}`
}
export function renderStatusBar(projects: ProjectSummary[]): string {
/// Precomputed today/month totals from the durable daily cache. When supplied,
/// the status bar renders these instead of bucketing the live parse, so the
/// figures match the menubar exactly (carried, expired-source days included).
export type StatusBarTotals = {
today: { cost: number; calls: number }
month: { cost: number; calls: number }
}
export function renderStatusBar(projects: ProjectSummary[], totals?: StatusBarTotals): string {
const now = new Date()
const today = localDateString(now)
const monthStart = `${today.slice(0, 7)}-01`
let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0
if (totals) {
todayCost = totals.today.cost; todayCalls = totals.today.calls
monthCost = totals.month.cost; monthCalls = totals.month.calls
const lines: string[] = ['']
lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayCost))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthCost))} ${chalk.dim(`${monthCalls} calls`)}`)
lines.push('')
return lines.join('\n')
}
for (const project of projects) {
for (const session of project.sessions) {

View file

@ -11,7 +11,7 @@ import { toDateString } from './daily-cache.js'
import { dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js'
import { buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, type DurablePeriod } from './usage-aggregator.js'
import { renderDashboard } from './dashboard.js'
import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
@ -413,8 +413,8 @@ function assertScope(value: string, allowed: readonly string[], command: string)
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; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period))
const durable = await buildDurablePeriod({ range, label }, { provider, project, exclude })
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(durable.liveProjects, label, period, durable))
console.log(JSON.stringify(report, null, 2))
}
@ -447,23 +447,27 @@ program.hook('preAction', async (thisCommand) => {
await loadCurrency()
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable?: DurablePeriod) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalEstimatedUSD = projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0)
// Headline totals come from the durable daily cache (carry-forward days whose
// session files have expired still count), matching the menubar exactly. The
// proxied/net split is a surviving-session concept (subscription attribution
// isn't stored per day), so it stays live; net is taken off the durable total.
const totalCostUSD = durable ? durable.data.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = durable ? durable.data.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalEstimatedUSD = durable ? (durable.data.estimatedCostUSD ?? 0) : projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0)
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
const netCostUSD = totalCostUSD - totalProxiedUSD
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)
const totalCalls = durable ? durable.data.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = durable ? durable.data.sessions : projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = durable ? durable.data.inputTokens : sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = durable ? durable.data.outputTokens : sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = durable ? durable.data.cacheReadTokens : sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = durable ? durable.data.cacheWriteTokens : 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
@ -499,21 +503,40 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
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,
}))
// Daily rows come from the same durable day set as the headline so they sum
// to it, carried days included. The live per-turn rollup (dailyMap) is only
// the fallback for callers that pass no durable period.
const daily = durable
? durable.days.map(d => {
const turns = Object.values(d.categories).reduce((s, c) => s + c.turns, 0)
return {
date: d.date,
cost: convertCost(d.cost),
savings: convertCost(d.savingsUSD),
calls: d.calls,
turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
oneShotRate: d.editTurns > 0
? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
: null,
}
})
: Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
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,
@ -748,12 +771,8 @@ program
const range = daySelection?.range ?? customRange!
const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to)
const periodKey = daySelection ? 'day' : 'custom'
const projects = filterProjectsByName(
await parseAllSessions(range, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2))
const durable = await buildDurablePeriod({ range, label }, { provider: opts.provider, project: opts.project, exclude: opts.exclude })
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(durable.liveProjects, label, periodKey, durable)), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
@ -926,12 +945,29 @@ program
const { range, label } = customRange
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
: getDateRange(period!)
const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude)
const durable = await buildDurablePeriod({ range, label }, { provider: opts.provider, project: opts.project, exclude: opts.exclude })
const projects = durable.liveProjects
const config = await readConfig()
const budget = isOverviewBudgetFilterActive(opts)
? undefined
: buildOverviewBudget(projects, config.budget, budgetTierForOverview(period, customRange), range)
process.stdout.write(renderOverview(projects, { label, color: opts.color, budget }))
process.stdout.write(renderOverview(projects, {
label,
color: opts.color,
budget,
durable: {
cost: durable.data.cost,
savingsUSD: durable.data.savingsUSD,
calls: durable.data.calls,
sessions: durable.data.sessions,
inputTokens: durable.data.inputTokens,
outputTokens: durable.data.outputTokens,
cacheReadTokens: durable.data.cacheReadTokens,
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
},
}))
})
program
@ -1091,12 +1127,13 @@ program
}
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()
// Durable totals so the compact status matches the menubar / report.
const todayDurable = await buildDurablePeriod(getDateRange('today'), { provider: pf, project: opts.project, exclude: opts.exclude })
const todayData = todayDurable.data
const todayProjects = todayDurable.liveProjects
const monthDurable = await buildDurablePeriod(getDateRange('month'), { provider: pf, project: opts.project, exclude: opts.exclude })
const monthData = monthDurable.data
const monthProjects = monthDurable.liveProjects
const { code, rate } = getCurrency()
const payload: {
currency: string
@ -1124,9 +1161,12 @@ program
return
}
const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf))
clearSessionCache()
console.log(renderStatusBar(monthProjects2))
const todayDurable = await buildDurablePeriod(getDateRange('today'), { provider: pf, project: opts.project, exclude: opts.exclude })
const monthDurable = await buildDurablePeriod(getDateRange('month'), { provider: pf, project: opts.project, exclude: opts.exclude })
console.log(renderStatusBar([], {
today: { cost: todayDurable.data.cost, calls: todayDurable.data.calls },
month: { cost: monthDurable.data.cost, calls: monthDurable.data.calls },
}))
})
program

View file

@ -7,6 +7,7 @@ import { formatCost as baseCost, getCurrency } from './currency.js'
import { findUnpricedModels, getShortModelName } from './models.js'
import { markEstimated } from './format.js'
import { dateKey } from './day-aggregator.js'
import type { DailyEntry } from './daily-cache.js'
import type { BudgetStatus, BudgetTier } from './budget.js'
// Display-only helpers. The shared formatters omit thousands separators and
@ -83,18 +84,34 @@ function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string {
].join('\n')
}
/// The durable slice renderOverview needs: headline totals + the day set behind
/// them + how much of the total came from carried (expired-source) days.
export type OverviewDurable = {
cost: number
savingsUSD: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
}
export function renderOverview(
projects: ProjectSummary[],
opts: { label: string; color: boolean; budget?: OverviewBudget },
opts: { label: string; color: boolean; budget?: OverviewBudget; durable?: OverviewDurable },
): string {
const c = new Chalk(opts.color ? {} : { level: 0 })
const heading = (text: string): string => c.cyan.bold(text)
const out: string[] = []
const durable = opts.durable
out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label))
out.push('')
if (projects.length === 0) {
if (projects.length === 0 && !(durable && durable.cost > 0)) {
out.push(c.dim(`No usage found for ${opts.label}.`))
return out.join('\n') + '\n'
}
@ -160,6 +177,29 @@ export function renderOverview(
}
}
// Headline totals and the day-resolved views (Daily, Highest-value days) come
// from the durable daily cache so they match the menubar exactly, carried
// (expired-source) days included. The per-tool / per-model / per-project
// breakdowns above stay live: they need surviving session detail.
if (durable) {
cost = durable.cost
savings = durable.savingsUSD
calls = durable.calls
sessions = durable.sessions
inTok = durable.inputTokens
outTok = durable.outputTokens
cacheR = durable.cacheReadTokens
cacheW = durable.cacheWriteTokens
byDay.clear()
for (const d of durable.days) {
byDay.set(d.date, {
cost: d.cost,
tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens,
providers: new Set(Object.keys(d.providers)),
})
}
}
const totalTokens = inTok + outTok + cacheR + cacheW
const cacheHitDenom = inTok + cacheR
const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0
@ -303,5 +343,12 @@ export function renderOverview(
const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : ''
out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`)
// When some of the period's total came from days whose session logs have since
// expired, say so once. The figure is real (preserved in the durable daily
// cache); it just can't be re-derived from surviving files anymore.
if (durable && durable.carriedCostUSD > 0) {
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}
return out.join('\n') + '\n'
}

View file

@ -11,7 +11,7 @@ import { aggregateModelEfficiency } from './model-efficiency.js'
import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@ -183,6 +183,170 @@ function dailyEntriesToHistory(days: ReturnType<typeof aggregateProjectsIntoDays
})
}
/// Collapse a day to a single provider's slice, promoting the slice's totals to
/// the day-level fields buildPeriodDataFromDays reads. A day with no slice for
/// the provider becomes a zero day (so the date is still present but contributes
/// nothing). The `carried` flag is inherited so a per-provider total can still
/// account for expired-source days.
function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
const s = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
if (!s) {
return {
date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
...(day.carried ? { carried: true as const } : {}),
}
}
return {
date: day.date,
cost: s.cost,
savingsUSD: s.savingsUSD ?? 0,
calls: s.calls,
sessions: s.sessions ?? 0,
inputTokens: s.inputTokens ?? 0,
outputTokens: s.outputTokens ?? 0,
cacheReadTokens: s.cacheReadTokens ?? 0,
cacheWriteTokens: s.cacheWriteTokens ?? 0,
editTurns: s.editTurns ?? 0,
oneShotTurns: s.oneShotTurns ?? 0,
models: s.models ?? {},
categories: s.categories ?? {},
providers: { [provider]: s },
...(s.projects ? { projects: s.projects } : {}),
...(day.carried ? { carried: true as const } : {}),
}
}
/// The durable day set behind a period's headline: historical days from the
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
/// expired) unioned with today parsed live, then narrowed to the requested range
/// and (when given) the heatmap day selection. Identical construction to the
/// menubar's all-provider headline — this IS that construction, extracted.
function unionDaysForPeriod(
cache: DailyCache,
todayAllDays: DailyEntry[],
periodInfo: PeriodInfo,
daysSelection: Set<string> | null,
): DailyEntry[] {
const now = new Date()
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 historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const historicalDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
}
/// The single durable-totals builder every CLI/TUI surface and the menubar share.
/// Headline totals (cost/calls/sessions/tokens/models/categories/savings) come
/// from the carry-forward daily cache unioned with today's live parse and sliced
/// to the requested provider, so a period that includes days whose session files
/// have expired still counts them — the invariant the menubar already relies on.
/// Detail-only fields that day entries can't carry (estimatedCost, unpriced
/// models, workflow intelligence, per-session drill-down) are enriched from a
/// fresh parse of the surviving sessions.
export type DurablePeriod = {
/// Durable headline totals for the period.
data: PeriodData
/// The exact provider-sliced, day-filtered day set behind `data`. Daily rows
/// rendered by report/overview come from here so they reconcile to `data`.
days: DailyEntry[]
/// Sum of `cost` on `carried` days included in the period (footnote source).
carriedCostUSD: number
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
/// Hydrated all-provider cache (reused by the menubar's provider list + daily
/// history sections).
cache: DailyCache
/// Today-only slice, all providers, name-filtered (memo seed for the menubar).
todayAllDays: DailyEntry[]
/// The scan range the live parse covered (today-only when the period is today).
scanRange: DateRange
}
export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: AggregateOpts = {}): Promise<DurablePeriod> {
const pf = opts.provider ?? 'all'
const daysSelection = opts.daysSelection ?? null
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? [])
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 rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
const cache = await hydrateCache()
// Today's live data always comes from an all-provider parse so the union (and
// any per-provider slice of it) sees every provider's today. `todayAllDays` is
// the today bucket only — the union filters the historical remainder out of the
// cache.
let liveProjects: ProjectSummary[]
let todayAllDays: DailyEntry[]
let scanRange: DateRange
if (pf === 'all') {
if (isTodayOnly) {
const raw = fp(await parseAllSessions(todayRange, 'all'))
liveProjects = raw
scanRange = todayRange
todayAllDays = aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
} else {
const raw = fp(await parseAllSessions(periodInfo.range, 'all'))
liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw
scanRange = periodInfo.range
// A period that reaches today contains today's turns already, so derive the
// today slice from the same parse instead of scanning today again.
todayAllDays = rangeEndStr >= todayStr
? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
}
} else {
// Provider-filtered: today's all-provider parse feeds the union (sliced
// below); the provider-scoped parse feeds the detail/enrichment fields.
todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf))
liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const data = buildPeriodDataFromDays(days, periodInfo.label)
// Enrich the cache-authoritative headline with fields DailyEntry cannot carry.
// These are all derivable only from surviving sessions (estimated-cost markers,
// unpriced-model detection, per-turn workflow intelligence), so they describe
// the live population, a subset of the carried headline.
const scanData = buildPeriodData(periodInfo.label, liveProjects)
data.estimatedCostUSD = scanData.estimatedCostUSD
data.unpricedModels = scanData.unpricedModels
data.workflow = scanData.workflow
data.topReworkedFiles = scanData.topReworkedFiles
data.pricingCoverage = scanData.pricingCoverage
// Cache buckets a session on its START day, the scan on any ACTIVE day; both
// are lower bounds of distinct sessions, so max is the tightest safe bound.
data.sessions = Math.max(data.sessions, scanData.sessions)
const estimatedByModel = new Map(
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
)
if (estimatedByModel.size > 0) {
data.models = data.models.map(m =>
estimatedByModel.has(m.name) ? { ...m, estimatedCostUSD: estimatedByModel.get(m.name) } : m,
)
}
const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
}
/**
* Resolved-range aggregation shared by `status --format menubar-json` and the MCP server.
* Pricing must already be loaded (callers run loadPricing first). When opts.optimize is
@ -231,7 +395,6 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
/// unscoped all-provider path; it is the authority the projects view merges
/// from, so carried days count even after their session files are gone.
let cacheDaysForPeriod: DailyEntry[] | null = null
let todayProviderData: PeriodData | null = null
let claudeConfigs: ClaudeConfigSelector | undefined
const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null
const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null
@ -257,93 +420,23 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// still renders.
}
if (!effectivelyScoped) {
if (isAllProviders) {
cache = await hydrateCache()
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
// Single all-provider scan for the requested window. Previously the overview
// parsed every provider TWICE — once over todayRange and once over the full
// period — even though the period scan already contains today's turns. Scan
// once here and derive the "today" slice from it below: aggregateProjects-
// IntoDays buckets by each turn's own timestamp, so a period scan's today
// slice is identical to a dedicated today scan's. A purely historical window
// (ends before today) never reaches today, so its separate today scan — the
// one the always-on daily-history strip needs — is left to run.
let rawScan: ProjectSummary[]
if (isTodayOnly) {
rawScan = fp(await parseAllSessions(todayRange, 'all'))
scanProjects = rawScan
scanRange = todayRange
} else {
rawScan = fp(await parseAllSessions(periodInfo.range, 'all'))
scanProjects = daysSelection ? filterProjectsByDays(rawScan, daysSelection.days) : rawScan
scanRange = periodInfo.range
}
// Seed the today memo from the un-daysSelection rawScan so the always-on
// daily-history strip still shows today even when the heatmap day filter
// excludes it (matching the old dedicated today scan, which ignored it).
if (rangeEndStr >= todayStr) {
todayAllDays = aggregateProjectsIntoDays(rawScan).filter(d => d.date === todayStr)
}
const todayDays = await getTodayAllDays()
const historicalDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays
cacheDaysForPeriod = allDays
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
} else {
cache = await loadDailyCache()
const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf))
const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
currentData = todayProviderData
scanProjects = fullProjects
scanRange = periodInfo.range
}
}
if (isAllProviders) {
const scanData = buildPeriodData(periodInfo.label, scanProjects)
if (cacheDaysForPeriod === null) {
// Scoped all-provider path (claude-config source selected): the scan IS
// the authority, as before.
currentData = scanData
} else {
// The daily-cache path is the authority for headline totals: it includes
// carried days whose session files no longer exist, which the fresh
// parse cannot see. Replacing it wholesale with the scan (the previous
// behavior) silently truncated cost/calls/models/categories to the
// source-retention window. The scan contributes ONLY what DailyEntry
// genuinely lacks: the estimated-cost markers and unpriced-model
// detection, both derivable solely from surviving sessions.
currentData.estimatedCostUSD = scanData.estimatedCostUSD
currentData.unpricedModels = scanData.unpricedModels
// Workflow-intelligence metrics are session-derived by nature (they need
// turn text, timestamps, and tool sequences that day entries don't
// carry), so like the fields above they come from the scan. Note
// pricingCoverage therefore describes surviving-session calls, a smaller
// population than the carried headline cost.
currentData.workflow = scanData.workflow
currentData.topReworkedFiles = scanData.topReworkedFiles
currentData.pricingCoverage = scanData.pricingCoverage
// Sessions: the cache buckets a session on its START day, the scan
// counts it on any day it was ACTIVE. Each undercounts differently
// (day-filtered views miss midnight-spanners; the scan misses expired
// sessions). Both count distinct real sessions, so max is the tightest
// safe bound and never double counts.
currentData.sessions = Math.max(currentData.sessions, scanData.sessions)
const estimatedByModel = new Map(
scanData.models
.filter(m => m.estimatedCostUSD != null)
.map(m => [m.name, m.estimatedCostUSD!]),
)
if (estimatedByModel.size > 0) {
currentData.models = currentData.models.map(m =>
estimatedByModel.has(m.name) ? { ...m, estimatedCostUSD: estimatedByModel.get(m.name) } : m,
)
}
}
// Every non-scoped headline — all-provider AND provider-filtered — is built
// by the one shared durable-totals builder. It unions the carry-forward
// cache with today's live parse (slicing to the provider when filtered), so
// days whose session files have expired still count. The provider list and
// daily-history sections below reuse its cache + today slice.
const durable = await buildDurablePeriod(periodInfo, {
provider: pf,
project: opts.project,
exclude: opts.exclude,
daysSelection,
})
currentData = durable.data
scanProjects = durable.liveProjects
scanRange = durable.scanRange
cacheDaysForPeriod = durable.days
cache = durable.cache
todayAllDays = durable.todayAllDays
}
claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null)

View file

@ -0,0 +1,261 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
import {
buildMenubarPayloadForRange,
buildDurablePeriod,
buildPeriodData,
getDailyCacheConfigHash,
} from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
import { renderOverview } from '../src/overview.js'
import type { DateRange } from '../src/types.js'
// The point of #755: Claude deletes transcripts after ~30 days, so a day that
// can no longer be re-derived from session files exists ONLY in the durable
// daily cache. The bug this suite guards: the CLI report / overview / TUI
// live-parsed the surviving files and dropped those carried days, so their
// totals fell short of the menubar (which unions the cache). Every surface now
// routes totals through the ONE shared builder (buildDurablePeriod), so the CLI
// report totals must equal the menubar payload totals EXACTLY — carried day
// included — across all-provider, provider-filtered, custom-range, and lifetime
// queries, and in the plain live regime with no carried days at all.
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
let savedEnv: Record<string, string | undefined>
const CARRIED_COST = 100
function daysAgoStr(n: number): string {
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** A day the cache holds but no session file can reproduce (sources aged out). */
function carriedDay(date: string): DailyEntry {
return {
date,
cost: CARRIED_COST,
savingsUSD: 0,
calls: 40,
sessions: 3,
inputTokens: 5000,
outputTokens: 2000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 4,
oneShotTurns: 2,
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
providers: {
claude: {
calls: 40, cost: CARRIED_COST, savingsUSD: 0, sessions: 3,
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 4, oneShotTurns: 2,
models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
},
},
projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } },
carried: true,
}
}
/** Write a cache whose only historical day is carried (no source files exist). */
async function seedCarriedCache(): Promise<string> {
const day = daysAgoStr(10)
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: getDailyCacheConfigHash(),
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [carriedDay(day)],
complete: true,
}
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
return day
}
/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */
async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString()
const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString()
const line = (id: string, t: string): string => JSON.stringify({
type: 'assistant',
timestamp: t,
sessionId: 's-today',
message: {
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
content: [],
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
},
})
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', ts), line('m2', ts2)].join('\n') + '\n', 'utf-8')
}
/** Live-only headline over the surviving files for the range (no cache union). */
async function liveOnly(range: DateRange): Promise<{ cost: number; calls: number }> {
clearSessionCache()
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), [], [])
const data = buildPeriodData('live', projects)
return { cost: data.cost, calls: data.calls }
}
beforeAll(async () => {
await loadPricing()
})
beforeEach(async () => {
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
await mkdir(join(ROOT, 'cache'), { recursive: true })
process.env['HOME'] = join(ROOT, 'home')
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEX_HOME']
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
})
/** The full report-vs-menubar equality for one resolved range + provider. */
async function assertParity(range: DateRange, provider: string): Promise<{ menubarCost: number; carried: number }> {
clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider, optimize: false, timeline: false })
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider })
// The report / overview / TUI headline IS durable.data; the menubar payload's
// current IS the same builder. They must agree bit-for-bit.
expect(menubar.current.cost).toBe(durable.data.cost)
expect(menubar.current.calls).toBe(durable.data.calls)
expect(menubar.current.sessions).toBe(durable.data.sessions)
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
expect(menubar.current.outputTokens).toBe(durable.data.outputTokens)
return { menubarCost: menubar.current.cost, carried: durable.carriedCostUSD }
}
describe('CLI totals ↔ menubar parity through the durable daily cache', () => {
it('counts the carried day equally on both paths for all-provider, custom-range, and lifetime', async () => {
await seedCarriedCache()
await seedLiveTodaySession()
const custom: DateRange = { start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), end: new Date() }
for (const range of [getDateRange('all').range, getDateRange('lifetime').range, custom]) {
const { menubarCost, carried } = await assertParity(range, 'all')
// The carried day is genuinely in the total: it equals the surviving-file
// parse PLUS the $100 carried day, and strictly exceeds the live-only view.
const live = await liveOnly(range)
expect(carried).toBeCloseTo(CARRIED_COST, 6)
expect(menubarCost).toBeGreaterThan(live.cost)
expect(menubarCost).toBeCloseTo(live.cost + CARRIED_COST, 6)
}
})
it('resolves provider filters identically on both paths, slicing the carried day per provider', async () => {
await seedCarriedCache()
await seedLiveTodaySession()
const range = getDateRange('all').range
// The corpus is entirely Claude, so the claude slice equals the all total.
const claude = await assertParity(range, 'claude')
clearSessionCache()
const all = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
expect(claude.menubarCost).toBeCloseTo(all.data.cost, 6)
expect(claude.carried).toBeCloseTo(CARRIED_COST, 6)
// A provider with no data is zero on both paths (no carried leak).
clearSessionCache()
const codexMenubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'codex', optimize: false, timeline: false })
clearSessionCache()
const codexDurable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'codex' })
expect(codexMenubar.current.cost).toBe(codexDurable.data.cost)
expect(codexDurable.data.cost).toBe(0)
expect(codexDurable.carriedCostUSD).toBe(0)
})
it('holds the equality in the plain live regime with no carried days', async () => {
// No cache seeded: the only data is today's surviving session.
await seedLiveTodaySession()
const range = getDateRange('all').range
const { menubarCost, carried } = await assertParity(range, 'all')
const live = await liveOnly(range)
expect(carried).toBe(0)
expect(menubarCost).toBeGreaterThan(0)
expect(menubarCost).toBeCloseTo(live.cost, 6)
})
})
describe('terminal overview carried-day footnote', () => {
it('appends the preserved-cost footnote exactly when carried > 0', async () => {
await seedCarriedCache()
await seedLiveTodaySession()
const range = getDateRange('all').range
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
expect(durable.carriedCostUSD).toBeGreaterThan(0)
const withCarried = renderOverview(durable.liveProjects, {
label: 'Last 6 months',
color: false,
durable: {
cost: durable.data.cost,
savingsUSD: durable.data.savingsUSD,
calls: durable.data.calls,
sessions: durable.data.sessions,
inputTokens: durable.data.inputTokens,
outputTokens: durable.data.outputTokens,
cacheReadTokens: durable.data.cacheReadTokens,
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
},
})
expect(withCarried).toContain('preserved from expired session logs')
})
it('omits the footnote when nothing was carried', async () => {
await seedLiveTodaySession()
const range = getDateRange('all').range
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' })
expect(durable.carriedCostUSD).toBe(0)
const noCarried = renderOverview(durable.liveProjects, {
label: 'Last 6 months',
color: false,
durable: {
cost: durable.data.cost,
savingsUSD: durable.data.savingsUSD,
calls: durable.data.calls,
sessions: durable.data.sessions,
inputTokens: durable.data.inputTokens,
outputTokens: durable.data.outputTokens,
cacheReadTokens: durable.data.cacheReadTokens,
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
},
})
expect(noCarried).not.toContain('preserved from expired session logs')
})
})