From 97b71c2285fd140b35705beedfca152d8d18a929 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:33:33 -0700 Subject: [PATCH] refactor(usage): split AI-usage scanners and stores under the max-lines budget (#14668) The three usage scanners and their stores, plus the renderer usage-overview model, each carried a file-level `eslint-disable max-lines` and had grown to 338-769 counted lines against a 300-line budget. AGENTS.md calls for splitting rather than suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all seven suppressions and prunes their entries (341 -> 334). Each file is cut along the seams it already had -- and that several of the suppression comments named out loud: filesystem discovery / record parsing / attribution / aggregation for the scanners, and pricing policy / scope filters / rollups / session rows / automation attribution for the stores. Pure move, no behavior change. Code is relocated verbatim; the only edits are import plumbing and, where a private class method became a free function, the mechanical `this.state` -> `state` parameter threading. Every converted call site passes `this.state` at call time and the automation path takes a live `getState: () => this.state` getter, so no state is snapshotted. No barrel exports: each new module owns real logic and importers point at the owner. Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green (remaining failures are pre-existing load flakes in untouched files, each green when re-run serially), no import cycles among the 64 affected modules, and a statement-level diff of every split confirms the moves are verbatim. --- config/max-lines-baseline.txt | 7 - .../session-scanner-opencode-sources.test.ts | 2 +- .../session-scanner-opencode-sources.ts | 2 +- src/main/claude-usage/claude-model-pricing.ts | 221 ++++++ .../claude-usage-automation-attribution.ts | 149 ++++ .../claude-usage-report-aggregation.ts | 181 +++++ .../claude-usage-scope-filters.ts | 44 ++ .../claude-usage/claude-usage-session-rows.ts | 62 ++ .../scanner-large-directory.test.ts | 2 +- src/main/claude-usage/scanner.test.ts | 9 +- src/main/claude-usage/scanner.ts | 600 +-------------- src/main/claude-usage/store.ts | 632 +-------------- .../claude-usage/transcript-file-discovery.ts | 46 ++ .../claude-usage/transcript-record-parser.ts | 195 +++++ src/main/claude-usage/usage-aggregation.ts | 205 +++++ src/main/claude-usage/worktree-attribution.ts | 151 ++++ .../codex-automation-run-attribution.ts | 181 +++++ src/main/codex-usage/codex-model-pricing.ts | 181 +++++ .../codex-session-file-discovery.ts | 169 ++++ .../codex-usage/codex-usage-cost-estimate.ts | 41 + .../codex-usage-event-attribution.ts | 113 +++ .../codex-usage/codex-usage-record-parser.ts | 161 ++++ .../codex-usage-rollup-projections.ts | 183 +++++ .../codex-usage/codex-usage-scope-filters.ts | 103 +++ .../codex-usage/codex-usage-session-rows.ts | 69 ++ .../codex-usage/codex-usage-token-delta.ts | 178 +++++ .../scanner-large-directory.test.ts | 2 +- src/main/codex-usage/scanner-paths.test.ts | 6 +- src/main/codex-usage/scanner.test.ts | 3 +- src/main/codex-usage/scanner.ts | 623 +-------------- src/main/codex-usage/store.ts | 723 +----------------- .../opencode-database-discovery.ts | 94 +++ .../opencode-usage-row-parsing.ts | 110 +++ .../opencode-usage-row-queries.ts | 164 ++++ .../opencode-usage-worktree-attribution.ts | 122 +++ .../persisted-state-normalization.ts | 68 ++ .../opencode-usage/scanner-wsl-gate.test.ts | 2 +- src/main/opencode-usage/scanner.test.ts | 11 +- src/main/opencode-usage/scanner.ts | 496 +----------- src/main/opencode-usage/scope-range-filter.ts | 40 + src/main/opencode-usage/snapshot-rollups.ts | 174 +++++ src/main/opencode-usage/store.test.ts | 3 +- src/main/opencode-usage/store.ts | 273 +------ .../components/stats/UsageOverviewPane.tsx | 8 +- .../stats/usage-overview-daily-series.ts | 120 +++ .../stats/usage-overview-model.test.ts | 8 +- .../components/stats/usage-overview-model.ts | 321 +------- .../stats/usage-overview-sections.tsx | 13 +- .../components/stats/usage-overview-types.ts | 88 +++ .../stats/usage-provider-normalization.ts | 110 +++ 50 files changed, 3867 insertions(+), 3602 deletions(-) create mode 100644 src/main/claude-usage/claude-model-pricing.ts create mode 100644 src/main/claude-usage/claude-usage-automation-attribution.ts create mode 100644 src/main/claude-usage/claude-usage-report-aggregation.ts create mode 100644 src/main/claude-usage/claude-usage-scope-filters.ts create mode 100644 src/main/claude-usage/claude-usage-session-rows.ts create mode 100644 src/main/claude-usage/transcript-file-discovery.ts create mode 100644 src/main/claude-usage/transcript-record-parser.ts create mode 100644 src/main/claude-usage/usage-aggregation.ts create mode 100644 src/main/claude-usage/worktree-attribution.ts create mode 100644 src/main/codex-usage/codex-automation-run-attribution.ts create mode 100644 src/main/codex-usage/codex-model-pricing.ts create mode 100644 src/main/codex-usage/codex-session-file-discovery.ts create mode 100644 src/main/codex-usage/codex-usage-cost-estimate.ts create mode 100644 src/main/codex-usage/codex-usage-event-attribution.ts create mode 100644 src/main/codex-usage/codex-usage-record-parser.ts create mode 100644 src/main/codex-usage/codex-usage-rollup-projections.ts create mode 100644 src/main/codex-usage/codex-usage-scope-filters.ts create mode 100644 src/main/codex-usage/codex-usage-session-rows.ts create mode 100644 src/main/codex-usage/codex-usage-token-delta.ts create mode 100644 src/main/opencode-usage/opencode-database-discovery.ts create mode 100644 src/main/opencode-usage/opencode-usage-row-parsing.ts create mode 100644 src/main/opencode-usage/opencode-usage-row-queries.ts create mode 100644 src/main/opencode-usage/opencode-usage-worktree-attribution.ts create mode 100644 src/main/opencode-usage/persisted-state-normalization.ts create mode 100644 src/main/opencode-usage/scope-range-filter.ts create mode 100644 src/main/opencode-usage/snapshot-rollups.ts create mode 100644 src/renderer/src/components/stats/usage-overview-daily-series.ts create mode 100644 src/renderer/src/components/stats/usage-overview-types.ts create mode 100644 src/renderer/src/components/stats/usage-provider-normalization.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index b84b381225d..5c9433c324d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -15,14 +15,10 @@ inline src/main/browser/cdp-bridge.ts inline src/main/browser/grab-guest-script.ts inline src/main/claude-accounts/runtime-auth-service.ts inline src/main/claude-accounts/service.ts -inline src/main/claude-usage/scanner.ts -inline src/main/claude-usage/store.ts inline src/main/cli/cli-installer.ts inline src/main/cli/wsl-cli-installer.ts inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts -inline src/main/codex-usage/scanner.ts -inline src/main/codex-usage/store.ts inline src/main/codex/config-toml-trust.ts inline src/main/codex/hook-service.ts inline src/main/daemon/client.ts @@ -57,8 +53,6 @@ inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts inline src/main/linear/projects.ts inline src/main/memory/collector.ts -inline src/main/opencode-usage/scanner.ts -inline src/main/opencode-usage/store.ts inline src/main/opencode/hook-service.ts inline src/main/persistence.ts inline src/main/ports/advertised-url-watcher.ts @@ -154,7 +148,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts -inline src/renderer/src/components/stats/usage-overview-model.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx diff --git a/src/main/ai-vault/session-scanner-opencode-sources.test.ts b/src/main/ai-vault/session-scanner-opencode-sources.test.ts index 12ca270871a..b3a7983e607 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -11,7 +11,7 @@ vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({ discoverOpenCodeSessions: discoverOpenCodeSessionsMock })) -vi.mock('../opencode-usage/scanner', () => ({ +vi.mock('../opencode-usage/opencode-database-discovery', () => ({ listOpenCodeDatabases: listOpenCodeDatabasesMock })) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index 365e6334512..4dc1303487c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -3,7 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' -import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery' import { recordSessionScanIssue } from './session-scan-issues' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts new file mode 100644 index 00000000000..6d28b3deace --- /dev/null +++ b/src/main/claude-usage/claude-model-pricing.ts @@ -0,0 +1,221 @@ +type ClaudeModelPricing = { + input: number + output: number + cacheRead: number + cacheWrite: number + thresholdTokens?: number + inputAboveThreshold?: number + outputAboveThreshold?: number + cacheReadAboveThreshold?: number + cacheWriteAboveThreshold?: number +} + +const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 +const SONNET_LONG_CONTEXT_PRICING = { + thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, + inputAboveThreshold: 6, + outputAboveThreshold: 22.5, + cacheReadAboveThreshold: 0.6, + cacheWriteAboveThreshold: 7.5 +} satisfies Partial + +const MODEL_PRICING: Record = { + 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + // Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here. + // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension. + 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + 'claude-sonnet-4-6': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, + 'claude-sonnet-4-5': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, + 'claude-sonnet-4': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, + 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, + 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } +} + +const MODEL_ALIASES: Record = { + model_placeholder_m26: 'claude-opus-4-6', + model_placeholder_m35: 'claude-sonnet-4-6', + 'claude-opus-4.8': 'claude-opus-4-8', + 'claude-opus-4.6': 'claude-opus-4-6', + 'claude-sonnet-4.6': 'claude-sonnet-4-6', + 'claude-opus-4.8-thinking': 'claude-opus-4-8', + 'claude-opus-4.6-thinking': 'claude-opus-4-6', + 'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6', + 'claude-opus-4-8-thinking': 'claude-opus-4-8', + 'claude-opus-4-6-thinking': 'claude-opus-4-6', + 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6' +} + +function hasClaudeModelVersion(model: string, family: string, version: string): boolean { + const normalized = model.replace(/\./g, '-') + return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized) +} + +function isLegacyBaseOpus4Model(model: string): boolean { + const normalized = model.replace(/\./g, '-') + return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized) +} + +function normalizeModelForPricing(model: string | null): string | null { + if (!model) { + return null + } + const lower = model + .toLowerCase() + .trim() + .replace(/^anthropic[/:]/, '') + const alias = MODEL_ALIASES[lower] + if (alias) { + return alias + } + if (hasClaudeModelVersion(lower, 'fable', '5')) { + return 'claude-fable-5' + } + if (hasClaudeModelVersion(lower, 'opus', '5')) { + return 'claude-opus-5' + } + if (hasClaudeModelVersion(lower, 'opus', '4-8')) { + return 'claude-opus-4-8' + } + if (hasClaudeModelVersion(lower, 'opus', '4-7')) { + return 'claude-opus-4-7' + } + if (hasClaudeModelVersion(lower, 'opus', '4-6')) { + return 'claude-opus-4-6' + } + if (hasClaudeModelVersion(lower, 'opus', '4-5')) { + return 'claude-opus-4-5' + } + if (hasClaudeModelVersion(lower, 'opus', '4-1')) { + return 'claude-opus-4-1' + } + if (isLegacyBaseOpus4Model(lower)) { + return 'claude-opus-4' + } + if (lower.includes('opus-4')) { + // Why: new Opus 4 point releases now share the current low Opus pricing; + // avoid overbilling unknown future Claude Code model IDs as legacy Opus 4. + return 'claude-opus-4-8' + } + if (hasClaudeModelVersion(lower, 'sonnet', '5')) { + return 'claude-sonnet-5' + } + if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) { + return 'claude-sonnet-4-6' + } + if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) { + return 'claude-sonnet-4-5' + } + if (lower.includes('sonnet-4')) { + return 'claude-sonnet-4-6' + } + if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) { + return 'claude-sonnet-3-7' + } + // Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still + // present in historical Claude Code/SDK logs read off disk. Match them so + // their cost is not silently dropped from the breakdown. + if ( + lower.includes('sonnet-3-5') || + lower.includes('sonnet-3.5') || + lower.includes('3-5-sonnet') || + lower.includes('3.5-sonnet') + ) { + return 'claude-sonnet-3-5' + } + if (lower.includes('haiku-4-5')) { + return 'claude-haiku-4-5' + } + if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) { + return 'claude-haiku-3-5' + } + if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) { + return 'claude-haiku-3-5' + } + if (lower.includes('haiku-3')) { + return 'claude-haiku-3' + } + return null +} + +function calculateTieredCost( + tokens: number, + basePrice: number, + abovePrice?: number, + threshold?: number +): number { + if (threshold === undefined || abovePrice === undefined) { + return tokens * basePrice + } + const belowTokens = Math.min(tokens, threshold) + const aboveTokens = Math.max(tokens - threshold, 0) + return belowTokens * basePrice + aboveTokens * abovePrice +} + +export function estimateCostUsd( + model: string | null, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number +): number | null { + const normalized = normalizeModelForPricing(model) + if (!normalized) { + return null + } + const pricing = MODEL_PRICING[normalized] + return ( + (calculateTieredCost( + inputTokens, + pricing.input, + pricing.inputAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + outputTokens, + pricing.output, + pricing.outputAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + cacheReadTokens, + pricing.cacheRead, + pricing.cacheReadAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + cacheWriteTokens, + pricing.cacheWrite, + pricing.cacheWriteAboveThreshold, + pricing.thresholdTokens + )) / + 1_000_000 + ) +} diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts new file mode 100644 index 00000000000..adda1ad394e --- /dev/null +++ b/src/main/claude-usage/claude-usage-automation-attribution.ts @@ -0,0 +1,149 @@ +import type { AutomationRunUsage } from '../../shared/automations-types' +import type { ClaudeUsagePersistedState } from './types' +import { estimateCostUsd } from './claude-model-pricing' + +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 + +export type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + +type ClaudeUsageStateAccess = { + getState: () => ClaudeUsagePersistedState + refresh: (force: boolean) => Promise<{ lastScanError: string | null }> +} + +function shouldForceAutomationUsageScan( + state: ClaudeUsagePersistedState, + completedAt: number +): boolean { + const { lastScanCompletedAt, lastScanError } = state.scanState + // Why: attribution needs a scan after the run finishes, but repeated + // lookups after that point should not rescan all Claude transcript history. + return Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt +} + +export async function resolveAutomationRunUsage( + input: AutomationUsageLookupInput, + access: ClaudeUsageStateAccess +): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!access.getState().scanState.enabled) { + return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await access.refresh( + shouldForceAutomationUsageScan(access.getState(), input.completedAt) + ) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = access.getState().sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Claude usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Claude usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.turns += entry.turnCount + acc.inputTokens += entry.inputTokens + acc.outputTokens += entry.outputTokens + acc.cacheReadTokens += entry.cacheReadTokens + acc.cacheWriteTokens += entry.cacheWriteTokens + return acc + }, + { + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + ) + const estimatedCostUsd = estimateCostUsd( + session.model, + totals.inputTokens, + totals.outputTokens, + totals.cacheReadTokens, + totals.cacheWriteTokens + ) + + return { + status: 'known', + provider: 'claude', + model: session.model, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens, + reasoningOutputTokens: null, + totalTokens: + totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens, + estimatedCostUsd, + estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent', + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Claude usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } +} diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts new file mode 100644 index 00000000000..f9bdd863c36 --- /dev/null +++ b/src/main/claude-usage/claude-usage-report-aggregation.ts @@ -0,0 +1,181 @@ +import type { + ClaudeUsageBreakdownKind, + ClaudeUsageBreakdownRow, + ClaudeUsageDailyPoint, + ClaudeUsageRange, + ClaudeUsageScope, + ClaudeUsageSummary +} from '../../shared/claude-usage-types' +import type { ClaudeUsagePersistedState } from './types' +import { estimateCostUsd } from './claude-model-pricing' +import { getFilteredDaily, getFilteredSessions } from './claude-usage-scope-filters' + +export function buildSummary( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange +): ClaudeUsageSummary { + const filteredDaily = getFilteredDaily(state, scope, range) + const filteredSessions = getFilteredSessions(state, scope, range) + + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + let turns = 0 + let zeroCacheReadTurns = 0 + const byModel = new Map() + const byProject = new Map() + let estimatedCostUsd = 0 + let hasAnyBillableCost = false + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + outputTokens += row.outputTokens + cacheReadTokens += row.cacheReadTokens + cacheWriteTokens += row.cacheWriteTokens + turns += row.turnCount + zeroCacheReadTurns += row.zeroCacheReadTurnCount + const modelKey = row.model ?? 'Unknown model' + byModel.set(modelKey, (byModel.get(modelKey) ?? 0) + row.inputTokens + row.outputTokens) + byProject.set( + row.projectLabel, + (byProject.get(row.projectLabel) ?? 0) + row.inputTokens + row.outputTokens + ) + const cost = estimateCostUsd( + row.model, + row.inputTokens, + row.outputTokens, + row.cacheReadTokens, + row.cacheWriteTokens + ) + if (cost !== null) { + hasAnyBillableCost = true + estimatedCostUsd += cost + } + } + + const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + turns, + zeroCacheReadTurns, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + cacheReuseRate: + inputTokens + cacheReadTokens > 0 ? cacheReadTokens / (inputTokens + cacheReadTokens) : null, + estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null, + topModel, + topProject, + // Why: the empty-state UX is scope/range specific. Using global persisted + // data here makes the Orca-only view render empty charts instead of the + // intended "no usage for this scope" message when only off-Orca logs exist. + hasAnyClaudeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } +} + +export function buildDaily( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange +): ClaudeUsageDailyPoint[] { + const byDay = new Map() + for (const row of getFilteredDaily(state, scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.outputTokens += row.outputTokens + existing.cacheReadTokens += row.cacheReadTokens + existing.cacheWriteTokens += row.cacheWriteTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) +} + +export function buildBreakdown( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange, + kind: ClaudeUsageBreakdownKind +): ClaudeUsageBreakdownRow[] { + const rows = new Map() + const filteredDaily = getFilteredDaily(state, scope, range) + const filteredSessions = getFilteredSessions(state, scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCostUsd: null + } + existing.turns += daily.turnCount + existing.inputTokens += daily.inputTokens + existing.outputTokens += daily.outputTokens + existing.cacheReadTokens += daily.cacheReadTokens + existing.cacheWriteTokens += daily.cacheWriteTokens + rows.set(key, existing) + } + + for (const session of filteredSessions) { + if (kind === 'model') { + const key = session.model ?? 'unknown' + const row = rows.get(key) + if (row) { + row.sessions++ + } + continue + } + const matchingLocations = session.locationBreakdown.filter((entry) => + scope === 'all' ? true : entry.worktreeId !== null + ) + const seen = new Set() + for (const location of matchingLocations) { + if (seen.has(location.locationKey)) { + continue + } + seen.add(location.locationKey) + const row = rows.get(location.locationKey) + if (row) { + row.sessions++ + } + } + } + + for (const row of rows.values()) { + if (kind === 'model') { + row.estimatedCostUsd = estimateCostUsd( + row.key, + row.inputTokens, + row.outputTokens, + row.cacheReadTokens, + row.cacheWriteTokens + ) + } + } + + return [...rows.values()].sort((left, right) => { + const leftTotal = left.inputTokens + left.outputTokens + const rightTotal = right.inputTokens + right.outputTokens + return rightTotal - leftTotal + }) +} diff --git a/src/main/claude-usage/claude-usage-scope-filters.ts b/src/main/claude-usage/claude-usage-scope-filters.ts new file mode 100644 index 00000000000..6fa0115c670 --- /dev/null +++ b/src/main/claude-usage/claude-usage-scope-filters.ts @@ -0,0 +1,44 @@ +import type { ClaudeUsageRange, ClaudeUsageScope } from '../../shared/claude-usage-types' +import type { ClaudeUsagePersistedState } from './types' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' + +export function getFilteredDaily( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange +) { + const cutoff = getUsageRangeCutoff(range) + return state.dailyAggregates.filter((entry) => { + if (cutoff && entry.day < cutoff) { + return false + } + if (scope === 'orca' && entry.worktreeId === null) { + return false + } + return true + }) +} + +export function getFilteredSessions( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange +) { + const cutoff = getUsageRangeCutoff(range) + return state.sessions.filter((session) => { + // Why: daily aggregates use local calendar days, so session filtering has + // to use the same conversion or the sessions table/counts can disagree + // with the chart around UTC day boundaries. + const day = getLocalUsageDay(session.lastTimestamp) + if (!day) { + return false + } + if (cutoff && day < cutoff) { + return false + } + if (scope === 'orca') { + return session.locationBreakdown.some((entry) => entry.worktreeId !== null) + } + return true + }) +} diff --git a/src/main/claude-usage/claude-usage-session-rows.ts b/src/main/claude-usage/claude-usage-session-rows.ts new file mode 100644 index 00000000000..4093dbd09c8 --- /dev/null +++ b/src/main/claude-usage/claude-usage-session-rows.ts @@ -0,0 +1,62 @@ +import type { + ClaudeUsageRange, + ClaudeUsageScope, + ClaudeUsageSessionRow +} from '../../shared/claude-usage-types' +import type { ClaudeUsagePersistedState } from './types' +import { getSessionProjectLabel } from './usage-aggregation' +import { getFilteredSessions } from './claude-usage-scope-filters' + +export function buildRecentSessions( + state: ClaudeUsagePersistedState, + scope: ClaudeUsageScope, + range: ClaudeUsageRange, + limit = 12 +): ClaudeUsageSessionRow[] { + return getFilteredSessions(state, scope, range) + .slice(0, limit) + .map((session) => { + const matchingLocations = session.locationBreakdown.filter((entry) => + scope === 'all' ? true : entry.worktreeId !== null + ) + const scopedLocations = + matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown + const totals = scopedLocations.reduce( + (acc, entry) => { + acc.turns += entry.turnCount + acc.inputTokens += entry.inputTokens + acc.outputTokens += entry.outputTokens + acc.cacheReadTokens += entry.cacheReadTokens + acc.cacheWriteTokens += entry.cacheWriteTokens + return acc + }, + { + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + ) + const durationMinutes = Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ) + return { + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes, + projectLabel: getSessionProjectLabel(scopedLocations), + branch: session.lastGitBranch, + model: session.model, + turns: totals.turns, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens + } + }) +} diff --git a/src/main/claude-usage/scanner-large-directory.test.ts b/src/main/claude-usage/scanner-large-directory.test.ts index 78d3895c58b..6cf048d6591 100644 --- a/src/main/claude-usage/scanner-large-directory.test.ts +++ b/src/main/claude-usage/scanner-large-directory.test.ts @@ -59,7 +59,7 @@ describe('listClaudeTranscriptFiles large directories', () => { throw new Error(`Unexpected readdir path: ${dirPath}`) }) - const { listClaudeTranscriptFiles } = await import('./scanner') + const { listClaudeTranscriptFiles } = await import('./transcript-file-discovery') await expect(listClaudeTranscriptFiles()).resolves.toHaveLength(FILE_COUNT) }) diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index f7de6cef141..dc4d6085a5a 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - aggregateClaudeUsage, - attributeClaudeUsageTurns, - parseClaudeUsageFile, - parseClaudeUsageRecord -} from './scanner' +import { parseClaudeUsageFile, parseClaudeUsageRecord } from './transcript-record-parser' +import { attributeClaudeUsageTurns } from './worktree-attribution' +import { aggregateClaudeUsage } from './usage-aggregation' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index e6c4f0fc8dc..1db29d5e6bd 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -1,174 +1,33 @@ -/* eslint-disable max-lines -- Why: transcript discovery, parsing, attribution, and aggregation share one data shape pipeline. Keeping them co-located makes it easier to audit correctness when Claude usage numbers look surprising. */ -import { homedir } from 'node:os' -import { join, basename } from 'node:path' -import { realpath, readdir, stat } from 'node:fs/promises' -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' +import { stat } from 'node:fs/promises' import type { - ClaudeUsageAttributedTurn, ClaudeUsageDailyAggregate, - ClaudeUsageLocationBreakdown, ClaudeUsageParsedTurn, ClaudeUsagePersistedFile, ClaudeUsageProcessedFile, ClaudeUsageSession } from './types' +import { listClaudeTranscriptFiles } from './transcript-file-discovery' +import { readClaudeUsageScanFile, stripClaudeSourceMetadata } from './transcript-record-parser' +import { + attributeClaudeUsageTurns, + buildWorktreeLookup, + type ClaudeUsageWorktreeRef +} from './worktree-attribution' +import { + aggregateClaudeUsage, + finalizeClaudeSessions, + mergeClaudeDailyAggregates, + mergeClaudeSessions +} from './usage-aggregation' -export type ClaudeUsageWorktreeRef = { - repoId: string - worktreeId: string - path: string - displayName: string -} - -type ClaudeUsageSourceRecord = { - type?: string - sessionId?: string - timestamp?: string - cwd?: string - gitBranch?: string - requestId?: string - /** Stable row id when present; preserved across fork-copied history. */ - uuid?: string - isSidechain?: boolean - agentId?: string - message?: { - id?: string - model?: string - usage?: { - input_tokens?: number - output_tokens?: number - cache_read_input_tokens?: number - cache_creation_input_tokens?: number - } - } -} - -const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') -const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts') const FILE_SCAN_BATCH_SIZE = 4 -type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & { - dedupeKey: string | null -} - -type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef] - -const sortedWorktreeEntriesByLookup = new WeakMap< - Map, - ClaudeUsageWorktreeEntry[] ->() - -function getDefaultProjectLabel(cwd: string | null): string { - if (!cwd) { - return 'Unknown location' - } - const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts.at(-1) ?? cwd -} - -async function canonicalizePath(pathValue: string): Promise { - try { - const resolved = await realpath(pathValue) - return normalizeComparablePath(resolved) - } catch { - return normalizeComparablePath(pathValue) - } -} - -function normalizeComparablePath(pathValue: string): string { - const normalized = pathValue.replace(/\\/g, '/') - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function isContainedPath(parentPath: string, childPath: string): boolean { - const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '') - const child = normalizeComparablePath(childPath).replace(/\/+$/, '') - return child === parent || child.startsWith(`${parent}/`) -} - -function findContainingWorktree( - cwd: string, - worktreeLookup: Map -): ClaudeUsageWorktreeRef | null { - const normalizedCwd = normalizeComparablePath(cwd) - const exact = worktreeLookup.get(normalizedCwd) - if (exact) { - return exact - } - - for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) { - if (isContainedPath(worktreePath, normalizedCwd)) { - return worktree - } - } - - return null -} - -function getSortedWorktreeEntries( - worktreeLookup: Map -): ClaudeUsageWorktreeEntry[] { - const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup) - if (cached) { - return cached - } - const sorted = [...worktreeLookup.entries()].sort( - ([leftPath], [rightPath]) => rightPath.length - leftPath.length - ) - sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted) - return sorted -} - // Why setImmediate: setTimeout(0) is clamped to ~1ms, and this yields once per // 4-file batch, so a 7.5k-transcript scan spent ~2s parked on timers. async function yieldToEventLoop(): Promise { await new Promise((resolve) => setImmediate(resolve)) } -async function walkJsonlFiles(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath) - } - } - - return files -} - -function appendDiscoveredFiles(target: string[], source: readonly string[]): void { - // Why: long-lived transcript directories can exceed V8's argument limit if - // child file arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) - } -} - -export async function listClaudeTranscriptFiles(): Promise { - const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] - const files = await Promise.all( - roots.map(async (root) => { - try { - return await walkJsonlFiles(root) - } catch { - return [] - } - }) - ) - return [...new Set(files.flat())].sort() -} - async function getProcessedFileStat( filePath: string ): Promise> { @@ -180,427 +39,6 @@ async function getProcessedFileStat( } } -function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsageParsedTurn { - return { - sessionId: turn.sessionId, - timestamp: turn.timestamp, - model: turn.model, - cwd: turn.cwd, - gitBranch: turn.gitBranch, - inputTokens: turn.inputTokens, - outputTokens: turn.outputTokens, - cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens - } -} - -function dedupeClaudeUsageTurns( - turns: ClaudeUsageParsedSourceTurn[] -): ClaudeUsageParsedSourceTurn[] { - const dedupeIndexByKey = new Map() - const deduped: ClaudeUsageParsedSourceTurn[] = [] - - for (const turn of turns) { - if (turn.dedupeKey) { - const existingIndex = dedupeIndexByKey.get(turn.dedupeKey) - if (existingIndex !== undefined) { - const existing = deduped[existingIndex] - // Why: Claude Code streams repeated assistant rows with the same - // message/request IDs; later rows can contain more complete usage. - existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens) - existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens) - existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens) - existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens) - continue - } - } - - deduped.push({ ...turn }) - if (turn.dedupeKey) { - dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1) - } - } - - return deduped -} - -function parseClaudeUsageSourceRecord( - line: string, - fallbackSessionId: string | null = null -): ClaudeUsageParsedSourceTurn | null { - let parsed: ClaudeUsageSourceRecord - try { - parsed = JSON.parse(line) as ClaudeUsageSourceRecord - } catch { - return null - } - - if (parsed.type !== 'assistant') { - return null - } - const sessionId = parsed.sessionId ?? fallbackSessionId - if (!sessionId || !parsed.timestamp) { - return null - } - - const usage = parsed.message?.usage - const inputTokens = usage?.input_tokens ?? 0 - const outputTokens = usage?.output_tokens ?? 0 - const cacheReadTokens = usage?.cache_read_input_tokens ?? 0 - const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0 - - if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) { - return null - } - - return { - sessionId, - timestamp: parsed.timestamp, - model: parsed.message?.model ?? null, - cwd: parsed.cwd ?? null, - gitBranch: parsed.gitBranch ?? null, - // Why: forks rewrite sessionId but keep message/request ids (and usually - // uuid). Prefer the strongest stable identity available so ownership still - // works when requestId is missing on older or partial rows. - dedupeKey: buildClaudeUsageDedupeKey(parsed), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens - } -} - -function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null { - const messageId = parsed.message?.id?.trim() - const requestId = parsed.requestId?.trim() - if (messageId && requestId) { - return `${messageId}:${requestId}` - } - if (messageId) { - return `msg:${messageId}` - } - const uuid = parsed.uuid?.trim() - if (uuid) { - return `uuid:${uuid}` - } - return null -} - -export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null { - const parsed = parseClaudeUsageSourceRecord(line) - return parsed ? stripClaudeSourceMetadata(parsed) : null -} - -export async function parseClaudeUsageFile(filePath: string): Promise { - const turns: ClaudeUsageParsedSourceTurn[] = [] - const fallbackSessionId = basename(filePath, '.jsonl') - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) - if (parsed) { - turns.push(parsed) - } - } - - return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata) -} - -async function readClaudeUsageScanFile(filePath: string): Promise<{ - processedFile: ClaudeUsageProcessedFile - turns: ClaudeUsageParsedSourceTurn[] -}> { - const fileStat = await stat(filePath) - let lineCount = 0 - const turns: ClaudeUsageParsedSourceTurn[] = [] - const fallbackSessionId = basename(filePath, '.jsonl') - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - lineCount++ - const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) - if (parsed) { - turns.push(parsed) - } - } - - return { - processedFile: { - path: filePath, - mtimeMs: fileStat.mtimeMs, - size: fileStat.size, - lineCount - }, - turns: dedupeClaudeUsageTurns(turns) - } -} - -function localDayFromTimestamp(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -export async function buildWorktreeLookup( - worktrees: ClaudeUsageWorktreeRef[] -): Promise> { - const lookup = new Map() - for (const worktree of worktrees) { - lookup.set(await canonicalizePath(worktree.path), worktree) - } - return lookup -} - -export async function attributeClaudeUsageTurns( - turns: ClaudeUsageParsedTurn[], - worktreeLookup: Map -): Promise { - const attributed: ClaudeUsageAttributedTurn[] = [] - const canonicalCwdByPath = new Map() - - for (const turn of turns) { - const day = localDayFromTimestamp(turn.timestamp) - if (!day) { - continue - } - - let repoId: string | null = null - let worktreeId: string | null = null - let projectKey = 'unscoped' - let projectLabel = getDefaultProjectLabel(turn.cwd) - - if (turn.cwd) { - let canonicalCwd = canonicalCwdByPath.get(turn.cwd) - if (canonicalCwd === undefined) { - // Why: Claude transcripts repeat the same cwd for many consecutive - // turns. Cache realpath work so attribution scales with unique paths. - canonicalCwd = await canonicalizePath(turn.cwd) - canonicalCwdByPath.set(turn.cwd, canonicalCwd) - } - const worktree = findContainingWorktree(canonicalCwd, worktreeLookup) - if (worktree) { - repoId = worktree.repoId - worktreeId = worktree.worktreeId - projectKey = `worktree:${worktreeId}` - projectLabel = worktree.displayName - } else { - projectKey = `cwd:${normalizeComparablePath(turn.cwd)}` - } - } - - attributed.push({ - ...turn, - day, - projectKey, - projectLabel, - repoId, - worktreeId - }) - } - - return attributed -} - -function mergeClaudeSessions( - target: Map, - sessions: ClaudeUsageSession[] -): void { - for (const session of sessions) { - const existing = target.get(session.sessionId) - if (!existing) { - target.set(session.sessionId, structuredClone(session)) - continue - } - - if (session.firstTimestamp < existing.firstTimestamp) { - existing.firstTimestamp = session.firstTimestamp - } - if (session.lastTimestamp > existing.lastTimestamp) { - existing.lastTimestamp = session.lastTimestamp - existing.lastCwd = session.lastCwd - existing.lastGitBranch = session.lastGitBranch - } - existing.model = session.model ?? existing.model - existing.turnCount += session.turnCount - existing.totalInputTokens += session.totalInputTokens - existing.totalOutputTokens += session.totalOutputTokens - existing.totalCacheReadTokens += session.totalCacheReadTokens - existing.totalCacheWriteTokens += session.totalCacheWriteTokens - - for (const location of session.locationBreakdown) { - const existingLocation = - existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? - null - if (existingLocation) { - existingLocation.turnCount += location.turnCount - existingLocation.inputTokens += location.inputTokens - existingLocation.outputTokens += location.outputTokens - existingLocation.cacheReadTokens += location.cacheReadTokens - existingLocation.cacheWriteTokens += location.cacheWriteTokens - } else { - existing.locationBreakdown.push({ ...location }) - } - } - } -} - -function mergeClaudeDailyAggregates( - target: Map, - dailyAggregates: ClaudeUsageDailyAggregate[] -): void { - for (const aggregate of dailyAggregates) { - const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') - const existing = target.get(key) - if (!existing) { - target.set(key, { ...aggregate }) - continue - } - existing.turnCount += aggregate.turnCount - existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount - existing.inputTokens += aggregate.inputTokens - existing.outputTokens += aggregate.outputTokens - existing.cacheReadTokens += aggregate.cacheReadTokens - existing.cacheWriteTokens += aggregate.cacheWriteTokens - } -} - -function finalizeClaudeSessions( - sessionsById: Map -): ClaudeUsageSession[] { - for (const session of sessionsById.values()) { - session.locationBreakdown.sort((left, right) => { - const leftTotal = left.inputTokens + left.outputTokens - const rightTotal = right.inputTokens + right.outputTokens - return rightTotal - leftTotal - }) - const primaryLocation = session.locationBreakdown[0] ?? null - if (primaryLocation) { - session.primaryRepoId = primaryLocation.repoId - session.primaryWorktreeId = primaryLocation.worktreeId - } - } - - return [...sessionsById.values()].sort((left, right) => - right.lastTimestamp.localeCompare(left.lastTimestamp) - ) -} - -export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { - sessions: ClaudeUsageSession[] - dailyAggregates: ClaudeUsageDailyAggregate[] -} { - const sessionsById = new Map() - const dailyByKey = new Map() - - for (const turn of turns) { - const existingSession = sessionsById.get(turn.sessionId) - if (!existingSession) { - sessionsById.set(turn.sessionId, { - sessionId: turn.sessionId, - firstTimestamp: turn.timestamp, - lastTimestamp: turn.timestamp, - model: turn.model, - lastCwd: turn.cwd, - lastGitBranch: turn.gitBranch, - primaryWorktreeId: turn.worktreeId, - primaryRepoId: turn.repoId, - turnCount: 0, - totalInputTokens: 0, - totalOutputTokens: 0, - totalCacheReadTokens: 0, - totalCacheWriteTokens: 0, - locationBreakdown: [] - }) - } - - const session = sessionsById.get(turn.sessionId)! - if (turn.timestamp < session.firstTimestamp) { - session.firstTimestamp = turn.timestamp - } - if (turn.timestamp > session.lastTimestamp) { - session.lastTimestamp = turn.timestamp - session.lastCwd = turn.cwd - session.lastGitBranch = turn.gitBranch - } - session.model = turn.model ?? session.model - session.turnCount++ - session.totalInputTokens += turn.inputTokens - session.totalOutputTokens += turn.outputTokens - session.totalCacheReadTokens += turn.cacheReadTokens - session.totalCacheWriteTokens += turn.cacheWriteTokens - - const location = - session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null - if (location) { - location.turnCount++ - location.inputTokens += turn.inputTokens - location.outputTokens += turn.outputTokens - location.cacheReadTokens += turn.cacheReadTokens - location.cacheWriteTokens += turn.cacheWriteTokens - } else { - session.locationBreakdown.push({ - locationKey: turn.projectKey, - projectLabel: turn.projectLabel, - repoId: turn.repoId, - worktreeId: turn.worktreeId, - turnCount: 1, - inputTokens: turn.inputTokens, - outputTokens: turn.outputTokens, - cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens - }) - } - - const dailyKey = [turn.day, turn.model ?? 'unknown', turn.projectKey].join('::') - const existingDaily = dailyByKey.get(dailyKey) - if (existingDaily) { - existingDaily.turnCount++ - if (turn.cacheReadTokens === 0) { - existingDaily.zeroCacheReadTurnCount++ - } - existingDaily.inputTokens += turn.inputTokens - existingDaily.outputTokens += turn.outputTokens - existingDaily.cacheReadTokens += turn.cacheReadTokens - existingDaily.cacheWriteTokens += turn.cacheWriteTokens - } else { - dailyByKey.set(dailyKey, { - day: turn.day, - model: turn.model, - projectKey: turn.projectKey, - projectLabel: turn.projectLabel, - repoId: turn.repoId, - worktreeId: turn.worktreeId, - turnCount: 1, - zeroCacheReadTurnCount: turn.cacheReadTokens === 0 ? 1 : 0, - inputTokens: turn.inputTokens, - outputTokens: turn.outputTokens, - cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens - }) - } - } - - return { - sessions: finalizeClaudeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) - } -} - export async function scanClaudeUsageFiles( worktrees: ClaudeUsageWorktreeRef[], previousProcessedFiles: ClaudeUsagePersistedFile[] = [] @@ -737,13 +175,3 @@ export async function scanClaudeUsageFiles( ) } } - -export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string { - if (locationBreakdown.length === 0) { - return 'Unknown location' - } - if (locationBreakdown.length === 1) { - return locationBreakdown[0].projectLabel - } - return 'Multiple locations' -} diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 66085504089..466b30571cb 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' import type { @@ -14,102 +13,22 @@ import type { import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import type { ClaudeUsagePersistedState } from './types' -import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' -import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { scanClaudeUsageFiles } from './scanner' import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' +import { buildBreakdown, buildDaily, buildSummary } from './claude-usage-report-aggregation' +import { buildRecentSessions } from './claude-usage-session-rows' +import type { AutomationUsageLookupInput } from './claude-usage-automation-attribution' +import { resolveAutomationRunUsage } from './claude-usage-automation-attribution' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. let _claudeUsageFile: string | null = null -type ClaudeModelPricing = { - input: number - output: number - cacheRead: number - cacheWrite: number - thresholdTokens?: number - inputAboveThreshold?: number - outputAboveThreshold?: number - cacheReadAboveThreshold?: number - cacheWriteAboveThreshold?: number -} - -type AutomationUsageLookupInput = { - worktreeId: string | null - terminalSessionId: string | null - startedAt: number | null - completedAt: number | null -} - -const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 -const SONNET_LONG_CONTEXT_PRICING = { - thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, - inputAboveThreshold: 6, - outputAboveThreshold: 22.5, - cacheReadAboveThreshold: 0.6, - cacheWriteAboveThreshold: 7.5 -} satisfies Partial - -const MODEL_PRICING: Record = { - 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - // Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here. - // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension. - 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - 'claude-sonnet-4-6': { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - ...SONNET_LONG_CONTEXT_PRICING - }, - 'claude-sonnet-4-5': { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - ...SONNET_LONG_CONTEXT_PRICING - }, - 'claude-sonnet-4': { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - ...SONNET_LONG_CONTEXT_PRICING - }, - 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, - 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } -} - -const MODEL_ALIASES: Record = { - model_placeholder_m26: 'claude-opus-4-6', - model_placeholder_m35: 'claude-sonnet-4-6', - 'claude-opus-4.8': 'claude-opus-4-8', - 'claude-opus-4.6': 'claude-opus-4-6', - 'claude-sonnet-4.6': 'claude-sonnet-4-6', - 'claude-opus-4.8-thinking': 'claude-opus-4-8', - 'claude-opus-4.6-thinking': 'claude-opus-4-6', - 'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6', - 'claude-opus-4-8-thinking': 'claude-opus-4-8', - 'claude-opus-4-6-thinking': 'claude-opus-4-6', - 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6' -} - function getDefaultState(): ClaudeUsagePersistedState { return { schemaVersion: SCHEMA_VERSION, @@ -152,153 +71,6 @@ function getClaudeUsageFile(): string { return _claudeUsageFile } -function hasClaudeModelVersion(model: string, family: string, version: string): boolean { - const normalized = model.replace(/\./g, '-') - return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized) -} - -function isLegacyBaseOpus4Model(model: string): boolean { - const normalized = model.replace(/\./g, '-') - return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized) -} - -function normalizeModelForPricing(model: string | null): string | null { - if (!model) { - return null - } - const lower = model - .toLowerCase() - .trim() - .replace(/^anthropic[/:]/, '') - const alias = MODEL_ALIASES[lower] - if (alias) { - return alias - } - if (hasClaudeModelVersion(lower, 'fable', '5')) { - return 'claude-fable-5' - } - if (hasClaudeModelVersion(lower, 'opus', '5')) { - return 'claude-opus-5' - } - if (hasClaudeModelVersion(lower, 'opus', '4-8')) { - return 'claude-opus-4-8' - } - if (hasClaudeModelVersion(lower, 'opus', '4-7')) { - return 'claude-opus-4-7' - } - if (hasClaudeModelVersion(lower, 'opus', '4-6')) { - return 'claude-opus-4-6' - } - if (hasClaudeModelVersion(lower, 'opus', '4-5')) { - return 'claude-opus-4-5' - } - if (hasClaudeModelVersion(lower, 'opus', '4-1')) { - return 'claude-opus-4-1' - } - if (isLegacyBaseOpus4Model(lower)) { - return 'claude-opus-4' - } - if (lower.includes('opus-4')) { - // Why: new Opus 4 point releases now share the current low Opus pricing; - // avoid overbilling unknown future Claude Code model IDs as legacy Opus 4. - return 'claude-opus-4-8' - } - if (hasClaudeModelVersion(lower, 'sonnet', '5')) { - return 'claude-sonnet-5' - } - if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) { - return 'claude-sonnet-4-6' - } - if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) { - return 'claude-sonnet-4-5' - } - if (lower.includes('sonnet-4')) { - return 'claude-sonnet-4-6' - } - if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) { - return 'claude-sonnet-3-7' - } - // Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still - // present in historical Claude Code/SDK logs read off disk. Match them so - // their cost is not silently dropped from the breakdown. - if ( - lower.includes('sonnet-3-5') || - lower.includes('sonnet-3.5') || - lower.includes('3-5-sonnet') || - lower.includes('3.5-sonnet') - ) { - return 'claude-sonnet-3-5' - } - if (lower.includes('haiku-4-5')) { - return 'claude-haiku-4-5' - } - if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) { - return 'claude-haiku-3-5' - } - if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) { - return 'claude-haiku-3-5' - } - if (lower.includes('haiku-3')) { - return 'claude-haiku-3' - } - return null -} - -function calculateTieredCost( - tokens: number, - basePrice: number, - abovePrice?: number, - threshold?: number -): number { - if (threshold === undefined || abovePrice === undefined) { - return tokens * basePrice - } - const belowTokens = Math.min(tokens, threshold) - const aboveTokens = Math.max(tokens - threshold, 0) - return belowTokens * basePrice + aboveTokens * abovePrice -} - -function estimateCostUsd( - model: string | null, - inputTokens: number, - outputTokens: number, - cacheReadTokens: number, - cacheWriteTokens: number -): number | null { - const normalized = normalizeModelForPricing(model) - if (!normalized) { - return null - } - const pricing = MODEL_PRICING[normalized] - return ( - (calculateTieredCost( - inputTokens, - pricing.input, - pricing.inputAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - outputTokens, - pricing.output, - pricing.outputAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - cacheReadTokens, - pricing.cacheRead, - pricing.cacheReadAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - cacheWriteTokens, - pricing.cacheWrite, - pricing.cacheWriteAboveThreshold, - pricing.thresholdTokens - )) / - 1_000_000 - ) -} - export class ClaudeUsageStore extends UsageProviderStoreLifecycle< 'processedFiles', ClaudeUsagePersistedState, @@ -324,87 +96,17 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< ): ClaudeUsageSnapshot { return { scanState: this.getScanState(), - summary: this.buildSummary(scope, range), - daily: this.buildDaily(scope, range), - modelBreakdown: this.buildBreakdown(scope, range, 'model'), - projectBreakdown: this.buildBreakdown(scope, range, 'project'), - recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit) + summary: buildSummary(this.state, scope, range), + daily: buildDaily(this.state, scope, range), + modelBreakdown: buildBreakdown(this.state, scope, range, 'model'), + projectBreakdown: buildBreakdown(this.state, scope, range, 'project'), + recentSessions: buildRecentSessions(this.state, scope, range, recentSessionLimit) } } async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) - return this.buildSummary(scope, range) - } - - private buildSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): ClaudeUsageSummary { - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - let inputTokens = 0 - let outputTokens = 0 - let cacheReadTokens = 0 - let cacheWriteTokens = 0 - let turns = 0 - let zeroCacheReadTurns = 0 - const byModel = new Map() - const byProject = new Map() - let estimatedCostUsd = 0 - let hasAnyBillableCost = false - - for (const row of filteredDaily) { - inputTokens += row.inputTokens - outputTokens += row.outputTokens - cacheReadTokens += row.cacheReadTokens - cacheWriteTokens += row.cacheWriteTokens - turns += row.turnCount - zeroCacheReadTurns += row.zeroCacheReadTurnCount - const modelKey = row.model ?? 'Unknown model' - byModel.set(modelKey, (byModel.get(modelKey) ?? 0) + row.inputTokens + row.outputTokens) - byProject.set( - row.projectLabel, - (byProject.get(row.projectLabel) ?? 0) + row.inputTokens + row.outputTokens - ) - const cost = estimateCostUsd( - row.model, - row.inputTokens, - row.outputTokens, - row.cacheReadTokens, - row.cacheWriteTokens - ) - if (cost !== null) { - hasAnyBillableCost = true - estimatedCostUsd += cost - } - } - - const topModel = - [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - const topProject = - [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - - return { - scope, - range, - sessions: filteredSessions.length, - turns, - zeroCacheReadTurns, - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - cacheReuseRate: - inputTokens + cacheReadTokens > 0 - ? cacheReadTokens / (inputTokens + cacheReadTokens) - : null, - estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null, - topModel, - topProject, - // Why: the empty-state UX is scope/range specific. Using global persisted - // data here makes the Orca-only view render empty charts instead of the - // intended "no usage for this scope" message when only off-Orca logs exist. - hasAnyClaudeData: filteredSessions.length > 0 || filteredDaily.length > 0 - } + return buildSummary(this.state, scope, range) } async getDaily( @@ -412,26 +114,7 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< range: ClaudeUsageRange ): Promise { await this.refresh(false) - return this.buildDaily(scope, range) - } - - private buildDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange): ClaudeUsageDailyPoint[] { - const byDay = new Map() - for (const row of this.getFilteredDaily(scope, range)) { - const existing = byDay.get(row.day) ?? { - day: row.day, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0 - } - existing.inputTokens += row.inputTokens - existing.outputTokens += row.outputTokens - existing.cacheReadTokens += row.cacheReadTokens - existing.cacheWriteTokens += row.cacheWriteTokens - byDay.set(row.day, existing) - } - return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + return buildDaily(this.state, scope, range) } async getBreakdown( @@ -440,82 +123,7 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< kind: ClaudeUsageBreakdownKind ): Promise { await this.refresh(false) - return this.buildBreakdown(scope, range, kind) - } - - private buildBreakdown( - scope: ClaudeUsageScope, - range: ClaudeUsageRange, - kind: ClaudeUsageBreakdownKind - ): ClaudeUsageBreakdownRow[] { - const rows = new Map() - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - for (const daily of filteredDaily) { - const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey - const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel - const existing = rows.get(key) ?? { - key, - label, - sessions: 0, - turns: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - estimatedCostUsd: null - } - existing.turns += daily.turnCount - existing.inputTokens += daily.inputTokens - existing.outputTokens += daily.outputTokens - existing.cacheReadTokens += daily.cacheReadTokens - existing.cacheWriteTokens += daily.cacheWriteTokens - rows.set(key, existing) - } - - for (const session of filteredSessions) { - if (kind === 'model') { - const key = session.model ?? 'unknown' - const row = rows.get(key) - if (row) { - row.sessions++ - } - continue - } - const matchingLocations = session.locationBreakdown.filter((entry) => - scope === 'all' ? true : entry.worktreeId !== null - ) - const seen = new Set() - for (const location of matchingLocations) { - if (seen.has(location.locationKey)) { - continue - } - seen.add(location.locationKey) - const row = rows.get(location.locationKey) - if (row) { - row.sessions++ - } - } - } - - for (const row of rows.values()) { - if (kind === 'model') { - row.estimatedCostUsd = estimateCostUsd( - row.key, - row.inputTokens, - row.outputTokens, - row.cacheReadTokens, - row.cacheWriteTokens - ) - } - } - - return [...rows.values()].sort((left, right) => { - const leftTotal = left.inputTokens + left.outputTokens - const rightTotal = right.inputTokens + right.outputTokens - return rightTotal - leftTotal - }) + return buildBreakdown(this.state, scope, range, kind) } async getRecentSessions( @@ -524,219 +132,13 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< limit = 12 ): Promise { await this.refresh(false) - return this.buildRecentSessions(scope, range, limit) - } - - private buildRecentSessions( - scope: ClaudeUsageScope, - range: ClaudeUsageRange, - limit = 12 - ): ClaudeUsageSessionRow[] { - return this.getFilteredSessions(scope, range) - .slice(0, limit) - .map((session) => { - const matchingLocations = session.locationBreakdown.filter((entry) => - scope === 'all' ? true : entry.worktreeId !== null - ) - const scopedLocations = - matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown - const totals = scopedLocations.reduce( - (acc, entry) => { - acc.turns += entry.turnCount - acc.inputTokens += entry.inputTokens - acc.outputTokens += entry.outputTokens - acc.cacheReadTokens += entry.cacheReadTokens - acc.cacheWriteTokens += entry.cacheWriteTokens - return acc - }, - { - turns: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0 - } - ) - const durationMinutes = Math.max( - 0, - Math.round( - (new Date(session.lastTimestamp).getTime() - - new Date(session.firstTimestamp).getTime()) / - 60_000 - ) - ) - return { - sessionId: session.sessionId, - lastActiveAt: session.lastTimestamp, - durationMinutes, - projectLabel: getSessionProjectLabel(scopedLocations), - branch: session.lastGitBranch, - model: session.model, - turns: totals.turns, - inputTokens: totals.inputTokens, - outputTokens: totals.outputTokens, - cacheReadTokens: totals.cacheReadTokens, - cacheWriteTokens: totals.cacheWriteTokens - } - }) + return buildRecentSessions(this.state, scope, range, limit) } async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { - const collectedAt = Date.now() - const unavailable = ( - unavailableReason: AutomationRunUsage['unavailableReason'], - unavailableMessage: string - ): AutomationRunUsage => ({ - status: 'unavailable', - provider: 'claude', - model: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - reasoningOutputTokens: null, - totalTokens: null, - estimatedCostUsd: null, - estimatedCostSource: null, - providerSessionId: null, - attribution: null, - collectedAt, - unavailableReason, - unavailableMessage + return resolveAutomationRunUsage(input, { + getState: () => this.state, + refresh: (force) => this.refresh(force) }) - - if (!this.state.scanState.enabled) { - return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.') - } - if (!input.worktreeId || !input.startedAt || !input.completedAt) { - return unavailable('no_matching_session', 'Run session metadata is incomplete.') - } - - const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt)) - if (scanState.lastScanError) { - return unavailable('scan_failed', scanState.lastScanError) - } - - const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS - const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS - const candidates = this.state.sessions.filter((session) => { - const first = new Date(session.firstTimestamp).getTime() - const last = new Date(session.lastTimestamp).getTime() - if (!Number.isFinite(first) || !Number.isFinite(last)) { - return false - } - if (session.sessionId === input.terminalSessionId) { - return true - } - if (first < windowStart || first > windowEnd || last > windowEnd) { - return false - } - return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) - }) - - if (candidates.length === 0) { - return unavailable('no_matching_session', 'No Claude usage session matched this run.') - } - if (candidates.length > 1) { - return unavailable( - 'ambiguous_session', - 'Multiple Claude usage sessions matched this run window.' - ) - } - - const session = candidates[0] - const scopedLocations = session.locationBreakdown.filter( - (entry) => entry.worktreeId === input.worktreeId - ) - const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown - const totals = locations.reduce( - (acc, entry) => { - acc.turns += entry.turnCount - acc.inputTokens += entry.inputTokens - acc.outputTokens += entry.outputTokens - acc.cacheReadTokens += entry.cacheReadTokens - acc.cacheWriteTokens += entry.cacheWriteTokens - return acc - }, - { - turns: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0 - } - ) - const estimatedCostUsd = estimateCostUsd( - session.model, - totals.inputTokens, - totals.outputTokens, - totals.cacheReadTokens, - totals.cacheWriteTokens - ) - - return { - status: 'known', - provider: 'claude', - model: session.model, - inputTokens: totals.inputTokens, - outputTokens: totals.outputTokens, - cacheReadTokens: totals.cacheReadTokens, - cacheWriteTokens: totals.cacheWriteTokens, - reasoningOutputTokens: null, - totalTokens: - totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens, - estimatedCostUsd, - estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent', - providerSessionId: session.sessionId, - // Why: Orca terminal tab ids and Claude usage session ids are different - // systems today, so attribution is intentionally limited to one local - // provider session in the run's worktree/time window. - attribution: 'provider_session_time_window', - collectedAt, - unavailableReason: null, - unavailableMessage: null - } - } - - private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getUsageRangeCutoff(range) - return this.state.dailyAggregates.filter((entry) => { - if (cutoff && entry.day < cutoff) { - return false - } - if (scope === 'orca' && entry.worktreeId === null) { - return false - } - return true - }) - } - - private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getUsageRangeCutoff(range) - return this.state.sessions.filter((session) => { - // Why: daily aggregates use local calendar days, so session filtering has - // to use the same conversion or the sessions table/counts can disagree - // with the chart around UTC day boundaries. - const day = getLocalUsageDay(session.lastTimestamp) - if (!day) { - return false - } - if (cutoff && day < cutoff) { - return false - } - if (scope === 'orca') { - return session.locationBreakdown.some((entry) => entry.worktreeId !== null) - } - return true - }) - } - - private shouldForceAutomationUsageScan(completedAt: number): boolean { - const { lastScanCompletedAt, lastScanError } = this.state.scanState - // Why: attribution needs a scan after the run finishes, but repeated - // lookups after that point should not rescan all Claude transcript history. - return ( - Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt - ) } } diff --git a/src/main/claude-usage/transcript-file-discovery.ts b/src/main/claude-usage/transcript-file-discovery.ts new file mode 100644 index 00000000000..7ca1e770cae --- /dev/null +++ b/src/main/claude-usage/transcript-file-discovery.ts @@ -0,0 +1,46 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readdir } from 'node:fs/promises' + +const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') +const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts') + +async function walkJsonlFiles(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) + } + } + + return files +} + +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: long-lived transcript directories can exceed V8's argument limit if + // child file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +export async function listClaudeTranscriptFiles(): Promise { + const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] + const files = await Promise.all( + roots.map(async (root) => { + try { + return await walkJsonlFiles(root) + } catch { + return [] + } + }) + ) + return [...new Set(files.flat())].sort() +} diff --git a/src/main/claude-usage/transcript-record-parser.ts b/src/main/claude-usage/transcript-record-parser.ts new file mode 100644 index 00000000000..50623fa295f --- /dev/null +++ b/src/main/claude-usage/transcript-record-parser.ts @@ -0,0 +1,195 @@ +import { basename } from 'node:path' +import { stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { ClaudeUsageParsedTurn, ClaudeUsageProcessedFile } from './types' + +type ClaudeUsageSourceRecord = { + type?: string + sessionId?: string + timestamp?: string + cwd?: string + gitBranch?: string + requestId?: string + /** Stable row id when present; preserved across fork-copied history. */ + uuid?: string + isSidechain?: boolean + agentId?: string + message?: { + id?: string + model?: string + usage?: { + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + } + } +} + +export type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & { + dedupeKey: string | null +} + +export function stripClaudeSourceMetadata( + turn: ClaudeUsageParsedSourceTurn +): ClaudeUsageParsedTurn { + return { + sessionId: turn.sessionId, + timestamp: turn.timestamp, + model: turn.model, + cwd: turn.cwd, + gitBranch: turn.gitBranch, + inputTokens: turn.inputTokens, + outputTokens: turn.outputTokens, + cacheReadTokens: turn.cacheReadTokens, + cacheWriteTokens: turn.cacheWriteTokens + } +} + +function dedupeClaudeUsageTurns( + turns: ClaudeUsageParsedSourceTurn[] +): ClaudeUsageParsedSourceTurn[] { + const dedupeIndexByKey = new Map() + const deduped: ClaudeUsageParsedSourceTurn[] = [] + + for (const turn of turns) { + if (turn.dedupeKey) { + const existingIndex = dedupeIndexByKey.get(turn.dedupeKey) + if (existingIndex !== undefined) { + const existing = deduped[existingIndex] + // Why: Claude Code streams repeated assistant rows with the same + // message/request IDs; later rows can contain more complete usage. + existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens) + existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens) + existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens) + existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens) + continue + } + } + + deduped.push({ ...turn }) + if (turn.dedupeKey) { + dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1) + } + } + + return deduped +} + +function parseClaudeUsageSourceRecord( + line: string, + fallbackSessionId: string | null = null +): ClaudeUsageParsedSourceTurn | null { + let parsed: ClaudeUsageSourceRecord + try { + parsed = JSON.parse(line) as ClaudeUsageSourceRecord + } catch { + return null + } + + if (parsed.type !== 'assistant') { + return null + } + const sessionId = parsed.sessionId ?? fallbackSessionId + if (!sessionId || !parsed.timestamp) { + return null + } + + const usage = parsed.message?.usage + const inputTokens = usage?.input_tokens ?? 0 + const outputTokens = usage?.output_tokens ?? 0 + const cacheReadTokens = usage?.cache_read_input_tokens ?? 0 + const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0 + + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) { + return null + } + + return { + sessionId, + timestamp: parsed.timestamp, + model: parsed.message?.model ?? null, + cwd: parsed.cwd ?? null, + gitBranch: parsed.gitBranch ?? null, + // Why: forks rewrite sessionId but keep message/request ids (and usually + // uuid). Prefer the strongest stable identity available so ownership still + // works when requestId is missing on older or partial rows. + dedupeKey: buildClaudeUsageDedupeKey(parsed), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens + } +} + +function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null { + const messageId = parsed.message?.id?.trim() + const requestId = parsed.requestId?.trim() + if (messageId && requestId) { + return `${messageId}:${requestId}` + } + if (messageId) { + return `msg:${messageId}` + } + const uuid = parsed.uuid?.trim() + if (uuid) { + return `uuid:${uuid}` + } + return null +} + +export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null { + const parsed = parseClaudeUsageSourceRecord(line) + return parsed ? stripClaudeSourceMetadata(parsed) : null +} + +export async function parseClaudeUsageFile(filePath: string): Promise { + const turns: ClaudeUsageParsedSourceTurn[] = [] + const fallbackSessionId = basename(filePath, '.jsonl') + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) + if (parsed) { + turns.push(parsed) + } + } + + return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata) +} + +export async function readClaudeUsageScanFile(filePath: string): Promise<{ + processedFile: ClaudeUsageProcessedFile + turns: ClaudeUsageParsedSourceTurn[] +}> { + const fileStat = await stat(filePath) + let lineCount = 0 + const turns: ClaudeUsageParsedSourceTurn[] = [] + const fallbackSessionId = basename(filePath, '.jsonl') + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + lineCount++ + const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) + if (parsed) { + turns.push(parsed) + } + } + + return { + processedFile: { + path: filePath, + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + lineCount + }, + turns: dedupeClaudeUsageTurns(turns) + } +} diff --git a/src/main/claude-usage/usage-aggregation.ts b/src/main/claude-usage/usage-aggregation.ts new file mode 100644 index 00000000000..5ead6af0177 --- /dev/null +++ b/src/main/claude-usage/usage-aggregation.ts @@ -0,0 +1,205 @@ +import type { + ClaudeUsageAttributedTurn, + ClaudeUsageDailyAggregate, + ClaudeUsageLocationBreakdown, + ClaudeUsageSession +} from './types' + +export function mergeClaudeSessions( + target: Map, + sessions: ClaudeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + if (session.firstTimestamp < existing.firstTimestamp) { + existing.firstTimestamp = session.firstTimestamp + } + if (session.lastTimestamp > existing.lastTimestamp) { + existing.lastTimestamp = session.lastTimestamp + existing.lastCwd = session.lastCwd + existing.lastGitBranch = session.lastGitBranch + } + existing.model = session.model ?? existing.model + existing.turnCount += session.turnCount + existing.totalInputTokens += session.totalInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalCacheReadTokens += session.totalCacheReadTokens + existing.totalCacheWriteTokens += session.totalCacheWriteTokens + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.turnCount += location.turnCount + existingLocation.inputTokens += location.inputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.cacheReadTokens += location.cacheReadTokens + existingLocation.cacheWriteTokens += location.cacheWriteTokens + } else { + existing.locationBreakdown.push({ ...location }) + } + } + } +} + +export function mergeClaudeDailyAggregates( + target: Map, + dailyAggregates: ClaudeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.turnCount += aggregate.turnCount + existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount + existing.inputTokens += aggregate.inputTokens + existing.outputTokens += aggregate.outputTokens + existing.cacheReadTokens += aggregate.cacheReadTokens + existing.cacheWriteTokens += aggregate.cacheWriteTokens + } +} + +export function finalizeClaudeSessions( + sessionsById: Map +): ClaudeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => { + const leftTotal = left.inputTokens + left.outputTokens + const rightTotal = right.inputTokens + right.outputTokens + return rightTotal - leftTotal + }) + const primaryLocation = session.locationBreakdown[0] ?? null + if (primaryLocation) { + session.primaryRepoId = primaryLocation.repoId + session.primaryWorktreeId = primaryLocation.worktreeId + } + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + +export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { + sessions: ClaudeUsageSession[] + dailyAggregates: ClaudeUsageDailyAggregate[] +} { + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const turn of turns) { + const existingSession = sessionsById.get(turn.sessionId) + if (!existingSession) { + sessionsById.set(turn.sessionId, { + sessionId: turn.sessionId, + firstTimestamp: turn.timestamp, + lastTimestamp: turn.timestamp, + model: turn.model, + lastCwd: turn.cwd, + lastGitBranch: turn.gitBranch, + primaryWorktreeId: turn.worktreeId, + primaryRepoId: turn.repoId, + turnCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + locationBreakdown: [] + }) + } + + const session = sessionsById.get(turn.sessionId)! + if (turn.timestamp < session.firstTimestamp) { + session.firstTimestamp = turn.timestamp + } + if (turn.timestamp > session.lastTimestamp) { + session.lastTimestamp = turn.timestamp + session.lastCwd = turn.cwd + session.lastGitBranch = turn.gitBranch + } + session.model = turn.model ?? session.model + session.turnCount++ + session.totalInputTokens += turn.inputTokens + session.totalOutputTokens += turn.outputTokens + session.totalCacheReadTokens += turn.cacheReadTokens + session.totalCacheWriteTokens += turn.cacheWriteTokens + + const location = + session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null + if (location) { + location.turnCount++ + location.inputTokens += turn.inputTokens + location.outputTokens += turn.outputTokens + location.cacheReadTokens += turn.cacheReadTokens + location.cacheWriteTokens += turn.cacheWriteTokens + } else { + session.locationBreakdown.push({ + locationKey: turn.projectKey, + projectLabel: turn.projectLabel, + repoId: turn.repoId, + worktreeId: turn.worktreeId, + turnCount: 1, + inputTokens: turn.inputTokens, + outputTokens: turn.outputTokens, + cacheReadTokens: turn.cacheReadTokens, + cacheWriteTokens: turn.cacheWriteTokens + }) + } + + const dailyKey = [turn.day, turn.model ?? 'unknown', turn.projectKey].join('::') + const existingDaily = dailyByKey.get(dailyKey) + if (existingDaily) { + existingDaily.turnCount++ + if (turn.cacheReadTokens === 0) { + existingDaily.zeroCacheReadTurnCount++ + } + existingDaily.inputTokens += turn.inputTokens + existingDaily.outputTokens += turn.outputTokens + existingDaily.cacheReadTokens += turn.cacheReadTokens + existingDaily.cacheWriteTokens += turn.cacheWriteTokens + } else { + dailyByKey.set(dailyKey, { + day: turn.day, + model: turn.model, + projectKey: turn.projectKey, + projectLabel: turn.projectLabel, + repoId: turn.repoId, + worktreeId: turn.worktreeId, + turnCount: 1, + zeroCacheReadTurnCount: turn.cacheReadTokens === 0 ? 1 : 0, + inputTokens: turn.inputTokens, + outputTokens: turn.outputTokens, + cacheReadTokens: turn.cacheReadTokens, + cacheWriteTokens: turn.cacheWriteTokens + }) + } + } + + return { + sessions: finalizeClaudeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string { + if (locationBreakdown.length === 0) { + return 'Unknown location' + } + if (locationBreakdown.length === 1) { + return locationBreakdown[0].projectLabel + } + return 'Multiple locations' +} diff --git a/src/main/claude-usage/worktree-attribution.ts b/src/main/claude-usage/worktree-attribution.ts new file mode 100644 index 00000000000..dedb97025b6 --- /dev/null +++ b/src/main/claude-usage/worktree-attribution.ts @@ -0,0 +1,151 @@ +import { realpath } from 'node:fs/promises' +import type { ClaudeUsageAttributedTurn, ClaudeUsageParsedTurn } from './types' + +export type ClaudeUsageWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef] + +const sortedWorktreeEntriesByLookup = new WeakMap< + Map, + ClaudeUsageWorktreeEntry[] +>() + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +async function canonicalizePath(pathValue: string): Promise { + try { + const resolved = await realpath(pathValue) + return normalizeComparablePath(resolved) + } catch { + return normalizeComparablePath(pathValue) + } +} + +function normalizeComparablePath(pathValue: string): string { + const normalized = pathValue.replace(/\\/g, '/') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function isContainedPath(parentPath: string, childPath: string): boolean { + const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '') + const child = normalizeComparablePath(childPath).replace(/\/+$/, '') + return child === parent || child.startsWith(`${parent}/`) +} + +function findContainingWorktree( + cwd: string, + worktreeLookup: Map +): ClaudeUsageWorktreeRef | null { + const normalizedCwd = normalizeComparablePath(cwd) + const exact = worktreeLookup.get(normalizedCwd) + if (exact) { + return exact + } + + for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) { + if (isContainedPath(worktreePath, normalizedCwd)) { + return worktree + } + } + + return null +} + +function getSortedWorktreeEntries( + worktreeLookup: Map +): ClaudeUsageWorktreeEntry[] { + const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup) + if (cached) { + return cached + } + const sorted = [...worktreeLookup.entries()].sort( + ([leftPath], [rightPath]) => rightPath.length - leftPath.length + ) + sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted) + return sorted +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export async function buildWorktreeLookup( + worktrees: ClaudeUsageWorktreeRef[] +): Promise> { + const lookup = new Map() + for (const worktree of worktrees) { + lookup.set(await canonicalizePath(worktree.path), worktree) + } + return lookup +} + +export async function attributeClaudeUsageTurns( + turns: ClaudeUsageParsedTurn[], + worktreeLookup: Map +): Promise { + const attributed: ClaudeUsageAttributedTurn[] = [] + const canonicalCwdByPath = new Map() + + for (const turn of turns) { + const day = localDayFromTimestamp(turn.timestamp) + if (!day) { + continue + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(turn.cwd) + + if (turn.cwd) { + let canonicalCwd = canonicalCwdByPath.get(turn.cwd) + if (canonicalCwd === undefined) { + // Why: Claude transcripts repeat the same cwd for many consecutive + // turns. Cache realpath work so attribution scales with unique paths. + canonicalCwd = await canonicalizePath(turn.cwd) + canonicalCwdByPath.set(turn.cwd, canonicalCwd) + } + const worktree = findContainingWorktree(canonicalCwd, worktreeLookup) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(turn.cwd)}` + } + } + + attributed.push({ + ...turn, + day, + projectKey, + projectLabel, + repoId, + worktreeId + }) + } + + return attributed +} diff --git a/src/main/codex-usage/codex-automation-run-attribution.ts b/src/main/codex-usage/codex-automation-run-attribution.ts new file mode 100644 index 00000000000..10b366d6756 --- /dev/null +++ b/src/main/codex-usage/codex-automation-run-attribution.ts @@ -0,0 +1,181 @@ +import type { AutomationRunUsage } from '../../shared/automations-types' +import type { CodexUsagePersistedState } from './types' +import { estimateCostUsd } from './codex-usage-cost-estimate' + +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 + +export type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + +type CodexAutomationAttributionDeps = { + /** Callback, not a snapshot: refresh mutates persisted state in place. */ + getState: () => CodexUsagePersistedState + refresh: (force: boolean) => Promise<{ lastScanError: string | null }> +} + +function shouldForceAutomationUsageScan( + scanState: CodexUsagePersistedState['scanState'], + completedAt: number +): boolean { + const { lastScanCompletedAt, lastScanError } = scanState + // Why: attribution needs a scan after the run finishes, but repeated + // lookups after that point should not rescan all Codex session history. + return Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt +} + +export async function resolveCodexAutomationRunUsage( + input: AutomationUsageLookupInput, + deps: CodexAutomationAttributionDeps +): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'codex', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!deps.getState().scanState.enabled) { + return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await deps.refresh( + shouldForceAutomationUsageScan(deps.getState().scanState, input.completedAt) + ) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = deps.getState().sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Codex usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Codex usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.events += entry.eventCount + acc.inputTokens += entry.inputTokens + acc.cachedInputTokens += entry.cachedInputTokens + acc.outputTokens += entry.outputTokens + acc.reasoningOutputTokens += entry.reasoningOutputTokens + acc.totalTokens += entry.totalTokens + return acc + }, + { + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + ) + const scopedModelRows = session.locationModelBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown + const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))] + let estimatedCostUsd = 0 + let hasKnownCost = false + if (scopedModelRows.length > 0) { + for (const modelRow of scopedModelRows) { + const cost = estimateCostUsd( + modelRow.modelKey, + modelRow.inputTokens, + modelRow.cachedInputTokens, + modelRow.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + } else if (!session.hasMixedModels) { + const cost = estimateCostUsd( + session.primaryModel, + totals.inputTokens, + totals.cachedInputTokens, + totals.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + + return { + status: 'known', + provider: 'codex', + model: + modelLabels.length === 1 + ? modelLabels[0] + : session.hasMixedModels + ? 'Mixed models' + : session.primaryModel, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cachedInputTokens, + cacheWriteTokens: null, + reasoningOutputTokens: totals.reasoningOutputTokens, + totalTokens: totals.totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, + estimatedCostSource: hasKnownCost ? 'api_equivalent' : null, + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Codex usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } +} diff --git a/src/main/codex-usage/codex-model-pricing.ts b/src/main/codex-usage/codex-model-pricing.ts new file mode 100644 index 00000000000..23e721e27db --- /dev/null +++ b/src/main/codex-usage/codex-model-pricing.ts @@ -0,0 +1,181 @@ +export type TieredPrice = { threshold: number; price: number } +export type CodexModelPricing = { + input: number + cachedInput: number + output: number + inputTiers?: TieredPrice[] + cachedInputTiers?: TieredPrice[] + outputTiers?: TieredPrice[] +} + +const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 + +export const MODEL_PRICING: Record = { + 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 }, + 'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 }, + 'gpt-5.4-pro': { + input: 30, + cachedInput: 30, + output: 180, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] + }, + 'gpt-5.4': { + input: 2.5, + cachedInput: 0.25, + output: 15, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] + }, + 'gpt-5.5-pro': { + input: 30, + cachedInput: 30, + output: 180, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] + }, + 'gpt-5.5': { + input: 5, + cachedInput: 0.5, + output: 30, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] + }, + 'gpt-5.6-sol': { + input: 5, + cachedInput: 0.5, + output: 30, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] + }, + 'gpt-5.6-terra': { + input: 2.5, + cachedInput: 0.25, + output: 15, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] + }, + 'gpt-5.6-luna': { + input: 1, + cachedInput: 0.1, + output: 6, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }] + } +} + +const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none'] + +function stripParenthesizedReasoningTier(model: string): string | null { + const match = model.match(/^(.*)\(([^()]*)\)$/) + if (!match) { + return model + } + const tier = match[2].trim().toLowerCase() + if (!REASONING_TIER_SUFFIXES.includes(tier)) { + return null + } + return match[1] +} + +function stripDashReasoningTiers(model: string): string { + let current = model + for (let index = 0; index < 4; index++) { + const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`)) + if (!suffix) { + return current + } + current = current.slice(0, -suffix.length - 1) + } + return current +} + +export function normalizeModelForPricing(model: string | null): string | null { + if (!model) { + return null + } + + const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim()) + if (!lower) { + return null + } + + const normalized = stripDashReasoningTiers(lower) + if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') { + return 'gpt-5' + } + if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) { + return 'gpt-5.1-codex-max' + } + if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) { + return 'gpt-5.1-codex' + } + if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) { + return 'gpt-5.1' + } + if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) { + return 'gpt-5.2-codex' + } + if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) { + return 'gpt-5.2' + } + if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) { + return 'gpt-5.3-codex-spark' + } + if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) { + return 'gpt-5.3-codex' + } + if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) { + return 'gpt-5.3' + } + if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) { + return 'gpt-5.4-mini' + } + if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) { + return 'gpt-5.4-nano' + } + if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) { + return 'gpt-5.4-pro' + } + if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) { + return 'gpt-5.4' + } + if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) { + return 'gpt-5.5-pro' + } + if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) { + return 'gpt-5.5' + } + if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) { + return 'gpt-5.6-sol' + } + if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) { + return 'gpt-5.6-terra' + } + if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) { + return 'gpt-5.6-luna' + } + // Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a + // `gpt-5.6-` prefix match would swallow the tier IDs above and any future + // cheaper variant. + if (normalized === 'gpt-5.6') { + return 'gpt-5.6-sol' + } + return null +} diff --git a/src/main/codex-usage/codex-session-file-discovery.ts b/src/main/codex-usage/codex-session-file-discovery.ts new file mode 100644 index 00000000000..946f9f1838c --- /dev/null +++ b/src/main/codex-usage/codex-session-file-discovery.ts @@ -0,0 +1,169 @@ +import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { realpath, readdir, stat } from 'node:fs/promises' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' +import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' +import { normalizeFsPath } from '../usage/usage-path-comparison' + +const YIELD_EVERY_DISCOVERY_ENTRIES = 100 + +export async function canonicalizePath(pathValue: string): Promise { + try { + const resolved = await realpath(pathValue) + return normalizeFsPath(resolved) + } catch { + return normalizeFsPath(pathValue) + } +} + +export async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +async function walkJsonlFiles( + dirPath: string, + progress: { entriesVisited: number } = { entriesVisited: 0 } +): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + progress.entriesVisited += 1 + if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) + } + } + + return files +} + +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: large session directories can exceed V8's argument limit if child + // file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +export function getCodexSessionsDirectory(): string { + // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so + // callers that need the primary runtime path should not consult ambient + // shell CODEX_HOME. + return join(getOrcaManagedCodexHomePath(), 'sessions') +} + +export function getCodexSessionDirectories(): string[] { + // Why: sessions now live in three lanes — the shared runtime mirror, the real + // ~/.codex, and per-account self-contained homes; missing any lane silently + // undercounts usage for multi-account users. + return [ + getCodexSessionsDirectory(), + join(getSystemCodexHomePath(), 'sessions'), + ...getCodexAccountHomeSessionDirectories() + ].filter((dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index) +} + +function hasLegacyCopiedSessionBridgeMarkers(): boolean { + return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies')) +} + +export async function listCodexSessionFiles(): Promise { + const files: string[] = [] + for (const dirPath of getCodexSessionDirectories()) { + try { + appendDiscoveredFiles(files, await walkJsonlFiles(dirPath)) + } catch { + // Missing or unreadable history in one home should not hide the other. + } + } + return dedupeCodexSessionFileAliases(files, hasLegacyCopiedSessionBridgeMarkers()) +} + +async function dedupeCodexSessionFileAliases( + files: string[], + hasLegacyBridgeMarkers: boolean +): Promise { + const excludedAliases = new Set() + if (hasLegacyBridgeMarkers) { + for (const [index, filePath] of files.entries()) { + const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } + if (!legacyCopyBridge) { + continue + } + if (legacyCopyBridge.sourceSkipBytes !== null) { + continue + } + excludedAliases.add( + await getPhysicalFileAliasKey( + legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath + ) + ) + } + } + + const seenAliases = new Set() + const uniqueFiles: string[] = [] + for (const [index, filePath] of [...new Set(files)].sort().entries()) { + const aliasKey = await getCodexSessionFileAliasKey(filePath) + if (excludedAliases.has(aliasKey)) { + continue + } + if (seenAliases.has(aliasKey)) { + continue + } + seenAliases.add(aliasKey) + uniqueFiles.push(filePath) + if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } + } + return uniqueFiles +} + +async function getCodexSessionFileAliasKey(filePath: string): Promise { + return getPhysicalFileAliasKey(filePath) +} + +async function getPhysicalFileAliasKey(filePath: string): Promise { + try { + const fileStat = await stat(filePath) + if (fileStat.ino !== 0) { + return `${fileStat.dev}:${fileStat.ino}` + } + } catch {} + return `path:${await canonicalizePath(filePath)}` +} + +export function getLegacySourceSkipBytesByPath( + files: string[], + hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers() +): Map { + const sourceSkipBytesByPath = new Map() + if (!hasLegacyBridgeMarkers) { + return sourceSkipBytesByPath + } + for (const filePath of files) { + const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) { + continue + } + const existing = sourceSkipBytesByPath.get(legacyCopyBridge.sourcePath) ?? 0 + sourceSkipBytesByPath.set( + legacyCopyBridge.sourcePath, + Math.max(existing, legacyCopyBridge.sourceSkipBytes) + ) + } + return sourceSkipBytesByPath +} diff --git a/src/main/codex-usage/codex-usage-cost-estimate.ts b/src/main/codex-usage/codex-usage-cost-estimate.ts new file mode 100644 index 00000000000..c3372f3873c --- /dev/null +++ b/src/main/codex-usage/codex-usage-cost-estimate.ts @@ -0,0 +1,41 @@ +import type { TieredPrice } from './codex-model-pricing' +import { MODEL_PRICING, normalizeModelForPricing } from './codex-model-pricing' + +function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number { + let cost = 0 + let lowerBound = 0 + let activePrice = basePrice + for (const tier of tiers) { + if (tokens <= tier.threshold) { + return cost + Math.max(tokens - lowerBound, 0) * activePrice + } + cost += (tier.threshold - lowerBound) * activePrice + lowerBound = tier.threshold + activePrice = tier.price + } + return cost + Math.max(tokens - lowerBound, 0) * activePrice +} + +export function estimateCostUsd( + model: string | null, + inputTokens: number, + cachedInputTokens: number, + outputTokens: number +): number | null { + const normalized = normalizeModelForPricing(model) + if (!normalized) { + return null + } + const pricing = MODEL_PRICING[normalized] + const clampedCached = Math.min(cachedInputTokens, inputTokens) + // Why: Codex cached tokens are part of the input bucket. Charge uncached + // input on (input-cached) so cached tokens are not billed once at full input + // price and again at cache-read price. + const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0) + return ( + (calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) + + calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) + + calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) / + 1_000_000 + ) +} diff --git a/src/main/codex-usage/codex-usage-event-attribution.ts b/src/main/codex-usage/codex-usage-event-attribution.ts new file mode 100644 index 00000000000..798183360be --- /dev/null +++ b/src/main/codex-usage/codex-usage-event-attribution.ts @@ -0,0 +1,113 @@ +import { win32, posix } from 'node:path' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { + looksLikeWindowsPath, + normalizeComparablePath, + normalizeFsPath +} from '../usage/usage-path-comparison' +import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' +import type { CodexUsageAttributedEvent, CodexUsageParsedEvent } from './types' + +export type CodexUsageWorktreeRef = UsageScanWorktreeRef + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + // Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an + // absolute `D:\\other` path instead of a `..`-prefixed relative. Treating + // that as "contained" would attribute off-drive Codex usage to the wrong + // Orca worktree. + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) +} + +function findContainingWorktree( + cwd: string, + worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[] +): CodexUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeCodexUsageEvent( + event: CodexUsageParsedEvent, + worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + // Why: all-local mode should still collapse repeated off-Orca sessions by + // location, but those keys must normalize slash/case differences so the + // same folder does not fragment into multiple "projects" across platforms. + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} diff --git a/src/main/codex-usage/codex-usage-record-parser.ts b/src/main/codex-usage/codex-usage-record-parser.ts new file mode 100644 index 00000000000..e522101ae0a --- /dev/null +++ b/src/main/codex-usage/codex-usage-record-parser.ts @@ -0,0 +1,161 @@ +import { extractString } from '../usage/usage-record-coercion' +import { + buildCodexUsageEventKey, + normalizeRawUsage, + resolveCodexUsageDelta, + type CodexUsageRawUsage +} from './codex-usage-token-delta' +import type { CodexUsageParsedEvent } from './types' + +type CodexUsageRawRecord = { + timestamp?: string + type?: string + payload?: Record +} + +export type CodexUsageParseContext = { + sessionId: string + sessionCwd: string | null + currentCwd: string | null + currentModel: string | null + previousTotals: CodexUsageRawUsage | null + totalOnlyBaselinePending?: boolean +} + +function extractModel(value: unknown): string | null { + if (value == null || typeof value !== 'object') { + return null + } + + const record = value as Record + const direct = [extractString(record.model), extractString(record.model_name)].find( + (candidate) => candidate !== null + ) + if (direct) { + return direct + } + + if (record.info && typeof record.info === 'object') { + const info = record.info as Record + const infoDirect = [extractString(info.model), extractString(info.model_name)].find( + (candidate) => candidate !== null + ) + if (infoDirect) { + return infoDirect + } + if (info.metadata && typeof info.metadata === 'object') { + const metadata = info.metadata as Record + const metadataModel = extractString(metadata.model) + if (metadataModel) { + return metadataModel + } + } + } + + if (record.metadata && typeof record.metadata === 'object') { + const metadata = record.metadata as Record + return extractString(metadata.model) + } + + return null +} + +export function parseCodexUsageRecord( + line: string, + context: CodexUsageParseContext +): CodexUsageParsedEvent | null { + let parsed: CodexUsageRawRecord + try { + parsed = JSON.parse(line) as CodexUsageRawRecord + } catch { + return null + } + + if (!parsed.type || !parsed.payload) { + return null + } + + if (parsed.type === 'session_meta') { + context.sessionId = extractString(parsed.payload.id) ?? context.sessionId + context.sessionCwd = extractString(parsed.payload.cwd) + if (!context.currentCwd && context.sessionCwd) { + context.currentCwd = context.sessionCwd + } + return null + } + + if (parsed.type === 'turn_context') { + context.currentCwd = + extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd + context.currentModel = extractModel(parsed.payload) ?? context.currentModel + return null + } + + if (parsed.type !== 'event_msg' || parsed.payload.type !== 'token_count' || !parsed.timestamp) { + return null + } + + const info = parsed.payload.info + if (info == null || typeof info !== 'object') { + // Why: Codex emits token_count snapshots with null info for rate-limit + // updates. Treating them as malformed usage would make active sessions look + // flaky and create false scan errors for perfectly valid logs. + return null + } + + const record = info as Record + const totalUsage = normalizeRawUsage(record.total_token_usage) + const lastUsage = normalizeRawUsage(record.last_token_usage) + if (context.totalOnlyBaselinePending) { + context.totalOnlyBaselinePending = false + if (totalUsage && !lastUsage && !context.previousTotals) { + context.previousTotals = totalUsage + return null + } + } + const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals) + if (!resolvedUsage) { + return null + } + if (resolvedUsage.kind === 'baseline') { + context.previousTotals = resolvedUsage.nextTotals + return null + } + + let delta = { + ...resolvedUsage.delta, + cachedInputTokens: Math.min( + resolvedUsage.delta.cachedInputTokens, + resolvedUsage.delta.inputTokens + ) + } + + if ( + delta.inputTokens === 0 && + delta.cachedInputTokens === 0 && + delta.outputTokens === 0 && + delta.reasoningOutputTokens === 0 && + delta.totalTokens === 0 + ) { + return null + } + + context.previousTotals = resolvedUsage.nextTotals + + const resolvedModel = extractModel(parsed.payload) ?? context.currentModel + const hasInferredPricing = resolvedModel === null + + return { + sessionId: context.sessionId, + timestamp: parsed.timestamp, + eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage), + cwd: context.currentCwd ?? context.sessionCwd, + model: resolvedModel, + hasInferredPricing, + inputTokens: delta.inputTokens, + cachedInputTokens: delta.cachedInputTokens, + outputTokens: delta.outputTokens, + reasoningOutputTokens: delta.reasoningOutputTokens, + totalTokens: delta.totalTokens + } +} diff --git a/src/main/codex-usage/codex-usage-rollup-projections.ts b/src/main/codex-usage/codex-usage-rollup-projections.ts new file mode 100644 index 00000000000..0e03c2e80ee --- /dev/null +++ b/src/main/codex-usage/codex-usage-rollup-projections.ts @@ -0,0 +1,183 @@ +import type { + CodexUsageBreakdownKind, + CodexUsageBreakdownRow, + CodexUsageDailyPoint, + CodexUsageRange, + CodexUsageScope, + CodexUsageSummary +} from '../../shared/codex-usage-types' +import type { CodexUsagePersistedState } from './types' +import { estimateCostUsd } from './codex-usage-cost-estimate' +import { + getFilteredDaily, + getFilteredSessions, + getScopedSessionModels +} from './codex-usage-scope-filters' + +export function buildSummary( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange +): CodexUsageSummary { + const filteredDaily = getFilteredDaily(state, scope, range) + const filteredSessions = getFilteredSessions(state, scope, range) + + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd = 0 + let hasAnyBillableCost = false + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + const cost = estimateCostUsd( + row.model, + row.inputTokens, + row.cachedInputTokens, + row.outputTokens + ) + if (cost !== null) { + hasAnyBillableCost = true + estimatedCostUsd += cost + } + } + + const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null, + topModel, + topProject, + hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0 + } +} + +export function buildDaily( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange +): CodexUsageDailyPoint[] { + const byDay = new Map() + for (const row of getFilteredDaily(state, scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) +} + +export function buildBreakdown( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange, + kind: CodexUsageBreakdownKind +): CodexUsageBreakdownRow[] { + const rows = new Map() + const filteredDaily = getFilteredDaily(state, scope, range) + const filteredSessions = getFilteredSessions(state, scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + hasInferredPricing: false + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.hasInferredPricing ||= daily.hasInferredPricing + rows.set(key, existing) + } + + for (const session of filteredSessions) { + if (kind === 'model') { + const seen = new Set() + for (const model of getScopedSessionModels(session, scope)) { + if (seen.has(model.modelKey)) { + continue + } + seen.add(model.modelKey) + const row = rows.get(model.modelKey) + if (row) { + row.sessions++ + } + } + continue + } + const matchingLocations = session.locationBreakdown.filter((entry) => + scope === 'all' ? true : entry.worktreeId !== null + ) + const seen = new Set() + for (const location of matchingLocations) { + if (seen.has(location.locationKey)) { + continue + } + seen.add(location.locationKey) + const row = rows.get(location.locationKey) + if (row) { + row.sessions++ + } + } + } + + for (const row of rows.values()) { + row.estimatedCostUsd = estimateCostUsd( + kind === 'model' ? row.key : null, + row.inputTokens, + row.cachedInputTokens, + row.outputTokens + ) + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) +} diff --git a/src/main/codex-usage/codex-usage-scope-filters.ts b/src/main/codex-usage/codex-usage-scope-filters.ts new file mode 100644 index 00000000000..3efd8dbcd57 --- /dev/null +++ b/src/main/codex-usage/codex-usage-scope-filters.ts @@ -0,0 +1,103 @@ +import type { CodexUsageRange, CodexUsageScope } from '../../shared/codex-usage-types' +import type { CodexUsagePersistedState } from './types' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' + +export type ScopedCodexUsageModelRow = { + modelKey: string + modelLabel: string + hasInferredPricing: boolean + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export function getFilteredDaily( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange +) { + const cutoff = getUsageRangeCutoff(range) + return state.dailyAggregates.filter((entry) => { + if (cutoff && entry.day < cutoff) { + return false + } + if (scope === 'orca' && entry.worktreeId === null) { + return false + } + return true + }) +} + +export function getFilteredSessions( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange +) { + const cutoff = getUsageRangeCutoff(range) + return state.sessions.filter((session) => { + const day = getLocalUsageDay(session.lastTimestamp) + if (!day) { + return false + } + if (cutoff && day < cutoff) { + return false + } + if (scope === 'orca') { + return session.locationBreakdown.some((entry) => entry.worktreeId !== null) + } + return true + }) +} + +export function getScopedSessionModels( + session: CodexUsagePersistedState['sessions'][number], + scope: CodexUsageScope +): ScopedCodexUsageModelRow[] { + if (scope === 'all' || session.locationModelBreakdown.length === 0) { + return session.modelBreakdown + } + + const rows = new Map() + for (const entry of session.locationModelBreakdown) { + if (entry.worktreeId === null) { + continue + } + const existing = rows.get(entry.modelKey) ?? { + modelKey: entry.modelKey, + modelLabel: entry.modelLabel, + hasInferredPricing: false, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.hasInferredPricing ||= entry.hasInferredPricing + existing.eventCount += entry.eventCount + existing.inputTokens += entry.inputTokens + existing.cachedInputTokens += entry.cachedInputTokens + existing.outputTokens += entry.outputTokens + existing.reasoningOutputTokens += entry.reasoningOutputTokens + existing.totalTokens += entry.totalTokens + rows.set(entry.modelKey, existing) + } + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) +} + +export function getScopedSessionPrimaryModel( + session: CodexUsagePersistedState['sessions'][number], + scope: CodexUsageScope +): string | null { + const scopedModels = getScopedSessionModels(session, scope) + if (scopedModels.length === 0) { + return session.primaryModel + } + if (scopedModels.length === 1) { + return scopedModels[0]?.modelLabel ?? null + } + return 'Mixed models' +} diff --git a/src/main/codex-usage/codex-usage-session-rows.ts b/src/main/codex-usage/codex-usage-session-rows.ts new file mode 100644 index 00000000000..73f9c07fa7e --- /dev/null +++ b/src/main/codex-usage/codex-usage-session-rows.ts @@ -0,0 +1,69 @@ +import type { + CodexUsageRange, + CodexUsageScope, + CodexUsageSessionRow +} from '../../shared/codex-usage-types' +import type { CodexUsagePersistedState } from './types' +import { getFilteredSessions, getScopedSessionPrimaryModel } from './codex-usage-scope-filters' + +export function buildRecentSessions( + state: CodexUsagePersistedState, + scope: CodexUsageScope, + range: CodexUsageRange, + limit = 12 +): CodexUsageSessionRow[] { + return getFilteredSessions(state, scope, range) + .slice(0, limit) + .map((session) => { + const matchingLocations = session.locationBreakdown.filter((entry) => + scope === 'all' ? true : entry.worktreeId !== null + ) + const scopedLocations = + matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown + const totals = scopedLocations.reduce( + (acc, entry) => { + acc.events += entry.eventCount + acc.inputTokens += entry.inputTokens + acc.cachedInputTokens += entry.cachedInputTokens + acc.outputTokens += entry.outputTokens + acc.reasoningOutputTokens += entry.reasoningOutputTokens + acc.totalTokens += entry.totalTokens + acc.hasInferredPricing ||= entry.hasInferredPricing + return acc + }, + { + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + hasInferredPricing: false + } + ) + const durationMinutes = Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ) + return { + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes, + projectLabel: + scopedLocations.length > 1 + ? 'Multiple locations' + : (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel), + model: getScopedSessionPrimaryModel(session, scope), + events: totals.events, + inputTokens: totals.inputTokens, + cachedInputTokens: totals.cachedInputTokens, + outputTokens: totals.outputTokens, + reasoningOutputTokens: totals.reasoningOutputTokens, + totalTokens: totals.totalTokens, + hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing + } + }) +} diff --git a/src/main/codex-usage/codex-usage-token-delta.ts b/src/main/codex-usage/codex-usage-token-delta.ts new file mode 100644 index 00000000000..95a4241cdf6 --- /dev/null +++ b/src/main/codex-usage/codex-usage-token-delta.ts @@ -0,0 +1,178 @@ +import { ensureNumber } from '../usage/usage-record-coercion' + +export type CodexUsageRawUsage = { + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +type CodexUsageDeltaResolution = + | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } + | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } + +export function normalizeRawUsage(value: unknown): CodexUsageRawUsage | null { + if (value == null || typeof value !== 'object') { + return null + } + + const record = value as Record + const inputTokens = ensureNumber(record.input_tokens) + const cachedInputTokens = ensureNumber( + record.cached_input_tokens ?? record.cache_read_input_tokens + ) + const outputTokens = ensureNumber(record.output_tokens) + const reasoningOutputTokens = ensureNumber(record.reasoning_output_tokens) + const totalTokens = ensureNumber(record.total_tokens) + + return { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + // Why: legacy Codex logs can omit total_tokens. Reasoning is already billed + // inside output, so synthesizing input+output matches Codex pricing instead + // of double-counting reasoning as another billable bucket. + totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens + } +} + +function subtractRawUsage( + current: CodexUsageRawUsage, + previous: CodexUsageRawUsage | null +): CodexUsageRawUsage { + return { + inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0), + cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0), + outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0), + reasoningOutputTokens: Math.max( + current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0), + 0 + ), + totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0) + } +} + +function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage { + return { + inputTokens: left.inputTokens + right.inputTokens, + cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, + outputTokens: left.outputTokens + right.outputTokens, + reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens, + totalTokens: left.totalTokens + right.totalTokens + } +} + +function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean { + return ( + left.inputTokens === right.inputTokens && + left.cachedInputTokens === right.cachedInputTokens && + left.outputTokens === right.outputTokens && + left.reasoningOutputTokens === right.reasoningOutputTokens + ) +} + +function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean { + return ( + current.inputTokens >= previous.inputTokens && + current.cachedInputTokens >= previous.cachedInputTokens && + current.outputTokens >= previous.outputTokens && + current.reasoningOutputTokens >= previous.reasoningOutputTokens + ) +} + +function rawUsageMagnitude(usage: CodexUsageRawUsage): number { + return ( + usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens + ) +} + +function looksLikeStaleRegression( + current: CodexUsageRawUsage, + previous: CodexUsageRawUsage, + last: CodexUsageRawUsage +): boolean { + const previousTotal = rawUsageMagnitude(previous) + const currentTotal = rawUsageMagnitude(current) + const lastTotal = rawUsageMagnitude(last) + if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) { + return false + } + return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal +} + +export function resolveCodexUsageDelta( + totalUsage: CodexUsageRawUsage | null, + lastUsage: CodexUsageRawUsage | null, + previousTotals: CodexUsageRawUsage | null +): CodexUsageDeltaResolution | null { + if (totalUsage && lastUsage && previousTotals) { + if (rawUsageEquals(totalUsage, previousTotals)) { + return null + } + if ( + !rawUsageIsMonotonic(totalUsage, previousTotals) && + looksLikeStaleRegression(totalUsage, previousTotals, lastUsage) + ) { + return null + } + // Why: Codex totals are mutable snapshots after compaction/resume. The + // last_token_usage payload is the billable increment; totals are the baseline. + return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } + } + + if (totalUsage && lastUsage) { + return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } + } + + if (totalUsage && previousTotals) { + if (rawUsageEquals(totalUsage, previousTotals)) { + return null + } + if (!rawUsageIsMonotonic(totalUsage, previousTotals)) { + return { kind: 'baseline', nextTotals: totalUsage } + } + return { + kind: 'event', + delta: subtractRawUsage(totalUsage, previousTotals), + nextTotals: totalUsage + } + } + + if (totalUsage) { + return { kind: 'event', delta: totalUsage, nextTotals: totalUsage } + } + + if (lastUsage && previousTotals) { + return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) } + } + + if (lastUsage) { + return { kind: 'event', delta: lastUsage, nextTotals: null } + } + + return null +} + +export function buildCodexUsageEventKey( + timestamp: string, + totalUsage: CodexUsageRawUsage | null, + lastUsage: CodexUsageRawUsage | null +): string { + // Why: fork/resume copies token_count records byte-for-byte into a new + // rollout file, but session_meta.id is often rewritten to the new session. + // Key only on the raw record fields (timestamp + usage tuples) so the copy + // matches the original regardless of surrounding parse context / session id. + const tupleOf = (usage: CodexUsageRawUsage | null): string => + usage + ? [ + usage.inputTokens, + usage.cachedInputTokens, + usage.outputTokens, + usage.reasoningOutputTokens, + usage.totalTokens + ].join(',') + : '' + return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|') +} diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 1ef57ff9d03..9d7c584f79f 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -78,7 +78,7 @@ describe('listCodexSessionFiles large directories', () => { } as Stats }) - const { listCodexSessionFiles } = await import('./scanner') + const { listCodexSessionFiles } = await import('./codex-session-file-discovery') await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled() diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index 3b12987464b..2a7cc7ccd41 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -35,9 +35,9 @@ vi.mock('node:os', async () => { import { getCodexSessionDirectories, getCodexSessionsDirectory, - listCodexSessionFiles, - scanCodexUsageFiles -} from './scanner' + listCodexSessionFiles +} from './codex-session-file-discovery' +import { scanCodexUsageFiles } from './scanner' const originalCodexHome = process.env.CODEX_HOME let fakeHomeDir: string diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index 6426222461f..746be0ffa15 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -10,7 +10,8 @@ vi.mock('electron', () => ({ } })) -import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' +import { attributeCodexUsageEvent } from './codex-usage-event-attribution' +import { parseCodexUsageRecord } from './codex-usage-record-parser' describe('parseCodexUsageRecord', () => { it('uses token totals only as a duplicate baseline', () => { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 20d22c00d71..d06a85f8901 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,221 +1,29 @@ -/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ -import { basename, join, win32, posix } from 'node:path' -import { createReadStream, existsSync } from 'node:fs' -import { realpath, readdir, stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' import { createInterface } from 'node:readline' -import { areWorktreePathsEqual } from '../ipc/worktree-logic' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' -import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' -import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import { createUsageEventAggregation } from '../usage/usage-event-aggregation' import { - looksLikeWindowsPath, - normalizeComparablePath, - normalizeFsPath -} from '../usage/usage-path-comparison' -import { ensureNumber, extractString } from '../usage/usage-record-coercion' -import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' + canonicalizePath, + getLegacySourceSkipBytesByPath, + listCodexSessionFiles, + yieldToEventLoop +} from './codex-session-file-discovery' +import { + attributeCodexUsageEvent, + type CodexUsageWorktreeRef +} from './codex-usage-event-attribution' +import { parseCodexUsageRecord, type CodexUsageParseContext } from './codex-usage-record-parser' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, - CodexUsageParsedEvent, CodexUsagePersistedFile, CodexUsageProcessedFile, CodexUsageSession } from './types' -export type CodexUsageWorktreeRef = UsageScanWorktreeRef - -type CodexUsageRawRecord = { - timestamp?: string - type?: string - payload?: Record -} - -type CodexUsageRawUsage = { - inputTokens: number - cachedInputTokens: number - outputTokens: number - reasoningOutputTokens: number - totalTokens: number -} - -type CodexUsageParseContext = { - sessionId: string - sessionCwd: string | null - currentCwd: string | null - currentModel: string | null - previousTotals: CodexUsageRawUsage | null - totalOnlyBaselinePending?: boolean -} - -type CodexUsageDeltaResolution = - | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } - | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } - const YIELD_EVERY_FILES = 10 -const YIELD_EVERY_DISCOVERY_ENTRIES = 100 - -async function canonicalizePath(pathValue: string): Promise { - try { - const resolved = await realpath(pathValue) - return normalizeFsPath(resolved) - } catch { - return normalizeFsPath(pathValue) - } -} - -async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -async function walkJsonlFiles( - dirPath: string, - progress: { entriesVisited: number } = { entriesVisited: 0 } -): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - progress.entriesVisited += 1 - if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { - await yieldToEventLoop() - } - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath) - } - } - - return files -} - -function appendDiscoveredFiles(target: string[], source: readonly string[]): void { - // Why: large session directories can exceed V8's argument limit if child - // file arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) - } -} - -export function getCodexSessionsDirectory(): string { - // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so - // callers that need the primary runtime path should not consult ambient - // shell CODEX_HOME. - return join(getOrcaManagedCodexHomePath(), 'sessions') -} - -export function getCodexSessionDirectories(): string[] { - // Why: sessions now live in three lanes — the shared runtime mirror, the real - // ~/.codex, and per-account self-contained homes; missing any lane silently - // undercounts usage for multi-account users. - return [ - getCodexSessionsDirectory(), - join(getSystemCodexHomePath(), 'sessions'), - ...getCodexAccountHomeSessionDirectories() - ].filter((dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index) -} - -function hasLegacyCopiedSessionBridgeMarkers(): boolean { - return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies')) -} - -export async function listCodexSessionFiles(): Promise { - const files: string[] = [] - for (const dirPath of getCodexSessionDirectories()) { - try { - appendDiscoveredFiles(files, await walkJsonlFiles(dirPath)) - } catch { - // Missing or unreadable history in one home should not hide the other. - } - } - return dedupeCodexSessionFileAliases(files, hasLegacyCopiedSessionBridgeMarkers()) -} - -async function dedupeCodexSessionFileAliases( - files: string[], - hasLegacyBridgeMarkers: boolean -): Promise { - const excludedAliases = new Set() - if (hasLegacyBridgeMarkers) { - for (const [index, filePath] of files.entries()) { - const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) - if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { - await yieldToEventLoop() - } - if (!legacyCopyBridge) { - continue - } - if (legacyCopyBridge.sourceSkipBytes !== null) { - continue - } - excludedAliases.add( - await getPhysicalFileAliasKey( - legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath - ) - ) - } - } - - const seenAliases = new Set() - const uniqueFiles: string[] = [] - for (const [index, filePath] of [...new Set(files)].sort().entries()) { - const aliasKey = await getCodexSessionFileAliasKey(filePath) - if (excludedAliases.has(aliasKey)) { - continue - } - if (seenAliases.has(aliasKey)) { - continue - } - seenAliases.add(aliasKey) - uniqueFiles.push(filePath) - if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { - await yieldToEventLoop() - } - } - return uniqueFiles -} - -async function getCodexSessionFileAliasKey(filePath: string): Promise { - return getPhysicalFileAliasKey(filePath) -} - -async function getPhysicalFileAliasKey(filePath: string): Promise { - try { - const fileStat = await stat(filePath) - if (fileStat.ino !== 0) { - return `${fileStat.dev}:${fileStat.ino}` - } - } catch {} - return `path:${await canonicalizePath(filePath)}` -} - -function getLegacySourceSkipBytesByPath( - files: string[], - hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers() -): Map { - const sourceSkipBytesByPath = new Map() - if (!hasLegacyBridgeMarkers) { - return sourceSkipBytesByPath - } - for (const filePath of files) { - const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) - if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) { - continue - } - const existing = sourceSkipBytesByPath.get(legacyCopyBridge.sourcePath) ?? 0 - sourceSkipBytesByPath.set( - legacyCopyBridge.sourcePath, - Math.max(existing, legacyCopyBridge.sourceSkipBytes) - ) - } - return sourceSkipBytesByPath -} export async function getProcessedFileInfo(filePath: string): Promise { const fileStat = await stat(filePath) @@ -226,317 +34,12 @@ export async function getProcessedFileInfo(filePath: string): Promise - const inputTokens = ensureNumber(record.input_tokens) - const cachedInputTokens = ensureNumber( - record.cached_input_tokens ?? record.cache_read_input_tokens - ) - const outputTokens = ensureNumber(record.output_tokens) - const reasoningOutputTokens = ensureNumber(record.reasoning_output_tokens) - const totalTokens = ensureNumber(record.total_tokens) - - return { - inputTokens, - cachedInputTokens, - outputTokens, - reasoningOutputTokens, - // Why: legacy Codex logs can omit total_tokens. Reasoning is already billed - // inside output, so synthesizing input+output matches Codex pricing instead - // of double-counting reasoning as another billable bucket. - totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens - } -} - -function subtractRawUsage( - current: CodexUsageRawUsage, - previous: CodexUsageRawUsage | null -): CodexUsageRawUsage { - return { - inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0), - cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0), - outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0), - reasoningOutputTokens: Math.max( - current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0), - 0 - ), - totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0) - } -} - -function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage { - return { - inputTokens: left.inputTokens + right.inputTokens, - cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, - outputTokens: left.outputTokens + right.outputTokens, - reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens, - totalTokens: left.totalTokens + right.totalTokens - } -} - -function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean { - return ( - left.inputTokens === right.inputTokens && - left.cachedInputTokens === right.cachedInputTokens && - left.outputTokens === right.outputTokens && - left.reasoningOutputTokens === right.reasoningOutputTokens - ) -} - -function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean { - return ( - current.inputTokens >= previous.inputTokens && - current.cachedInputTokens >= previous.cachedInputTokens && - current.outputTokens >= previous.outputTokens && - current.reasoningOutputTokens >= previous.reasoningOutputTokens - ) -} - -function rawUsageMagnitude(usage: CodexUsageRawUsage): number { - return ( - usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens - ) -} - -function looksLikeStaleRegression( - current: CodexUsageRawUsage, - previous: CodexUsageRawUsage, - last: CodexUsageRawUsage -): boolean { - const previousTotal = rawUsageMagnitude(previous) - const currentTotal = rawUsageMagnitude(current) - const lastTotal = rawUsageMagnitude(last) - if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) { - return false - } - return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal -} - -function resolveCodexUsageDelta( - totalUsage: CodexUsageRawUsage | null, - lastUsage: CodexUsageRawUsage | null, - previousTotals: CodexUsageRawUsage | null -): CodexUsageDeltaResolution | null { - if (totalUsage && lastUsage && previousTotals) { - if (rawUsageEquals(totalUsage, previousTotals)) { - return null - } - if ( - !rawUsageIsMonotonic(totalUsage, previousTotals) && - looksLikeStaleRegression(totalUsage, previousTotals, lastUsage) - ) { - return null - } - // Why: Codex totals are mutable snapshots after compaction/resume. The - // last_token_usage payload is the billable increment; totals are the baseline. - return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } - } - - if (totalUsage && lastUsage) { - return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } - } - - if (totalUsage && previousTotals) { - if (rawUsageEquals(totalUsage, previousTotals)) { - return null - } - if (!rawUsageIsMonotonic(totalUsage, previousTotals)) { - return { kind: 'baseline', nextTotals: totalUsage } - } - return { - kind: 'event', - delta: subtractRawUsage(totalUsage, previousTotals), - nextTotals: totalUsage - } - } - - if (totalUsage) { - return { kind: 'event', delta: totalUsage, nextTotals: totalUsage } - } - - if (lastUsage && previousTotals) { - return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) } - } - - if (lastUsage) { - return { kind: 'event', delta: lastUsage, nextTotals: null } - } - - return null -} - -function buildCodexUsageEventKey( - timestamp: string, - totalUsage: CodexUsageRawUsage | null, - lastUsage: CodexUsageRawUsage | null -): string { - // Why: fork/resume copies token_count records byte-for-byte into a new - // rollout file, but session_meta.id is often rewritten to the new session. - // Key only on the raw record fields (timestamp + usage tuples) so the copy - // matches the original regardless of surrounding parse context / session id. - const tupleOf = (usage: CodexUsageRawUsage | null): string => - usage - ? [ - usage.inputTokens, - usage.cachedInputTokens, - usage.outputTokens, - usage.reasoningOutputTokens, - usage.totalTokens - ].join(',') - : '' - return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|') -} - -function extractModel(value: unknown): string | null { - if (value == null || typeof value !== 'object') { - return null - } - - const record = value as Record - const direct = [extractString(record.model), extractString(record.model_name)].find( - (candidate) => candidate !== null - ) - if (direct) { - return direct - } - - if (record.info && typeof record.info === 'object') { - const info = record.info as Record - const infoDirect = [extractString(info.model), extractString(info.model_name)].find( - (candidate) => candidate !== null - ) - if (infoDirect) { - return infoDirect - } - if (info.metadata && typeof info.metadata === 'object') { - const metadata = info.metadata as Record - const metadataModel = extractString(metadata.model) - if (metadataModel) { - return metadataModel - } - } - } - - if (record.metadata && typeof record.metadata === 'object') { - const metadata = record.metadata as Record - return extractString(metadata.model) - } - - return null -} - -function getDefaultProjectLabel(cwd: string | null): string { - if (!cwd) { - return 'Unknown location' - } - const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts.at(-1) ?? cwd -} - -function localDayFromTimestamp(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - async function buildWorktreesWithCanonicalPaths( worktrees: CodexUsageWorktreeRef[] ): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> { return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) } -function isContainingPath(candidatePath: string, targetPath: string): boolean { - const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) - const relativePath = useWin32 - ? win32.relative(candidatePath, targetPath) - : posix.relative(candidatePath, targetPath) - if (!relativePath) { - return true - } - // Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an - // absolute `D:\\other` path instead of a `..`-prefixed relative. Treating - // that as "contained" would attribute off-drive Codex usage to the wrong - // Orca worktree. - const isAbsoluteRelative = useWin32 - ? win32.isAbsolute(relativePath) - : posix.isAbsolute(relativePath) - const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` - // Why: `..name` is a valid child path; only `..` and `../...` escape. - return ( - !isAbsoluteRelative && - relativePath !== '..' && - !relativePath.startsWith(parentPrefix) && - relativePath !== '.' - ) -} - -function findContainingWorktree( - cwd: string, - worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[] -): CodexUsageWorktreeRef | null { - const normalizedCwd = normalizeFsPath(cwd) - for (const worktree of worktrees) { - if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { - return worktree - } - if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { - return worktree - } - } - return null -} - -export async function attributeCodexUsageEvent( - event: CodexUsageParsedEvent, - worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[] -): Promise { - const day = localDayFromTimestamp(event.timestamp) - if (!day) { - return null - } - - let repoId: string | null = null - let worktreeId: string | null = null - let projectKey = 'unscoped' - let projectLabel = getDefaultProjectLabel(event.cwd) - - if (event.cwd) { - const worktree = findContainingWorktree(event.cwd, worktrees) - if (worktree) { - repoId = worktree.repoId - worktreeId = worktree.worktreeId - projectKey = `worktree:${worktree.worktreeId}` - projectLabel = worktree.displayName - } else { - // Why: all-local mode should still collapse repeated off-Orca sessions by - // location, but those keys must normalize slash/case differences so the - // same folder does not fragment into multiple "projects" across platforms. - projectKey = `cwd:${normalizeComparablePath(event.cwd)}` - } - } - - return { - ...event, - day, - projectKey, - projectLabel, - repoId, - worktreeId - } -} - type CodexUsageMetric = { hasInferredPricing: boolean } const codexUsageAggregation = createUsageEventAggregation< @@ -561,106 +64,6 @@ const codexUsageAggregation = createUsageEventAggregation< const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregates } = codexUsageAggregation -export function parseCodexUsageRecord( - line: string, - context: CodexUsageParseContext -): CodexUsageParsedEvent | null { - let parsed: CodexUsageRawRecord - try { - parsed = JSON.parse(line) as CodexUsageRawRecord - } catch { - return null - } - - if (!parsed.type || !parsed.payload) { - return null - } - - if (parsed.type === 'session_meta') { - context.sessionId = extractString(parsed.payload.id) ?? context.sessionId - context.sessionCwd = extractString(parsed.payload.cwd) - if (!context.currentCwd && context.sessionCwd) { - context.currentCwd = context.sessionCwd - } - return null - } - - if (parsed.type === 'turn_context') { - context.currentCwd = - extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd - context.currentModel = extractModel(parsed.payload) ?? context.currentModel - return null - } - - if (parsed.type !== 'event_msg' || parsed.payload.type !== 'token_count' || !parsed.timestamp) { - return null - } - - const info = parsed.payload.info - if (info == null || typeof info !== 'object') { - // Why: Codex emits token_count snapshots with null info for rate-limit - // updates. Treating them as malformed usage would make active sessions look - // flaky and create false scan errors for perfectly valid logs. - return null - } - - const record = info as Record - const totalUsage = normalizeRawUsage(record.total_token_usage) - const lastUsage = normalizeRawUsage(record.last_token_usage) - if (context.totalOnlyBaselinePending) { - context.totalOnlyBaselinePending = false - if (totalUsage && !lastUsage && !context.previousTotals) { - context.previousTotals = totalUsage - return null - } - } - const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals) - if (!resolvedUsage) { - return null - } - if (resolvedUsage.kind === 'baseline') { - context.previousTotals = resolvedUsage.nextTotals - return null - } - - let delta = { - ...resolvedUsage.delta, - cachedInputTokens: Math.min( - resolvedUsage.delta.cachedInputTokens, - resolvedUsage.delta.inputTokens - ) - } - - if ( - delta.inputTokens === 0 && - delta.cachedInputTokens === 0 && - delta.outputTokens === 0 && - delta.reasoningOutputTokens === 0 && - delta.totalTokens === 0 - ) { - return null - } - - context.previousTotals = resolvedUsage.nextTotals - - const resolvedModel = extractModel(parsed.payload) ?? context.currentModel - const hasInferredPricing = resolvedModel === null - - return { - sessionId: context.sessionId, - timestamp: parsed.timestamp, - eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage), - cwd: context.currentCwd ?? context.sessionCwd, - model: resolvedModel, - hasInferredPricing, - inputTokens: delta.inputTokens, - cachedInputTokens: delta.cachedInputTokens, - outputTokens: delta.outputTokens, - reasoningOutputTokens: delta.reasoningOutputTokens, - totalTokens: delta.totalTokens - } -} - export async function parseCodexUsageFile( filePath: string, worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[], diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index c1784f556a1..c187cdf290a 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' import type { @@ -14,106 +13,17 @@ import type { import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import type { CodexUsagePersistedState } from './types' +import type { AutomationUsageLookupInput } from './codex-automation-run-attribution' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' -import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { resolveCodexAutomationRunUsage } from './codex-automation-run-attribution' +import { buildRecentSessions } from './codex-usage-session-rows' +import { buildBreakdown, buildDaily, buildSummary } from './codex-usage-rollup-projections' import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null -type TieredPrice = { threshold: number; price: number } -type CodexModelPricing = { - input: number - cachedInput: number - output: number - inputTiers?: TieredPrice[] - cachedInputTiers?: TieredPrice[] - outputTiers?: TieredPrice[] -} - -type AutomationUsageLookupInput = { - worktreeId: string | null - terminalSessionId: string | null - startedAt: number | null - completedAt: number | null -} - -const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 - -const MODEL_PRICING: Record = { - 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 }, - 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 }, - 'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 }, - 'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 }, - 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 }, - 'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 }, - 'gpt-5.4-pro': { - input: 30, - cachedInput: 30, - output: 180, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] - }, - 'gpt-5.4': { - input: 2.5, - cachedInput: 0.25, - output: 15, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] - }, - 'gpt-5.5-pro': { - input: 30, - cachedInput: 30, - output: 180, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] - }, - 'gpt-5.5': { - input: 5, - cachedInput: 0.5, - output: 30, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] - }, - 'gpt-5.6-sol': { - input: 5, - cachedInput: 0.5, - output: 30, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] - }, - 'gpt-5.6-terra': { - input: 2.5, - cachedInput: 0.25, - output: 15, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] - }, - 'gpt-5.6-luna': { - input: 1, - cachedInput: 0.1, - output: 6, - inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }], - cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }], - outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }] - } -} - -const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none'] - function getDefaultState(): CodexUsagePersistedState { return { schemaVersion: SCHEMA_VERSION, @@ -167,155 +77,6 @@ function getCodexUsageFile(): string { return _codexUsageFile } -function stripParenthesizedReasoningTier(model: string): string | null { - const match = model.match(/^(.*)\(([^()]*)\)$/) - if (!match) { - return model - } - const tier = match[2].trim().toLowerCase() - if (!REASONING_TIER_SUFFIXES.includes(tier)) { - return null - } - return match[1] -} - -function stripDashReasoningTiers(model: string): string { - let current = model - for (let index = 0; index < 4; index++) { - const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`)) - if (!suffix) { - return current - } - current = current.slice(0, -suffix.length - 1) - } - return current -} - -function normalizeModelForPricing(model: string | null): string | null { - if (!model) { - return null - } - - const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim()) - if (!lower) { - return null - } - - const normalized = stripDashReasoningTiers(lower) - if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') { - return 'gpt-5' - } - if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) { - return 'gpt-5.1-codex-max' - } - if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) { - return 'gpt-5.1-codex' - } - if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) { - return 'gpt-5.1' - } - if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) { - return 'gpt-5.2-codex' - } - if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) { - return 'gpt-5.2' - } - if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) { - return 'gpt-5.3-codex-spark' - } - if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) { - return 'gpt-5.3-codex' - } - if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) { - return 'gpt-5.3' - } - if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) { - return 'gpt-5.4-mini' - } - if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) { - return 'gpt-5.4-nano' - } - if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) { - return 'gpt-5.4-pro' - } - if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) { - return 'gpt-5.4' - } - if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) { - return 'gpt-5.5-pro' - } - if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) { - return 'gpt-5.5' - } - if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) { - return 'gpt-5.6-sol' - } - if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) { - return 'gpt-5.6-terra' - } - if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) { - return 'gpt-5.6-luna' - } - // Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a - // `gpt-5.6-` prefix match would swallow the tier IDs above and any future - // cheaper variant. - if (normalized === 'gpt-5.6') { - return 'gpt-5.6-sol' - } - return null -} - -function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number { - let cost = 0 - let lowerBound = 0 - let activePrice = basePrice - for (const tier of tiers) { - if (tokens <= tier.threshold) { - return cost + Math.max(tokens - lowerBound, 0) * activePrice - } - cost += (tier.threshold - lowerBound) * activePrice - lowerBound = tier.threshold - activePrice = tier.price - } - return cost + Math.max(tokens - lowerBound, 0) * activePrice -} - -function estimateCostUsd( - model: string | null, - inputTokens: number, - cachedInputTokens: number, - outputTokens: number -): number | null { - const normalized = normalizeModelForPricing(model) - if (!normalized) { - return null - } - const pricing = MODEL_PRICING[normalized] - const clampedCached = Math.min(cachedInputTokens, inputTokens) - // Why: Codex cached tokens are part of the input bucket. Charge uncached - // input on (input-cached) so cached tokens are not billed once at full input - // price and again at cache-read price. - const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0) - return ( - (calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) + - calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) + - calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) / - 1_000_000 - ) -} - -type ScopedCodexUsageModelRow = { - modelKey: string - modelLabel: string - hasInferredPricing: boolean - eventCount: number - inputTokens: number - cachedInputTokens: number - outputTokens: number - reasoningOutputTokens: number - totalTokens: number -} - export class CodexUsageStore extends UsageProviderStoreLifecycle< 'processedFiles', CodexUsagePersistedState, @@ -340,104 +101,22 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle< ): CodexUsageSnapshot { return { scanState: this.getScanState(), - summary: this.buildSummary(scope, range), - daily: this.buildDaily(scope, range), - modelBreakdown: this.buildBreakdown(scope, range, 'model'), - projectBreakdown: this.buildBreakdown(scope, range, 'project'), - recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit) + summary: buildSummary(this.state, scope, range), + daily: buildDaily(this.state, scope, range), + modelBreakdown: buildBreakdown(this.state, scope, range, 'model'), + projectBreakdown: buildBreakdown(this.state, scope, range, 'project'), + recentSessions: buildRecentSessions(this.state, scope, range, recentSessionLimit) } } async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) - return this.buildSummary(scope, range) - } - - private buildSummary(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageSummary { - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - let inputTokens = 0 - let cachedInputTokens = 0 - let outputTokens = 0 - let reasoningOutputTokens = 0 - let totalTokens = 0 - let events = 0 - let estimatedCostUsd = 0 - let hasAnyBillableCost = false - const byModel = new Map() - const byProject = new Map() - - for (const row of filteredDaily) { - inputTokens += row.inputTokens - cachedInputTokens += row.cachedInputTokens - outputTokens += row.outputTokens - reasoningOutputTokens += row.reasoningOutputTokens - totalTokens += row.totalTokens - events += row.eventCount - byModel.set( - row.model ?? 'Unknown model', - (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens - ) - byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) - const cost = estimateCostUsd( - row.model, - row.inputTokens, - row.cachedInputTokens, - row.outputTokens - ) - if (cost !== null) { - hasAnyBillableCost = true - estimatedCostUsd += cost - } - } - - const topModel = - [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - const topProject = - [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - - return { - scope, - range, - sessions: filteredSessions.length, - events, - inputTokens, - cachedInputTokens, - outputTokens, - reasoningOutputTokens, - totalTokens, - estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null, - topModel, - topProject, - hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0 - } + return buildSummary(this.state, scope, range) } async getDaily(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) - return this.buildDaily(scope, range) - } - - private buildDaily(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageDailyPoint[] { - const byDay = new Map() - for (const row of this.getFilteredDaily(scope, range)) { - const existing = byDay.get(row.day) ?? { - day: row.day, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0 - } - existing.inputTokens += row.inputTokens - existing.cachedInputTokens += row.cachedInputTokens - existing.outputTokens += row.outputTokens - existing.reasoningOutputTokens += row.reasoningOutputTokens - existing.totalTokens += row.totalTokens - byDay.set(row.day, existing) - } - return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + return buildDaily(this.state, scope, range) } async getBreakdown( @@ -446,85 +125,7 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle< kind: CodexUsageBreakdownKind ): Promise { await this.refresh(false) - return this.buildBreakdown(scope, range, kind) - } - - private buildBreakdown( - scope: CodexUsageScope, - range: CodexUsageRange, - kind: CodexUsageBreakdownKind - ): CodexUsageBreakdownRow[] { - const rows = new Map() - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - for (const daily of filteredDaily) { - const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey - const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel - const existing = rows.get(key) ?? { - key, - label, - sessions: 0, - events: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - estimatedCostUsd: null, - hasInferredPricing: false - } - existing.events += daily.eventCount - existing.inputTokens += daily.inputTokens - existing.cachedInputTokens += daily.cachedInputTokens - existing.outputTokens += daily.outputTokens - existing.reasoningOutputTokens += daily.reasoningOutputTokens - existing.totalTokens += daily.totalTokens - existing.hasInferredPricing ||= daily.hasInferredPricing - rows.set(key, existing) - } - - for (const session of filteredSessions) { - if (kind === 'model') { - const seen = new Set() - for (const model of this.getScopedSessionModels(session, scope)) { - if (seen.has(model.modelKey)) { - continue - } - seen.add(model.modelKey) - const row = rows.get(model.modelKey) - if (row) { - row.sessions++ - } - } - continue - } - const matchingLocations = session.locationBreakdown.filter((entry) => - scope === 'all' ? true : entry.worktreeId !== null - ) - const seen = new Set() - for (const location of matchingLocations) { - if (seen.has(location.locationKey)) { - continue - } - seen.add(location.locationKey) - const row = rows.get(location.locationKey) - if (row) { - row.sessions++ - } - } - } - - for (const row of rows.values()) { - row.estimatedCostUsd = estimateCostUsd( - kind === 'model' ? row.key : null, - row.inputTokens, - row.cachedInputTokens, - row.outputTokens - ) - } - - return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + return buildBreakdown(this.state, scope, range, kind) } async getRecentSessions( @@ -533,305 +134,13 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle< limit = 12 ): Promise { await this.refresh(false) - return this.buildRecentSessions(scope, range, limit) - } - - private buildRecentSessions( - scope: CodexUsageScope, - range: CodexUsageRange, - limit = 12 - ): CodexUsageSessionRow[] { - return this.getFilteredSessions(scope, range) - .slice(0, limit) - .map((session) => { - const matchingLocations = session.locationBreakdown.filter((entry) => - scope === 'all' ? true : entry.worktreeId !== null - ) - const scopedLocations = - matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown - const totals = scopedLocations.reduce( - (acc, entry) => { - acc.events += entry.eventCount - acc.inputTokens += entry.inputTokens - acc.cachedInputTokens += entry.cachedInputTokens - acc.outputTokens += entry.outputTokens - acc.reasoningOutputTokens += entry.reasoningOutputTokens - acc.totalTokens += entry.totalTokens - acc.hasInferredPricing ||= entry.hasInferredPricing - return acc - }, - { - events: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - hasInferredPricing: false - } - ) - const durationMinutes = Math.max( - 0, - Math.round( - (new Date(session.lastTimestamp).getTime() - - new Date(session.firstTimestamp).getTime()) / - 60_000 - ) - ) - return { - sessionId: session.sessionId, - lastActiveAt: session.lastTimestamp, - durationMinutes, - projectLabel: - scopedLocations.length > 1 - ? 'Multiple locations' - : (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel), - model: this.getScopedSessionPrimaryModel(session, scope), - events: totals.events, - inputTokens: totals.inputTokens, - cachedInputTokens: totals.cachedInputTokens, - outputTokens: totals.outputTokens, - reasoningOutputTokens: totals.reasoningOutputTokens, - totalTokens: totals.totalTokens, - hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing - } - }) + return buildRecentSessions(this.state, scope, range, limit) } async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { - const collectedAt = Date.now() - const unavailable = ( - unavailableReason: AutomationRunUsage['unavailableReason'], - unavailableMessage: string - ): AutomationRunUsage => ({ - status: 'unavailable', - provider: 'codex', - model: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - reasoningOutputTokens: null, - totalTokens: null, - estimatedCostUsd: null, - estimatedCostSource: null, - providerSessionId: null, - attribution: null, - collectedAt, - unavailableReason, - unavailableMessage + return resolveCodexAutomationRunUsage(input, { + getState: () => this.state, + refresh: (force) => this.refresh(force) }) - - if (!this.state.scanState.enabled) { - return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.') - } - if (!input.worktreeId || !input.startedAt || !input.completedAt) { - return unavailable('no_matching_session', 'Run session metadata is incomplete.') - } - - const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt)) - if (scanState.lastScanError) { - return unavailable('scan_failed', scanState.lastScanError) - } - - const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS - const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS - const candidates = this.state.sessions.filter((session) => { - const first = new Date(session.firstTimestamp).getTime() - const last = new Date(session.lastTimestamp).getTime() - if (!Number.isFinite(first) || !Number.isFinite(last)) { - return false - } - if (session.sessionId === input.terminalSessionId) { - return true - } - if (first < windowStart || first > windowEnd || last > windowEnd) { - return false - } - return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) - }) - - if (candidates.length === 0) { - return unavailable('no_matching_session', 'No Codex usage session matched this run.') - } - if (candidates.length > 1) { - return unavailable( - 'ambiguous_session', - 'Multiple Codex usage sessions matched this run window.' - ) - } - - const session = candidates[0] - const scopedLocations = session.locationBreakdown.filter( - (entry) => entry.worktreeId === input.worktreeId - ) - const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown - const totals = locations.reduce( - (acc, entry) => { - acc.events += entry.eventCount - acc.inputTokens += entry.inputTokens - acc.cachedInputTokens += entry.cachedInputTokens - acc.outputTokens += entry.outputTokens - acc.reasoningOutputTokens += entry.reasoningOutputTokens - acc.totalTokens += entry.totalTokens - return acc - }, - { - events: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0 - } - ) - const scopedModelRows = session.locationModelBreakdown.filter( - (entry) => entry.worktreeId === input.worktreeId - ) - const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown - const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))] - let estimatedCostUsd = 0 - let hasKnownCost = false - if (scopedModelRows.length > 0) { - for (const modelRow of scopedModelRows) { - const cost = estimateCostUsd( - modelRow.modelKey, - modelRow.inputTokens, - modelRow.cachedInputTokens, - modelRow.outputTokens - ) - if (cost !== null) { - hasKnownCost = true - estimatedCostUsd += cost - } - } - } else if (!session.hasMixedModels) { - const cost = estimateCostUsd( - session.primaryModel, - totals.inputTokens, - totals.cachedInputTokens, - totals.outputTokens - ) - if (cost !== null) { - hasKnownCost = true - estimatedCostUsd += cost - } - } - - return { - status: 'known', - provider: 'codex', - model: - modelLabels.length === 1 - ? modelLabels[0] - : session.hasMixedModels - ? 'Mixed models' - : session.primaryModel, - inputTokens: totals.inputTokens, - outputTokens: totals.outputTokens, - cacheReadTokens: totals.cachedInputTokens, - cacheWriteTokens: null, - reasoningOutputTokens: totals.reasoningOutputTokens, - totalTokens: totals.totalTokens, - estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, - estimatedCostSource: hasKnownCost ? 'api_equivalent' : null, - providerSessionId: session.sessionId, - // Why: Orca terminal tab ids and Codex usage session ids are different - // systems today, so attribution is intentionally limited to one local - // provider session in the run's worktree/time window. - attribution: 'provider_session_time_window', - collectedAt, - unavailableReason: null, - unavailableMessage: null - } - } - - private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getUsageRangeCutoff(range) - return this.state.dailyAggregates.filter((entry) => { - if (cutoff && entry.day < cutoff) { - return false - } - if (scope === 'orca' && entry.worktreeId === null) { - return false - } - return true - }) - } - - private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getUsageRangeCutoff(range) - return this.state.sessions.filter((session) => { - const day = getLocalUsageDay(session.lastTimestamp) - if (!day) { - return false - } - if (cutoff && day < cutoff) { - return false - } - if (scope === 'orca') { - return session.locationBreakdown.some((entry) => entry.worktreeId !== null) - } - return true - }) - } - - private getScopedSessionModels( - session: CodexUsagePersistedState['sessions'][number], - scope: CodexUsageScope - ): ScopedCodexUsageModelRow[] { - if (scope === 'all' || session.locationModelBreakdown.length === 0) { - return session.modelBreakdown - } - - const rows = new Map() - for (const entry of session.locationModelBreakdown) { - if (entry.worktreeId === null) { - continue - } - const existing = rows.get(entry.modelKey) ?? { - modelKey: entry.modelKey, - modelLabel: entry.modelLabel, - hasInferredPricing: false, - eventCount: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0 - } - existing.hasInferredPricing ||= entry.hasInferredPricing - existing.eventCount += entry.eventCount - existing.inputTokens += entry.inputTokens - existing.cachedInputTokens += entry.cachedInputTokens - existing.outputTokens += entry.outputTokens - existing.reasoningOutputTokens += entry.reasoningOutputTokens - existing.totalTokens += entry.totalTokens - rows.set(entry.modelKey, existing) - } - return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) - } - - private getScopedSessionPrimaryModel( - session: CodexUsagePersistedState['sessions'][number], - scope: CodexUsageScope - ): string | null { - const scopedModels = this.getScopedSessionModels(session, scope) - if (scopedModels.length === 0) { - return session.primaryModel - } - if (scopedModels.length === 1) { - return scopedModels[0]?.modelLabel ?? null - } - return 'Mixed models' - } - - private shouldForceAutomationUsageScan(completedAt: number): boolean { - const { lastScanCompletedAt, lastScanError } = this.state.scanState - // Why: attribution needs a scan after the run finishes, but repeated - // lookups after that point should not rescan all Codex session history. - return ( - Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt - ) } } diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts new file mode 100644 index 00000000000..fb406dc7a79 --- /dev/null +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -0,0 +1,94 @@ +import { stat } from 'node:fs/promises' +import { basename, isAbsolute, join } from 'node:path' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' +import type { OpenCodeUsageProcessedDatabase } from './types' + +type OpenCodeDatabaseOverride = { + isConfigured: boolean + path: string | null +} + +function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { + const raw = process.env.OPENCODE_DB?.trim() + if (!raw) { + return { isConfigured: false, path: null } + } + if (raw === ':memory:') { + return { isConfigured: true, path: null } + } + return { + isConfigured: true, + path: isAbsolute(raw) ? raw : join(dataDirectory, raw) + } +} + +// Why gated: the AI Vault's primary OpenCode source delegates here from inside +// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled +// distro would otherwise hang the whole scan on a raw syscall (STA-4049). +export async function listOpenCodeDatabases( + /** Lets a caller report the refusal; an empty list otherwise reads as + * "OpenCode not used" rather than "we could not look". */ + onRefusal?: (path: string, error: WslTranscriptFsError) => void +): Promise { + const dataDirectory = resolveOpenCodeDataDirectory() + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + if (databaseOverride.isConfigured) { + if (!databaseOverride.path) { + return [] + } + try { + return (await wslGatedStat(databaseOverride.path, 'scan')).isFile() + ? [databaseOverride.path] + : [] + } catch (error) { + reportRefusal(databaseOverride.path, error, onRefusal) + return [] + } + } + + try { + const entries = await wslGatedReaddir(dataDirectory, 'scan') + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDirectory, entry.name)) + .sort() + } catch (error) { + reportRefusal(dataDirectory, error, onRefusal) + return [] + } +} + +function reportRefusal( + path: string, + error: unknown, + onRefusal?: (path: string, error: WslTranscriptFsError) => void +): void { + if (error instanceof WslTranscriptFsError) { + onRefusal?.(path, error) + } +} + +export function compareOpenCodeClaimPriority(left: string, right: string): number { + // Why: the canonical opencode.db is the live database; it must claim + // duplicated sessions ahead of stale sibling copies. Remaining ties use + // path order so ownership is deterministic across rescans. + const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1 + const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1 + if (leftRank !== rightRank) { + return leftRank - rightRank + } + return left < right ? -1 : left > right ? 1 : 0 +} + +export async function getProcessedDatabaseInfo( + dbPath: string +): Promise { + const dbStat = await stat(dbPath) + return { + path: dbPath, + mtimeMs: dbStat.mtimeMs, + size: dbStat.size + } +} diff --git a/src/main/opencode-usage/opencode-usage-row-parsing.ts b/src/main/opencode-usage/opencode-usage-row-parsing.ts new file mode 100644 index 00000000000..83242c47b99 --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-row-parsing.ts @@ -0,0 +1,110 @@ +import { ensureNumber, extractString } from '../usage/usage-record-coercion' +import type { OpenCodeUsageRow } from './opencode-usage-row-queries' +import type { OpenCodeUsageParsedEvent } from './types' + +function parseJsonObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string') { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function extractModelLabel(data: Record, sessionModel: unknown): string | null { + const directModel = extractString(data.modelID) ?? extractString(data.modelId) + const directProvider = extractString(data.providerID) ?? extractString(data.providerId) + if (directModel) { + return directProvider ? `${directProvider}/${directModel}` : directModel + } + + const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) + if (!modelObject) { + return null + } + const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) + const providerID = extractString(modelObject.providerID) + if (!modelID) { + return null + } + return providerID ? `${providerID}/${modelID}` : modelID +} + +function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { + const pathData = parseJsonObject(data.path) + return ( + extractString(pathData?.cwd) ?? + extractString(row.directory) ?? + extractString(row.worktree) ?? + null + ) +} + +function normalizeMillis(value: unknown): number | null { + const numeric = ensureNumber(value) + if (numeric <= 0) { + return null + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric +} + +function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { + const timeData = parseJsonObject(data.time) + const millis = + normalizeMillis(timeData?.completed) ?? + normalizeMillis(timeData?.created) ?? + normalizeMillis(row.time_updated) ?? + normalizeMillis(row.time_created) + return millis ? new Date(millis).toISOString() : null +} + +export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { + const data = parseJsonObject(row.data) + if (!data) { + return null + } + + const tokens = parseJsonObject(data.tokens) + if (!tokens) { + return null + } + const cache = parseJsonObject(tokens.cache) + const inputTokens = ensureNumber(tokens.input) + const outputTokens = ensureNumber(tokens.output) + const reasoningOutputTokens = ensureNumber(tokens.reasoning) + const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) + const totalTokens = + ensureNumber(tokens.total) > 0 + ? ensureNumber(tokens.total) + : inputTokens + outputTokens + reasoningOutputTokens + + if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { + return null + } + + const timestamp = extractTimestamp(data, row) + if (!timestamp) { + return null + } + + return { + sessionId: row.session_id, + timestamp, + cwd: extractCwd(data, row), + model: extractModelLabel(data, row.session_model), + estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + } +} diff --git a/src/main/opencode-usage/opencode-usage-row-queries.ts b/src/main/opencode-usage/opencode-usage-row-queries.ts new file mode 100644 index 00000000000..2e633d7cda2 --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-row-queries.ts @@ -0,0 +1,164 @@ +import type Database from '../sqlite/sync-database' +import { columnExists, tableExists } from './schema-helpers' + +export type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +export function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} diff --git a/src/main/opencode-usage/opencode-usage-worktree-attribution.ts b/src/main/opencode-usage/opencode-usage-worktree-attribution.ts new file mode 100644 index 00000000000..03d0e563059 --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-worktree-attribution.ts @@ -0,0 +1,122 @@ +import { realpath } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' +import { + looksLikeWindowsPath, + normalizeComparablePath, + normalizeFsPath +} from '../usage/usage-path-comparison' +import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' +import type { OpenCodeUsageAttributedEvent, OpenCodeUsageParsedEvent } from './types' + +export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) +} + +export async function buildWorktreesWithCanonicalPaths( + worktrees: OpenCodeUsageWorktreeRef[] +): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { + return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) +} + +async function canonicalizePath(pathValue: string): Promise { + try { + return normalizeFsPath(await realpath(pathValue)) + } catch { + return normalizeFsPath(pathValue) + } +} + +function findContainingWorktree( + cwd: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): OpenCodeUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeOpenCodeUsageEvent( + event: OpenCodeUsageParsedEvent, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} diff --git a/src/main/opencode-usage/persisted-state-normalization.ts b/src/main/opencode-usage/persisted-state-normalization.ts new file mode 100644 index 00000000000..0d54dfb4ecf --- /dev/null +++ b/src/main/opencode-usage/persisted-state-normalization.ts @@ -0,0 +1,68 @@ +import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' +import { OPENCODE_USAGE_SCHEMA_VERSION } from './opencode-usage-provider' + +const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION + +export function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizePersistedState( + state: OpenCodeUsagePersistedState +): OpenCodeUsagePersistedState { + if (state.schemaVersion !== SCHEMA_VERSION) { + return getDefaultState() + } + return { + ...state, + processedDatabases: (state.processedDatabases ?? []).map((database) => ({ + ...database, + sessions: (database.sessions ?? []).map(normalizeSessionCost), + dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) + })), + sessions: state.sessions.map(normalizeSessionCost), + dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) + } +} + +function normalizeDailyAggregateCost( + entry: OpenCodeUsageDailyAggregate +): OpenCodeUsageDailyAggregate { + return { + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + } +} + +function normalizeSessionCost( + session: OpenCodeUsagePersistedState['sessions'][number] +): OpenCodeUsagePersistedState['sessions'][number] { + return { + ...session, + estimatedCostUsd: session.estimatedCostUsd ?? null, + locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })) + } +} diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts index b7c94000635..44d6463d9f9 100644 --- a/src/main/opencode-usage/scanner-wsl-gate.test.ts +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -20,7 +20,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ stat: mocks.stat })) -import { listOpenCodeDatabases } from './scanner' +import { listOpenCodeDatabases } from './opencode-database-discovery' import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, WslTranscriptFsError diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 9680fea70fd..f4ce66b51e4 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -3,13 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import Database from '../sqlite/sync-database' -import { - attributeOpenCodeUsageEvent, - listOpenCodeDatabases, - parseOpenCodeUsageDatabase, - parseOpenCodeUsageRow, - scanOpenCodeUsageDatabases -} from './scanner' +import { listOpenCodeDatabases } from './opencode-database-discovery' +import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing' +import { attributeOpenCodeUsageEvent } from './opencode-usage-worktree-attribution' +import { parseOpenCodeUsageDatabase, scanOpenCodeUsageDatabases } from './scanner' const WORKTREE = '/workspace/repo' diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index f3a4e6b13e1..bd1ae8e3670 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,501 +1,27 @@ -/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ -import { realpath, stat } from 'node:fs/promises' -import { basename, isAbsolute, join, posix, win32 } from 'node:path' import { yieldToEventLoop } from '../../shared/event-loop-yield' -import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' -import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' -import { areWorktreePathsEqual } from '../ipc/worktree-logic' -import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' -import { columnExists, tableExists } from './schema-helpers' -import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import { createUsageEventAggregation } from '../usage/usage-event-aggregation' import { - looksLikeWindowsPath, - normalizeComparablePath, - normalizeFsPath -} from '../usage/usage-path-comparison' -import { ensureNumber, extractString } from '../usage/usage-record-coercion' -import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' + compareOpenCodeClaimPriority, + getProcessedDatabaseInfo, + listOpenCodeDatabases +} from './opencode-database-discovery' +import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing' +import { selectUsageRows } from './opencode-usage-row-queries' +import { + attributeOpenCodeUsageEvent, + buildWorktreesWithCanonicalPaths, + type OpenCodeUsageWorktreeRef +} from './opencode-usage-worktree-attribution' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, - OpenCodeUsageParsedEvent, OpenCodeUsagePersistedDatabase, - OpenCodeUsageProcessedDatabase, OpenCodeUsageSession } from './types' -export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef - -type OpenCodeUsageRow = { - id: string - session_id: string - time_created: number - time_updated: number | null - data: string - directory: string | null - title: string | null - worktree: string | null - session_model: string | null -} - -type OpenCodeSessionUsageRow = { - id: string - session_id: string - time_created: number - time_updated: number | null - directory: string | null - title: string | null - worktree: string | null - session_model: string | null - cost: number - tokens_input: number - tokens_output: number - tokens_reasoning: number - tokens_cache_read: number -} - const YIELD_EVERY_DATABASES = 2 -type OpenCodeDatabaseOverride = { - isConfigured: boolean - path: string | null -} - -function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { - const raw = process.env.OPENCODE_DB?.trim() - if (!raw) { - return { isConfigured: false, path: null } - } - if (raw === ':memory:') { - return { isConfigured: true, path: null } - } - return { - isConfigured: true, - path: isAbsolute(raw) ? raw : join(dataDirectory, raw) - } -} - -// Why gated: the AI Vault's primary OpenCode source delegates here from inside -// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled -// distro would otherwise hang the whole scan on a raw syscall (STA-4049). -export async function listOpenCodeDatabases( - /** Lets a caller report the refusal; an empty list otherwise reads as - * "OpenCode not used" rather than "we could not look". */ - onRefusal?: (path: string, error: WslTranscriptFsError) => void -): Promise { - const dataDirectory = resolveOpenCodeDataDirectory() - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) - if (databaseOverride.isConfigured) { - if (!databaseOverride.path) { - return [] - } - try { - return (await wslGatedStat(databaseOverride.path, 'scan')).isFile() - ? [databaseOverride.path] - : [] - } catch (error) { - reportRefusal(databaseOverride.path, error, onRefusal) - return [] - } - } - - try { - const entries = await wslGatedReaddir(dataDirectory, 'scan') - return entries - .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(dataDirectory, entry.name)) - .sort() - } catch (error) { - reportRefusal(dataDirectory, error, onRefusal) - return [] - } -} - -function reportRefusal( - path: string, - error: unknown, - onRefusal?: (path: string, error: WslTranscriptFsError) => void -): void { - if (error instanceof WslTranscriptFsError) { - onRefusal?.(path, error) - } -} - -function compareOpenCodeClaimPriority(left: string, right: string): number { - // Why: the canonical opencode.db is the live database; it must claim - // duplicated sessions ahead of stale sibling copies. Remaining ties use - // path order so ownership is deterministic across rescans. - const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1 - const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1 - if (leftRank !== rightRank) { - return leftRank - rightRank - } - return left < right ? -1 : left > right ? 1 : 0 -} - -export async function getProcessedDatabaseInfo( - dbPath: string -): Promise { - const dbStat = await stat(dbPath) - return { - path: dbPath, - mtimeMs: dbStat.mtimeMs, - size: dbStat.size - } -} - -function getProjectJoin(db: Database.Database): string { - return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') - ? 'LEFT JOIN project p ON p.id = s.project_id' - : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' -} - -function getSessionModelSelect(db: Database.Database): string { - return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' -} - -function getAssistantSessionMessageCount(db: Database.Database): number { - if (!tableExists(db, 'session_message')) { - return 0 - } - const assistantPredicate = columnExists(db, 'session_message', 'type') - ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" - : "json_extract(data, '$.tokens.input') IS NOT NULL" - const row = db - .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function canReadSessionUsageRows(db: Database.Database): boolean { - if (!tableExists(db, 'session')) { - return false - } - return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( - (columnName) => columnExists(db, 'session', columnName) - ) -} - -function getSessionUsageRowCount(db: Database.Database): number { - if (!canReadSessionUsageRows(db)) { - return 0 - } - const row = db - .prepare( - `SELECT COUNT(*) AS count - FROM session - WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` - ) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - const rows = db - .prepare( - `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, - s.directory, s.title, p.worktree, ${sessionModelSelect}, - s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read - FROM session s - ${projectJoin} - WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 - ORDER BY s.time_created, s.id` - ) - .all() as OpenCodeSessionUsageRow[] - - return rows.map((row) => ({ - id: row.id, - session_id: row.session_id, - time_created: row.time_created, - time_updated: row.time_updated, - directory: row.directory, - title: row.title, - worktree: row.worktree, - session_model: row.session_model, - data: JSON.stringify({ - cost: row.cost, - tokens: { - input: row.tokens_input, - output: row.tokens_output, - reasoning: row.tokens_reasoning, - total: row.tokens_input + row.tokens_output + row.tokens_reasoning, - cache: { - read: row.tokens_cache_read, - write: 0 - } - } - }) - })) -} - -function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { - if (!tableExists(db, 'session')) { - return [] - } - - // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading - // one aggregate row per session is faster than parsing every message blob. - if (getSessionUsageRowCount(db) > 0) { - return selectSessionUsageRows(db) - } - - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - - if (getAssistantSessionMessageCount(db) > 0) { - const assistantPredicate = columnExists(db, 'session_message', 'type') - ? "sm.type = 'assistant'" - : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" - return db - .prepare( - `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} - FROM session_message sm - JOIN session s ON s.id = sm.session_id - ${projectJoin} - WHERE ${assistantPredicate} - ORDER BY sm.time_created, sm.id` - ) - .all() as OpenCodeUsageRow[] - } - - if (!tableExists(db, 'message')) { - return [] - } - - return db - .prepare( - `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} - FROM message m - JOIN session s ON s.id = m.session_id - ${projectJoin} - WHERE json_extract(m.data, '$.role') = 'assistant' - ORDER BY m.time_created, m.id` - ) - .all() as OpenCodeUsageRow[] -} - -function parseJsonObject(value: unknown): Record | null { - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - return value as Record - } - if (typeof value !== 'string') { - return null - } - try { - const parsed = JSON.parse(value) as unknown - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : null - } catch { - return null - } -} - -function extractModelLabel(data: Record, sessionModel: unknown): string | null { - const directModel = extractString(data.modelID) ?? extractString(data.modelId) - const directProvider = extractString(data.providerID) ?? extractString(data.providerId) - if (directModel) { - return directProvider ? `${directProvider}/${directModel}` : directModel - } - - const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) - if (!modelObject) { - return null - } - const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) - const providerID = extractString(modelObject.providerID) - if (!modelID) { - return null - } - return providerID ? `${providerID}/${modelID}` : modelID -} - -function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { - const pathData = parseJsonObject(data.path) - return ( - extractString(pathData?.cwd) ?? - extractString(row.directory) ?? - extractString(row.worktree) ?? - null - ) -} - -function normalizeMillis(value: unknown): number | null { - const numeric = ensureNumber(value) - if (numeric <= 0) { - return null - } - return numeric < 10_000_000_000 ? numeric * 1000 : numeric -} - -function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { - const timeData = parseJsonObject(data.time) - const millis = - normalizeMillis(timeData?.completed) ?? - normalizeMillis(timeData?.created) ?? - normalizeMillis(row.time_updated) ?? - normalizeMillis(row.time_created) - return millis ? new Date(millis).toISOString() : null -} - -export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { - const data = parseJsonObject(row.data) - if (!data) { - return null - } - - const tokens = parseJsonObject(data.tokens) - if (!tokens) { - return null - } - const cache = parseJsonObject(tokens.cache) - const inputTokens = ensureNumber(tokens.input) - const outputTokens = ensureNumber(tokens.output) - const reasoningOutputTokens = ensureNumber(tokens.reasoning) - const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) - const totalTokens = - ensureNumber(tokens.total) > 0 - ? ensureNumber(tokens.total) - : inputTokens + outputTokens + reasoningOutputTokens - - if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { - return null - } - - const timestamp = extractTimestamp(data, row) - if (!timestamp) { - return null - } - - return { - sessionId: row.session_id, - timestamp, - cwd: extractCwd(data, row), - model: extractModelLabel(data, row.session_model), - estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, - inputTokens, - cachedInputTokens, - outputTokens, - reasoningOutputTokens, - totalTokens - } -} - -function getDefaultProjectLabel(cwd: string | null): string { - if (!cwd) { - return 'Unknown location' - } - const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts.at(-1) ?? cwd -} - -function localDayFromTimestamp(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function isContainingPath(candidatePath: string, targetPath: string): boolean { - const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) - const relativePath = useWin32 - ? win32.relative(candidatePath, targetPath) - : posix.relative(candidatePath, targetPath) - if (!relativePath) { - return true - } - const isAbsoluteRelative = useWin32 - ? win32.isAbsolute(relativePath) - : posix.isAbsolute(relativePath) - const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` - // Why: `..name` is a valid child path; only `..` and `../...` escape. - return ( - !isAbsoluteRelative && - relativePath !== '..' && - !relativePath.startsWith(parentPrefix) && - relativePath !== '.' - ) -} - -async function buildWorktreesWithCanonicalPaths( - worktrees: OpenCodeUsageWorktreeRef[] -): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { - return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) -} - -async function canonicalizePath(pathValue: string): Promise { - try { - return normalizeFsPath(await realpath(pathValue)) - } catch { - return normalizeFsPath(pathValue) - } -} - -function findContainingWorktree( - cwd: string, - worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] -): OpenCodeUsageWorktreeRef | null { - const normalizedCwd = normalizeFsPath(cwd) - for (const worktree of worktrees) { - if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { - return worktree - } - if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { - return worktree - } - } - return null -} - -export async function attributeOpenCodeUsageEvent( - event: OpenCodeUsageParsedEvent, - worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] -): Promise { - const day = localDayFromTimestamp(event.timestamp) - if (!day) { - return null - } - - let repoId: string | null = null - let worktreeId: string | null = null - let projectKey = 'unscoped' - let projectLabel = getDefaultProjectLabel(event.cwd) - - if (event.cwd) { - const worktree = findContainingWorktree(event.cwd, worktrees) - if (worktree) { - repoId = worktree.repoId - worktreeId = worktree.worktreeId - projectKey = `worktree:${worktree.worktreeId}` - projectLabel = worktree.displayName - } else { - projectKey = `cwd:${normalizeComparablePath(event.cwd)}` - } - } - - return { - ...event, - day, - projectKey, - projectLabel, - repoId, - worktreeId - } -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null diff --git a/src/main/opencode-usage/scope-range-filter.ts b/src/main/opencode-usage/scope-range-filter.ts new file mode 100644 index 00000000000..5c9fe6db2f0 --- /dev/null +++ b/src/main/opencode-usage/scope-range-filter.ts @@ -0,0 +1,40 @@ +import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../shared/opencode-usage-types' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' + +export function filterDailyAggregatesByScopeAndRange( + dailyAggregates: OpenCodeUsageDailyAggregate[], + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange +): OpenCodeUsageDailyAggregate[] { + const cutoff = getUsageRangeCutoff(range) + return dailyAggregates.filter((row) => { + if (scope === 'orca' && !row.worktreeId) { + return false + } + if (cutoff && row.day < cutoff) { + return false + } + return true + }) +} + +export function filterSessionsByScopeAndRange( + sessions: OpenCodeUsageSession[], + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange +): OpenCodeUsageSession[] { + const cutoff = getUsageRangeCutoff(range) + return sessions.filter((session) => { + if (scope === 'orca' && !session.primaryWorktreeId) { + return false + } + if (cutoff) { + const day = getLocalUsageDay(session.lastTimestamp) + if (!day || day < cutoff) { + return false + } + } + return true + }) +} diff --git a/src/main/opencode-usage/snapshot-rollups.ts b/src/main/opencode-usage/snapshot-rollups.ts new file mode 100644 index 00000000000..a98ddbdca14 --- /dev/null +++ b/src/main/opencode-usage/snapshot-rollups.ts @@ -0,0 +1,174 @@ +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../shared/opencode-usage-types' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types' + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +export function buildOpenCodeUsageSummary( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + filteredDaily: OpenCodeUsageDailyAggregate[], + filteredSessions: OpenCodeUsageSession[] +): OpenCodeUsageSummary { + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd: number | null = null + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + } + + const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd, + topModel, + topProject, + hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } +} + +export function buildOpenCodeUsageDailyPoints( + filteredDaily: OpenCodeUsageDailyAggregate[] +): OpenCodeUsageDailyPoint[] { + const byDay = new Map() + for (const row of filteredDaily) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) +} + +export function buildOpenCodeUsageBreakdownRows( + kind: OpenCodeUsageBreakdownKind, + filteredDaily: OpenCodeUsageDailyAggregate[], + filteredSessions: OpenCodeUsageSession[] +): OpenCodeUsageBreakdownRow[] { + const rows = new Map() + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) + rows.set(key, existing) + } + + if (kind === 'model') { + for (const session of filteredSessions) { + for (const entry of session.modelBreakdown) { + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + } + } + } else { + for (const session of filteredSessions) { + for (const entry of session.locationBreakdown) { + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + } + } + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) +} + +export function buildOpenCodeUsageRecentSessions( + filteredSessions: OpenCodeUsageSession[], + limit = 10 +): OpenCodeUsageSessionRow[] { + return filteredSessions.slice(0, limit).map( + (session): OpenCodeUsageSessionRow => ({ + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ), + projectLabel: session.primaryProjectLabel, + model: session.primaryModel, + events: session.eventCount, + inputTokens: session.totalInputTokens, + cachedInputTokens: session.totalCachedInputTokens, + outputTokens: session.totalOutputTokens, + reasoningOutputTokens: session.totalReasoningOutputTokens, + totalTokens: session.totalTokens + }) + ) +} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index d565327fd18..6f4f5a3f690 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -22,7 +22,8 @@ vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) -import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' +import { OpenCodeUsageStore, initOpenCodeUsagePath } from './store' +import { normalizePersistedState } from './persisted-state-normalization' import { scanOpenCodeUsageDatabases } from './scanner' function createEmptyScanResult() { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index b87357f9b0a..5dc8a87c67b 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' import type { @@ -12,49 +11,27 @@ import type { OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' -import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedState, + OpenCodeUsageSession +} from './types' +import { openCodeUsageProvider } from './opencode-usage-provider' +import { getDefaultState, normalizePersistedState } from './persisted-state-normalization' +import { + filterDailyAggregatesByScopeAndRange, + filterSessionsByScopeAndRange +} from './scope-range-filter' +import { + buildOpenCodeUsageBreakdownRows, + buildOpenCodeUsageDailyPoints, + buildOpenCodeUsageRecentSessions, + buildOpenCodeUsageSummary +} from './snapshot-rollups' import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' -const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION - let _openCodeUsageFile: string | null = null -function getDefaultState(): OpenCodeUsagePersistedState { - return { - schemaVersion: SCHEMA_VERSION, - worktreeFingerprint: null, - processedDatabases: [], - sessions: [], - dailyAggregates: [], - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - } -} - -export function normalizePersistedState( - state: OpenCodeUsagePersistedState -): OpenCodeUsagePersistedState { - if (state.schemaVersion !== SCHEMA_VERSION) { - return getDefaultState() - } - return { - ...state, - processedDatabases: (state.processedDatabases ?? []).map((database) => ({ - ...database, - sessions: (database.sessions ?? []).map(normalizeSessionCost), - dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) - })), - sessions: state.sessions.map(normalizeSessionCost), - dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) - } -} - export function initOpenCodeUsagePath(): void { _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') } @@ -66,43 +43,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function addCost(left: number | null, right: number | null): number | null { - if (left === null && right === null) { - return null - } - return (left ?? 0) + (right ?? 0) -} - -function normalizeDailyAggregateCost( - entry: OpenCodeUsageDailyAggregate -): OpenCodeUsageDailyAggregate { - return { - ...entry, - estimatedCostUsd: entry.estimatedCostUsd ?? null - } -} - -function normalizeSessionCost( - session: OpenCodeUsagePersistedState['sessions'][number] -): OpenCodeUsagePersistedState['sessions'][number] { - return { - ...session, - estimatedCostUsd: session.estimatedCostUsd ?? null, - locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ - ...entry, - estimatedCostUsd: entry.estimatedCostUsd ?? null - })), - modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ - ...entry, - estimatedCostUsd: entry.estimatedCostUsd ?? null - })), - locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ - ...entry, - estimatedCostUsd: entry.estimatedCostUsd ?? null - })) - } -} - export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< 'processedDatabases', OpenCodeUsagePersistedState, @@ -145,54 +85,12 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< } private buildSummary(scope: OpenCodeUsageScope, range: OpenCodeUsageRange): OpenCodeUsageSummary { - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - let inputTokens = 0 - let cachedInputTokens = 0 - let outputTokens = 0 - let reasoningOutputTokens = 0 - let totalTokens = 0 - let events = 0 - let estimatedCostUsd: number | null = null - const byModel = new Map() - const byProject = new Map() - - for (const row of filteredDaily) { - inputTokens += row.inputTokens - cachedInputTokens += row.cachedInputTokens - outputTokens += row.outputTokens - reasoningOutputTokens += row.reasoningOutputTokens - totalTokens += row.totalTokens - events += row.eventCount - estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) - byModel.set( - row.model ?? 'Unknown model', - (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens - ) - byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) - } - - const topModel = - [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - const topProject = - [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null - - return { + return buildOpenCodeUsageSummary( scope, range, - sessions: filteredSessions.length, - events, - inputTokens, - cachedInputTokens, - outputTokens, - reasoningOutputTokens, - totalTokens, - estimatedCostUsd, - topModel, - topProject, - hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 - } + this.getFilteredDaily(scope, range), + this.getFilteredSessions(scope, range) + ) } async getDaily( @@ -207,24 +105,7 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyPoint[] { - const byDay = new Map() - for (const row of this.getFilteredDaily(scope, range)) { - const existing = byDay.get(row.day) ?? { - day: row.day, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0 - } - existing.inputTokens += row.inputTokens - existing.cachedInputTokens += row.cachedInputTokens - existing.outputTokens += row.outputTokens - existing.reasoningOutputTokens += row.reasoningOutputTokens - existing.totalTokens += row.totalTokens - byDay.set(row.day, existing) - } - return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + return buildOpenCodeUsageDailyPoints(this.getFilteredDaily(scope, range)) } async getBreakdown( @@ -241,56 +122,11 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< range: OpenCodeUsageRange, kind: OpenCodeUsageBreakdownKind ): OpenCodeUsageBreakdownRow[] { - const rows = new Map() - const filteredDaily = this.getFilteredDaily(scope, range) - const filteredSessions = this.getFilteredSessions(scope, range) - - for (const daily of filteredDaily) { - const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey - const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel - const existing = rows.get(key) ?? { - key, - label, - sessions: 0, - events: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - estimatedCostUsd: null - } - existing.events += daily.eventCount - existing.inputTokens += daily.inputTokens - existing.cachedInputTokens += daily.cachedInputTokens - existing.outputTokens += daily.outputTokens - existing.reasoningOutputTokens += daily.reasoningOutputTokens - existing.totalTokens += daily.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) - rows.set(key, existing) - } - - if (kind === 'model') { - for (const session of filteredSessions) { - for (const entry of session.modelBreakdown) { - const row = rows.get(entry.modelKey) - if (row) { - row.sessions++ - } - } - } - } else { - for (const session of filteredSessions) { - for (const entry of session.locationBreakdown) { - const row = rows.get(entry.locationKey) - if (row) { - row.sessions++ - } - } - } - } - - return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + return buildOpenCodeUsageBreakdownRows( + kind, + this.getFilteredDaily(scope, range), + this.getFilteredSessions(scope, range) + ) } async getRecentSessions( @@ -307,61 +143,20 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< range: OpenCodeUsageRange, limit = 10 ): OpenCodeUsageSessionRow[] { - return this.getFilteredSessions(scope, range) - .slice(0, limit) - .map( - (session): OpenCodeUsageSessionRow => ({ - sessionId: session.sessionId, - lastActiveAt: session.lastTimestamp, - durationMinutes: Math.max( - 0, - Math.round( - (new Date(session.lastTimestamp).getTime() - - new Date(session.firstTimestamp).getTime()) / - 60_000 - ) - ), - projectLabel: session.primaryProjectLabel, - model: session.primaryModel, - events: session.eventCount, - inputTokens: session.totalInputTokens, - cachedInputTokens: session.totalCachedInputTokens, - outputTokens: session.totalOutputTokens, - reasoningOutputTokens: session.totalReasoningOutputTokens, - totalTokens: session.totalTokens - }) - ) + return buildOpenCodeUsageRecentSessions(this.getFilteredSessions(scope, range), limit) } private getFilteredDaily( scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getUsageRangeCutoff(range) - return this.state.dailyAggregates.filter((row) => { - if (scope === 'orca' && !row.worktreeId) { - return false - } - if (cutoff && row.day < cutoff) { - return false - } - return true - }) + return filterDailyAggregatesByScopeAndRange(this.state.dailyAggregates, scope, range) } - private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getUsageRangeCutoff(range) - return this.state.sessions.filter((session) => { - if (scope === 'orca' && !session.primaryWorktreeId) { - return false - } - if (cutoff) { - const day = getLocalUsageDay(session.lastTimestamp) - if (!day || day < cutoff) { - return false - } - } - return true - }) + private getFilteredSessions( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): OpenCodeUsageSession[] { + return filterSessionsByScopeAndRange(this.state.sessions, scope, range) } } diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 03001c2bec9..47c33a47ebe 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -5,12 +5,8 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { StatCard } from './StatCard' -import { - buildUsageOverview, - formatUsageCost, - formatUsageTokens, - getRecentUsageDays -} from './usage-overview-model' +import { getRecentUsageDays } from './usage-overview-daily-series' +import { buildUsageOverview, formatUsageCost, formatUsageTokens } from './usage-overview-model' import { DailyIntensityGrid, ProviderUsageRow, TokenMixBar } from './usage-overview-sections' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/stats/usage-overview-daily-series.ts b/src/renderer/src/components/stats/usage-overview-daily-series.ts new file mode 100644 index 00000000000..63c19a59eb1 --- /dev/null +++ b/src/renderer/src/components/stats/usage-overview-daily-series.ts @@ -0,0 +1,120 @@ +import type { ClaudeUsageDailyPoint } from '../../../../shared/claude-usage-types' +import type { UsageOverviewDailyPoint, UsageOverviewInput } from './usage-overview-types' + +export function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { + return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens +} + +function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { + if (totalTokens <= 0 || maxTokens <= 0) { + return 0 + } + const ratio = totalTokens / maxTokens + if (ratio <= 0.25) { + return 1 + } + if (ratio <= 0.5) { + return 2 + } + if (ratio <= 0.75) { + return 3 + } + return 4 +} + +export function countActiveDays(days: string[]): number { + return new Set(days).size +} + +export function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { + const byDay = new Map>() + + for (const entry of input.claude.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + const total = getClaudeDailyTotal(entry) + current.totalTokens += total + current.claudeTokens += total + byDay.set(entry.day, current) + } + + for (const entry of input.codex.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + current.totalTokens += entry.totalTokens + current.codexTokens += entry.totalTokens + byDay.set(entry.day, current) + } + + for (const entry of input.opencode.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + current.totalTokens += entry.totalTokens + current.openCodeTokens += entry.totalTokens + byDay.set(entry.day, current) + } + + let maxTokens = 0 + // Why: usage history can be large enough to exceed V8's argument limit if + // every day is spread into Math.max. + for (const entry of byDay.values()) { + maxTokens = Math.max(maxTokens, entry.totalTokens) + } + return [...byDay.values()] + .sort((left, right) => left.day.localeCompare(right.day)) + .map((entry) => ({ + ...entry, + intensity: getIntensity(entry.totalTokens, maxTokens) + })) +} + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getRecentUsageDays( + daily: UsageOverviewDailyPoint[], + dayCount: number, + anchorDate = new Date() +): UsageOverviewDailyPoint[] { + const byDay = new Map(daily.map((entry) => [entry.day, entry])) + const count = Math.max(1, Math.floor(dayCount)) + const end = new Date(anchorDate) + end.setHours(0, 0, 0, 0) + + const result: UsageOverviewDailyPoint[] = [] + for (let offset = count - 1; offset >= 0; offset--) { + const date = new Date(end) + date.setDate(end.getDate() - offset) + const day = formatLocalDay(date) + result.push( + byDay.get(day) ?? { + day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0, + intensity: 0 + } + ) + } + return result +} diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts index a9f52986085..469340f08b3 100644 --- a/src/renderer/src/components/stats/usage-overview-model.test.ts +++ b/src/renderer/src/components/stats/usage-overview-model.test.ts @@ -14,12 +14,8 @@ import type { OpenCodeUsageScanState, OpenCodeUsageSummary } from '../../../../shared/opencode-usage-types' -import { - buildUsageOverview, - formatUsageCost, - formatUsageTokens, - getRecentUsageDays -} from './usage-overview-model' +import { getRecentUsageDays } from './usage-overview-daily-series' +import { buildUsageOverview, formatUsageCost, formatUsageTokens } from './usage-overview-model' function enabledClaudeScanState(): ClaudeUsageScanState { return { diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts index db419949825..b6a61acabda 100644 --- a/src/renderer/src/components/stats/usage-overview-model.ts +++ b/src/renderer/src/components/stats/usage-overview-model.ts @@ -1,315 +1,14 @@ -/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share - one tested model so the overview UI cannot drift from the math. */ +import { buildDailyOverview, countActiveDays } from './usage-overview-daily-series' import type { - ClaudeUsageDailyPoint, - ClaudeUsageScanState, - ClaudeUsageSummary -} from '../../../../shared/claude-usage-types' -import type { - CodexUsageDailyPoint, - CodexUsageScanState, - CodexUsageSummary -} from '../../../../shared/codex-usage-types' -import type { - OpenCodeUsageDailyPoint, - OpenCodeUsageScanState, - OpenCodeUsageSummary -} from '../../../../shared/opencode-usage-types' -import { translate } from '@/i18n/i18n' - -export type UsageProviderId = 'claude' | 'codex' | 'opencode' - -export type UsageProviderOverview = { - id: UsageProviderId - label: string - enabled: boolean - isScanning: boolean - hasData: boolean - lastScanCompletedAt: number | null - lastScanError: string | null - sessions: number - activityLabel: 'turns' | 'events' - activityCount: number - totalTokens: number - newInputTokens: number - outputTokens: number - cacheTokens: number - reasoningTokens: number - estimatedCostUsd: number | null - topModel: string | null - topProject: string | null - activeDays: number -} - -export type UsageOverviewDailyPoint = { - day: string - totalTokens: number - claudeTokens: number - codexTokens: number - openCodeTokens: number - intensity: 0 | 1 | 2 | 3 | 4 -} - -export type UsageOverviewModel = { - providers: UsageProviderOverview[] - enabledProviderCount: number - dataProviderCount: number - hasAnyEnabledProvider: boolean - hasAnyData: boolean - totalTokens: number - newInputTokens: number - outputTokens: number - cacheTokens: number - reasoningTokens: number - sessions: number - activityCount: number - activeDays: number - estimatedCostUsd: number | null - hasPartialCost: boolean - cacheShare: number | null - daily: UsageOverviewDailyPoint[] - bestDay: UsageOverviewDailyPoint | null - lastUpdatedAt: number | null -} - -export type UsageOverviewInput = { - claude: { - scanState: ClaudeUsageScanState | null - summary: ClaudeUsageSummary | null - daily: ClaudeUsageDailyPoint[] - } - codex: { - scanState: CodexUsageScanState | null - summary: CodexUsageSummary | null - daily: CodexUsageDailyPoint[] - } - opencode: { - scanState: OpenCodeUsageScanState | null - summary: OpenCodeUsageSummary | null - daily: OpenCodeUsageDailyPoint[] - } -} - -function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { - return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens -} - -function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { - if (!summary) { - return 0 - } - return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) -} - -function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number { - if (!summary) { - return 0 - } - return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) -} - -function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { - if (totalTokens <= 0 || maxTokens <= 0) { - return 0 - } - const ratio = totalTokens / maxTokens - if (ratio <= 0.25) { - return 1 - } - if (ratio <= 0.5) { - return 2 - } - if (ratio <= 0.75) { - return 3 - } - return 4 -} - -function countActiveDays(days: string[]): number { - return new Set(days).size -} - -function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview { - const summary = input.summary - const dailyActiveDays = input.daily - .filter((entry) => getClaudeDailyTotal(entry) > 0) - .map((entry) => entry.day) - return { - id: 'claude', - label: translate('auto.components.stats.usage.overview.model.544d6d4c16', 'Claude'), - enabled: input.scanState?.enabled ?? false, - isScanning: input.scanState?.isScanning ?? false, - hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false, - lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, - lastScanError: input.scanState?.lastScanError ?? null, - sessions: summary?.sessions ?? 0, - activityLabel: 'turns', - activityCount: summary?.turns ?? 0, - totalTokens: summary - ? summary.inputTokens + - summary.outputTokens + - summary.cacheReadTokens + - summary.cacheWriteTokens - : 0, - newInputTokens: summary?.inputTokens ?? 0, - outputTokens: summary?.outputTokens ?? 0, - cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0, - reasoningTokens: 0, - estimatedCostUsd: summary?.estimatedCostUsd ?? null, - topModel: summary?.topModel ?? null, - topProject: summary?.topProject ?? null, - activeDays: countActiveDays(dailyActiveDays) - } -} - -function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview { - const summary = input.summary - const dailyActiveDays = input.daily - .filter((entry) => entry.totalTokens > 0) - .map((entry) => entry.day) - return { - id: 'codex', - label: translate('auto.components.stats.usage.overview.model.eb220d193b', 'Codex'), - enabled: input.scanState?.enabled ?? false, - isScanning: input.scanState?.isScanning ?? false, - hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false, - lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, - lastScanError: input.scanState?.lastScanError ?? null, - sessions: summary?.sessions ?? 0, - activityLabel: 'events', - activityCount: summary?.events ?? 0, - totalTokens: summary?.totalTokens ?? 0, - newInputTokens: getCodexNewInputTokens(summary), - outputTokens: summary?.outputTokens ?? 0, - cacheTokens: summary?.cachedInputTokens ?? 0, - reasoningTokens: summary?.reasoningOutputTokens ?? 0, - estimatedCostUsd: summary?.estimatedCostUsd ?? null, - topModel: summary?.topModel ?? null, - topProject: summary?.topProject ?? null, - activeDays: countActiveDays(dailyActiveDays) - } -} - -function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview { - const summary = input.summary - const dailyActiveDays = input.daily - .filter((entry) => entry.totalTokens > 0) - .map((entry) => entry.day) - return { - id: 'opencode', - label: translate('auto.components.stats.usage.overview.model.bc474051e5', 'OpenCode'), - enabled: input.scanState?.enabled ?? false, - isScanning: input.scanState?.isScanning ?? false, - hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false, - lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, - lastScanError: input.scanState?.lastScanError ?? null, - sessions: summary?.sessions ?? 0, - activityLabel: 'events', - activityCount: summary?.events ?? 0, - totalTokens: summary?.totalTokens ?? 0, - newInputTokens: getOpenCodeNewInputTokens(summary), - outputTokens: summary?.outputTokens ?? 0, - cacheTokens: summary?.cachedInputTokens ?? 0, - reasoningTokens: summary?.reasoningOutputTokens ?? 0, - estimatedCostUsd: summary?.estimatedCostUsd ?? null, - topModel: summary?.topModel ?? null, - topProject: summary?.topProject ?? null, - activeDays: countActiveDays(dailyActiveDays) - } -} - -function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { - const byDay = new Map>() - - for (const entry of input.claude.daily) { - const current = byDay.get(entry.day) ?? { - day: entry.day, - totalTokens: 0, - claudeTokens: 0, - codexTokens: 0, - openCodeTokens: 0 - } - const total = getClaudeDailyTotal(entry) - current.totalTokens += total - current.claudeTokens += total - byDay.set(entry.day, current) - } - - for (const entry of input.codex.daily) { - const current = byDay.get(entry.day) ?? { - day: entry.day, - totalTokens: 0, - claudeTokens: 0, - codexTokens: 0, - openCodeTokens: 0 - } - current.totalTokens += entry.totalTokens - current.codexTokens += entry.totalTokens - byDay.set(entry.day, current) - } - - for (const entry of input.opencode.daily) { - const current = byDay.get(entry.day) ?? { - day: entry.day, - totalTokens: 0, - claudeTokens: 0, - codexTokens: 0, - openCodeTokens: 0 - } - current.totalTokens += entry.totalTokens - current.openCodeTokens += entry.totalTokens - byDay.set(entry.day, current) - } - - let maxTokens = 0 - // Why: usage history can be large enough to exceed V8's argument limit if - // every day is spread into Math.max. - for (const entry of byDay.values()) { - maxTokens = Math.max(maxTokens, entry.totalTokens) - } - return [...byDay.values()] - .sort((left, right) => left.day.localeCompare(right.day)) - .map((entry) => ({ - ...entry, - intensity: getIntensity(entry.totalTokens, maxTokens) - })) -} - -function formatLocalDay(date: Date): string { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -export function getRecentUsageDays( - daily: UsageOverviewDailyPoint[], - dayCount: number, - anchorDate = new Date() -): UsageOverviewDailyPoint[] { - const byDay = new Map(daily.map((entry) => [entry.day, entry])) - const count = Math.max(1, Math.floor(dayCount)) - const end = new Date(anchorDate) - end.setHours(0, 0, 0, 0) - - const result: UsageOverviewDailyPoint[] = [] - for (let offset = count - 1; offset >= 0; offset--) { - const date = new Date(end) - date.setDate(end.getDate() - offset) - const day = formatLocalDay(date) - result.push( - byDay.get(day) ?? { - day, - totalTokens: 0, - claudeTokens: 0, - codexTokens: 0, - openCodeTokens: 0, - intensity: 0 - } - ) - } - return result -} + UsageOverviewDailyPoint, + UsageOverviewInput, + UsageOverviewModel +} from './usage-overview-types' +import { + createClaudeProvider, + createCodexProvider, + createOpenCodeProvider +} from './usage-provider-normalization' export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel { const providers = [ diff --git a/src/renderer/src/components/stats/usage-overview-sections.tsx b/src/renderer/src/components/stats/usage-overview-sections.tsx index 9e8c52da5a9..be1d6efec73 100644 --- a/src/renderer/src/components/stats/usage-overview-sections.tsx +++ b/src/renderer/src/components/stats/usage-overview-sections.tsx @@ -1,13 +1,12 @@ import { AlertCircle } from 'lucide-react' import { Badge } from '../ui/badge' import { Button } from '../ui/button' -import { - formatUsageCost, - formatUsageTokens, - type UsageOverviewDailyPoint, - type UsageOverviewModel, - type UsageProviderOverview -} from './usage-overview-model' +import { formatUsageCost, formatUsageTokens } from './usage-overview-model' +import type { + UsageOverviewDailyPoint, + UsageOverviewModel, + UsageProviderOverview +} from './usage-overview-types' import { translate } from '@/i18n/i18n' const INTENSITY_CLASS: Record = { diff --git a/src/renderer/src/components/stats/usage-overview-types.ts b/src/renderer/src/components/stats/usage-overview-types.ts new file mode 100644 index 00000000000..eb04a1f5bfb --- /dev/null +++ b/src/renderer/src/components/stats/usage-overview-types.ts @@ -0,0 +1,88 @@ +import type { + ClaudeUsageDailyPoint, + ClaudeUsageScanState, + ClaudeUsageSummary +} from '../../../../shared/claude-usage-types' +import type { + CodexUsageDailyPoint, + CodexUsageScanState, + CodexUsageSummary +} from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' + +export type UsageProviderId = 'claude' | 'codex' | 'opencode' + +export type UsageProviderOverview = { + id: UsageProviderId + label: string + enabled: boolean + isScanning: boolean + hasData: boolean + lastScanCompletedAt: number | null + lastScanError: string | null + sessions: number + activityLabel: 'turns' | 'events' + activityCount: number + totalTokens: number + newInputTokens: number + outputTokens: number + cacheTokens: number + reasoningTokens: number + estimatedCostUsd: number | null + topModel: string | null + topProject: string | null + activeDays: number +} + +export type UsageOverviewDailyPoint = { + day: string + totalTokens: number + claudeTokens: number + codexTokens: number + openCodeTokens: number + intensity: 0 | 1 | 2 | 3 | 4 +} + +export type UsageOverviewModel = { + providers: UsageProviderOverview[] + enabledProviderCount: number + dataProviderCount: number + hasAnyEnabledProvider: boolean + hasAnyData: boolean + totalTokens: number + newInputTokens: number + outputTokens: number + cacheTokens: number + reasoningTokens: number + sessions: number + activityCount: number + activeDays: number + estimatedCostUsd: number | null + hasPartialCost: boolean + cacheShare: number | null + daily: UsageOverviewDailyPoint[] + bestDay: UsageOverviewDailyPoint | null + lastUpdatedAt: number | null +} + +export type UsageOverviewInput = { + claude: { + scanState: ClaudeUsageScanState | null + summary: ClaudeUsageSummary | null + daily: ClaudeUsageDailyPoint[] + } + codex: { + scanState: CodexUsageScanState | null + summary: CodexUsageSummary | null + daily: CodexUsageDailyPoint[] + } + opencode: { + scanState: OpenCodeUsageScanState | null + summary: OpenCodeUsageSummary | null + daily: OpenCodeUsageDailyPoint[] + } +} diff --git a/src/renderer/src/components/stats/usage-provider-normalization.ts b/src/renderer/src/components/stats/usage-provider-normalization.ts new file mode 100644 index 00000000000..56c664d4b78 --- /dev/null +++ b/src/renderer/src/components/stats/usage-provider-normalization.ts @@ -0,0 +1,110 @@ +import type { CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { OpenCodeUsageSummary } from '../../../../shared/opencode-usage-types' +import { countActiveDays, getClaudeDailyTotal } from './usage-overview-daily-series' +import type { UsageOverviewInput, UsageProviderOverview } from './usage-overview-types' +import { translate } from '@/i18n/i18n' + +function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + +function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + +export function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => getClaudeDailyTotal(entry) > 0) + .map((entry) => entry.day) + return { + id: 'claude', + label: translate('auto.components.stats.usage.overview.model.544d6d4c16', 'Claude'), + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'turns', + activityCount: summary?.turns ?? 0, + totalTokens: summary + ? summary.inputTokens + + summary.outputTokens + + summary.cacheReadTokens + + summary.cacheWriteTokens + : 0, + newInputTokens: summary?.inputTokens ?? 0, + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0, + reasoningTokens: 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + +export function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'codex', + label: translate('auto.components.stats.usage.overview.model.eb220d193b', 'Codex'), + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getCodexNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + +export function createOpenCodeProvider( + input: UsageOverviewInput['opencode'] +): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'opencode', + label: translate('auto.components.stats.usage.overview.model.bc474051e5', 'OpenCode'), + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getOpenCodeNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +}