mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 13:51:50 +00:00
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:
commit
76744124ab
6 changed files with 670 additions and 154 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
130
src/main.ts
130
src/main.ts
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
261
tests/cli-durable-totals.test.ts
Normal file
261
tests/cli-durable-totals.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue