Skip to content
Merged
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
80 changes: 13 additions & 67 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,75 +472,36 @@ program.hook('preAction', async (thisCommand) => {
await loadCurrency()
})

function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable?: DurablePeriod) {
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable: DurablePeriod) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()

// 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)
const totalCostUSD = durable.data.cost
const totalSavingsUSD = durable.data.savingsUSD
const totalEstimatedUSD = durable.data.estimatedCostUSD ?? 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 = 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)
const totalCalls = durable.data.calls
const totalSessions = durable.data.sessions
const totalInput = durable.data.inputTokens
const totalOutput = durable.data.outputTokens
const totalCacheRead = durable.data.cacheReadTokens
const totalCacheWrite = durable.data.cacheWriteTokens
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0

// Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
// consumer summing daily[].editTurns over a period gets the same total as
// sum(activities[].editTurns) for that period: every turn counts once for
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; savings: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
// Prefer the user-message timestamp on the turn; fall back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions where the JSONL begins mid-conversation). Previously these
// turns dropped from daily but stayed in activities, breaking the
// sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
// Cost/savings/calls bucket under each call's OWN day — the same
// per-call rule as the durable day set (day-aggregator.ts), so this
// fallback and durable.days never diverge on a midnight-straddling
// turn (issue #852). Turn counts/edit stats stay anchored on the
// turn's day above. An unparseable call timestamp falls back to the
// turn's day rather than producing a garbage date key.
const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp)
if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[callDay].cost += call.costUSD
dailyMap[callDay].savings += call.savingsUSD ?? 0
dailyMap[callDay].calls += 1
}
}
}
// 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 => {
// to it, carried days included. Both JSON call sites always pass durable
// (#1067); the live dailyMap fallback was unreachable and is gone.
const daily = durable.days.map(d => {
const turns = Object.values(d.categories).reduce((s, c) => s + c.turns, 0)
return {
date: d.date,
Expand All @@ -555,21 +516,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
: 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,
Expand Down
25 changes: 13 additions & 12 deletions tests/day-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,18 +463,19 @@ describe('buildPeriodDataFromDays', () => {
})

describe('daily-cache ↔ report daily-bucket parity', () => {
// The daily cache (history.daily + provider breakdown) and the live report /
// headline (main.ts daily rollup) must bucket days by the SAME rule, or their
// per-day totals drift and their period sums diverge from current.cost at
// window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both
// are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats
// still turn-anchored: this asserts per-day equality against a reference
// that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its
// own date), plus the invariant history.daily Σ == report.daily Σ == total
// call cost.

// Mirrors the live report/headline daily rollup fallback in src/main.ts
// (cost/savings/calls bucket under each call's own date).
// The daily cache (history.daily + provider breakdown) and JSON-report
// daily[] rows (durable.days from buildDurablePeriod) must bucket days by the
// SAME rule, or their per-day totals drift and their period sums diverge from
// current.cost at window boundaries — the V1 audit's constant -$3.45/-81-calls
// finding. Both are now PER-CALL for cost/savings/calls (issue #852) with
// turn-level stats still turn-anchored: this asserts per-day equality against
// an independent per-call oracle for the durable day aggregation used by
// durable.days (each call on its own date), plus the invariant
// history.daily Σ == report.daily Σ == total call cost.

// Independent per-call reference for durable.days (cost/savings/calls bucket
// under each call's own date). Not a live buildJsonReport fallback — that
// path was deleted in #1067.
function reportDailyByDate(projects: ProjectSummary[]): Record<string, number> {
const byDate: Record<string, number> = {}
for (const p of projects) {
Expand Down
Loading