Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)

### Fixed (CLI)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,7 @@ program
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
unattributedCostUSD: durable.unattributedCostUSD,
},
}))
})
Expand Down
10 changes: 10 additions & 0 deletions src/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export type OverviewDurable = {
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
/// Cost a --project/--exclude filter could not attribute (cached days with no
/// per-project split). Optional so callers that never filter can omit it.
unattributedCostUSD?: number
}

export function renderOverview(
Expand Down Expand Up @@ -350,5 +353,12 @@ export function renderOverview(
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}

// A project filter cannot claim days the cache holds without a project split
// (recorded before that split existed), so they sit outside this total. Say how
// much rather than let the filtered figure look inexplicably short.
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
}

return out.join('\n') + '\n'
}
160 changes: 156 additions & 4 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'

// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
Expand Down Expand Up @@ -226,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
}
}

/// Does a cached day's project entry pass the active name filters? Mirrors
/// parser.filterProjectsByName exactly — case-insensitive substring match
/// against the project name OR its filesystem path, include first then exclude —
/// so a filter selects the same projects whether it is resolved against a fresh
/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
/// absent on entries whose sessions were gone before it could be recorded; the
/// name is then all there is to match on, as it is for the display layers.
function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
const n = name.toLowerCase()
const p = (path ?? '').toLowerCase()
const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
if (include.length > 0 && !include.some(hit)) return false
if (exclude.length > 0 && exclude.some(hit)) return false
return true
}

/// Sum the per-project day stats that pass the filters. `defineProperty` so a
/// project directory named "__proto__" stays an own key instead of mutating the
/// prototype link (same reason day-aggregator does it when writing them).
function sumMatchingProjects(
projects: Record<string, ProjectDayStats>,
include: string[],
exclude: string[],
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
for (const [name, p] of Object.entries(projects)) {
if (!dayProjectMatches(name, p.path, include, exclude)) continue
out.cost += p.cost
out.calls += p.calls
out.savingsUSD += p.savingsUSD ?? 0
out.sessions += p.sessions ?? 0
out.matched += 1
Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
}
return out
}

/// Collapse a day to the slice matching the active --project/--exclude filters,
/// the project-level counterpart of sliceDayToProvider. Without this a
/// project-filtered headline counted every historical day WHOLE — excluded
/// projects included — while every detail panel (By Project / By Activity / By
/// Model, all built from the name-filtered live parse) left them out, so the two
/// could not be reconciled.
///
/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
/// project, so those four are recomputed EXACTLY. The day's tokens, models and
/// categories have no per-project split to slice, so they are dropped here
/// rather than reported as if they belonged to the surviving projects;
/// buildDurablePeriod refills them from the project-filtered live parse, which
/// is exact for every session that still exists.
///
/// A day recorded before v15 has no `projects` at all and nothing can
/// reconstruct one once the sources are gone. Such a day cannot be attributed to
/// any project, so it contributes nothing to a project-filtered total and its
/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
/// (understating with a stated figure beats overstating with excluded spend).
function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
const zeroDay = (): DailyEntry => ({
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 } : {}),
})
if (!day.projects) return zeroDay()
const totals = sumMatchingProjects(day.projects, include, exclude)
if (totals.matched === 0) return zeroDay()

// Provider slices carry their own per-project split, so `--provider X` on top
// of a project filter stays consistent with the day-level slice. A slice
// adopted from a pre-v15 cache has no split and is dropped for the same
// reason the day-level one is.
const providers: Record<string, ProviderDaySlice> = {}
for (const [name, slice] of Object.entries(day.providers)) {
if (!slice.projects) continue
const sliced = sumMatchingProjects(slice.projects, include, exclude)
if (sliced.matched === 0) continue
Object.defineProperty(providers, name, {
value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
enumerable: true, writable: true, configurable: true,
})
}

return {
date: day.date,
cost: totals.cost,
savingsUSD: totals.savingsUSD,
calls: totals.calls,
sessions: totals.sessions,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
providers,
projects: totals.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.
///
/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
/// a parse the caller already name-filtered, so re-slicing them would be a no-op
/// at best and could only lose data the filter meant to keep.
function unionDaysForPeriod(
cache: DailyCache,
todayAllDays: DailyEntry[],
periodInfo: PeriodInfo,
daysSelection: Set<string> | null,
sliceHistorical?: (day: DailyEntry) => DailyEntry,
): 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
const cacheDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
// Apply the day selection BEFORE slicing so a day the heatmap filtered out
// never reaches the slicer (which tallies what it could not attribute).
const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
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
Expand All @@ -266,6 +371,12 @@ export type DurablePeriod = {
days: DailyEntry[]
/// Sum of `cost` on `carried` days included in the period (footnote source).
carriedCostUSD: number
/// Cost the active --project/--exclude filter had to set aside: cached days
/// recorded before per-project day stats existed (v15) carry no project split,
/// so they cannot be attributed to the filtered projects. Always 0 when no
/// project filter is active. Reported so a filtered total that is short of the
/// unfiltered one says so instead of just looking wrong.
unattributedCostUSD: number
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
Expand Down Expand Up @@ -325,7 +436,33 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
scanRange = isTodayOnly ? todayRange : periodInfo.range
}

const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
// Name filters must reach the cache-sourced days too. Today's parse is already
// name-filtered above (`fp`), but the historical remainder comes straight out
// of the day cache, so without this slice a --project/--exclude headline
// counted every expired-source day whole while the detail panels did not.
const projectInclude = (opts.project ?? []).map(s => s.toLowerCase())
const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase())
const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0
// What a filtered total cannot claim, and therefore has to leave out: a cached
// day with no project split at all, or — with a provider filter also active,
// since the headline then reads that provider's slice — a slice carried from a
// cache generation that predates per-project splits. Both are stated back to
// the caller (footnoted by the overview) instead of vanishing from the total.
const unattributableCost = (day: DailyEntry): number => {
if (pf === 'all') return day.projects ? 0 : day.cost
const slice = Object.hasOwn(day.providers, pf) ? day.providers[pf] : undefined
if (!slice) return 0
return !day.projects || !slice.projects ? slice.cost : 0
}
let unattributedCostUSD = 0
const sliceHistorical = hasProjectFilter
? (day: DailyEntry): DailyEntry => {
unattributedCostUSD += unattributableCost(day)
return sliceDayToProject(day, projectInclude, projectExclude)
}
: undefined

const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const data = buildPeriodDataFromDays(days, periodInfo.label)

Expand All @@ -342,6 +479,21 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
// 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)
// Tokens/models/categories have no per-project split in the day cache, so
// sliceDayToProject drops them (see there). Under a project filter they come
// from the live parse instead: exact for the filtered projects, bounded by
// source retention like every other scan-derived field above, and consistent
// with the By Model / By Activity panels that read the same parse. Cost, calls,
// sessions and savings stay durable — sliced out of the cache, expired days
// included.
if (hasProjectFilter) {
data.inputTokens = scanData.inputTokens
data.outputTokens = scanData.outputTokens
data.cacheReadTokens = scanData.cacheReadTokens
data.cacheWriteTokens = scanData.cacheWriteTokens
data.models = scanData.models
data.categories = scanData.categories
}
const estimatedByModel = new Map(
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
)
Expand All @@ -352,7 +504,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
}

const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
return { data, days, carriedCostUSD, unattributedCostUSD, liveProjects, cache, todayAllDays, scanRange }
}

/**
Expand Down
Loading