mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<ClaudeModelPricing>
|
||||
|
||||
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
|
||||
'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<string, string> = {
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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<AutomationRunUsage> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
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<string, ClaudeUsageDailyPoint>()
|
||||
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<string, ClaudeUsageBreakdownRow>()
|
||||
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<string>()
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, ClaudeUsageWorktreeRef>,
|
||||
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<string> {
|
||||
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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
|
||||
@@ -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<string, number>()
|
||||
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<ClaudeUsageParsedTurn[]> {
|
||||
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<Map<string, ClaudeUsageWorktreeRef>> {
|
||||
const lookup = new Map<string, ClaudeUsageWorktreeRef>()
|
||||
for (const worktree of worktrees) {
|
||||
lookup.set(await canonicalizePath(worktree.path), worktree)
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
export async function attributeClaudeUsageTurns(
|
||||
turns: ClaudeUsageParsedTurn[],
|
||||
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
|
||||
): Promise<ClaudeUsageAttributedTurn[]> {
|
||||
const attributed: ClaudeUsageAttributedTurn[] = []
|
||||
const canonicalCwdByPath = new Map<string, string>()
|
||||
|
||||
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<string, ClaudeUsageSession>,
|
||||
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<string, ClaudeUsageDailyAggregate>,
|
||||
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<string, ClaudeUsageSession>
|
||||
): 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<string, ClaudeUsageSession>()
|
||||
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
+17
-615
@@ -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<ClaudeModelPricing>
|
||||
|
||||
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
|
||||
'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<string, string> = {
|
||||
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<ClaudeUsageSummary> {
|
||||
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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
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<ClaudeUsageDailyPoint[]> {
|
||||
await this.refresh(false)
|
||||
return this.buildDaily(scope, range)
|
||||
}
|
||||
|
||||
private buildDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange): ClaudeUsageDailyPoint[] {
|
||||
const byDay = new Map<string, ClaudeUsageDailyPoint>()
|
||||
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<ClaudeUsageBreakdownRow[]> {
|
||||
await this.refresh(false)
|
||||
return this.buildBreakdown(scope, range, kind)
|
||||
}
|
||||
|
||||
private buildBreakdown(
|
||||
scope: ClaudeUsageScope,
|
||||
range: ClaudeUsageRange,
|
||||
kind: ClaudeUsageBreakdownKind
|
||||
): ClaudeUsageBreakdownRow[] {
|
||||
const rows = new Map<string, ClaudeUsageBreakdownRow>()
|
||||
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<string>()
|
||||
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<ClaudeUsageSessionRow[]> {
|
||||
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<AutomationRunUsage> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string[]> {
|
||||
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()
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
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<ClaudeUsageParsedTurn[]> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type {
|
||||
ClaudeUsageAttributedTurn,
|
||||
ClaudeUsageDailyAggregate,
|
||||
ClaudeUsageLocationBreakdown,
|
||||
ClaudeUsageSession
|
||||
} from './types'
|
||||
|
||||
export function mergeClaudeSessions(
|
||||
target: Map<string, ClaudeUsageSession>,
|
||||
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<string, ClaudeUsageDailyAggregate>,
|
||||
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<string, ClaudeUsageSession>
|
||||
): 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<string, ClaudeUsageSession>()
|
||||
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -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<string, ClaudeUsageWorktreeRef>,
|
||||
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<string> {
|
||||
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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<Map<string, ClaudeUsageWorktreeRef>> {
|
||||
const lookup = new Map<string, ClaudeUsageWorktreeRef>()
|
||||
for (const worktree of worktrees) {
|
||||
lookup.set(await canonicalizePath(worktree.path), worktree)
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
export async function attributeClaudeUsageTurns(
|
||||
turns: ClaudeUsageParsedTurn[],
|
||||
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
|
||||
): Promise<ClaudeUsageAttributedTurn[]> {
|
||||
const attributed: ClaudeUsageAttributedTurn[] = []
|
||||
const canonicalCwdByPath = new Map<string, string>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<AutomationRunUsage> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string, CodexModelPricing> = {
|
||||
'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
|
||||
}
|
||||
@@ -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<string> {
|
||||
try {
|
||||
const resolved = await realpath(pathValue)
|
||||
return normalizeFsPath(resolved)
|
||||
} catch {
|
||||
return normalizeFsPath(pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
export async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(
|
||||
dirPath: string,
|
||||
progress: { entriesVisited: number } = { entriesVisited: 0 }
|
||||
): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
const excludedAliases = new Set<string>()
|
||||
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<string>()
|
||||
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<string> {
|
||||
return getPhysicalFileAliasKey(filePath)
|
||||
}
|
||||
|
||||
async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
|
||||
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<string, number> {
|
||||
const sourceSkipBytesByPath = new Map<string, number>()
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<CodexUsageAttributedEvent | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
const metadataModel = extractString(metadata.model)
|
||||
if (metadataModel) {
|
||||
return metadataModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.metadata && typeof record.metadata === 'object') {
|
||||
const metadata = record.metadata as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
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<string, CodexUsageDailyPoint>()
|
||||
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<string, CodexUsageBreakdownRow>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
@@ -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<string, ScopedCodexUsageModelRow>()
|
||||
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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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('|')
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+13
-610
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
try {
|
||||
const resolved = await realpath(pathValue)
|
||||
return normalizeFsPath(resolved)
|
||||
} catch {
|
||||
return normalizeFsPath(pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(
|
||||
dirPath: string,
|
||||
progress: { entriesVisited: number } = { entriesVisited: 0 }
|
||||
): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
const excludedAliases = new Set<string>()
|
||||
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<string>()
|
||||
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<string> {
|
||||
return getPhysicalFileAliasKey(filePath)
|
||||
}
|
||||
|
||||
async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
|
||||
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<string, number> {
|
||||
const sourceSkipBytesByPath = new Map<string, number>()
|
||||
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<CodexUsageProcessedFile> {
|
||||
const fileStat = await stat(filePath)
|
||||
@@ -226,317 +34,12 @@ export async function getProcessedFileInfo(filePath: string): Promise<CodexUsage
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRawUsage(value: unknown): CodexUsageRawUsage | null {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
const metadataModel = extractString(metadata.model)
|
||||
if (metadataModel) {
|
||||
return metadataModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.metadata && typeof record.metadata === 'object') {
|
||||
const metadata = record.metadata as Record<string, unknown>
|
||||
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<CodexUsageAttributedEvent | null> {
|
||||
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<string, unknown>
|
||||
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 })[],
|
||||
|
||||
+16
-707
@@ -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<string, CodexModelPricing> = {
|
||||
'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<CodexUsageSummary> {
|
||||
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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
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<CodexUsageDailyPoint[]> {
|
||||
await this.refresh(false)
|
||||
return this.buildDaily(scope, range)
|
||||
}
|
||||
|
||||
private buildDaily(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageDailyPoint[] {
|
||||
const byDay = new Map<string, CodexUsageDailyPoint>()
|
||||
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<CodexUsageBreakdownRow[]> {
|
||||
await this.refresh(false)
|
||||
return this.buildBreakdown(scope, range, kind)
|
||||
}
|
||||
|
||||
private buildBreakdown(
|
||||
scope: CodexUsageScope,
|
||||
range: CodexUsageRange,
|
||||
kind: CodexUsageBreakdownKind
|
||||
): CodexUsageBreakdownRow[] {
|
||||
const rows = new Map<string, CodexUsageBreakdownRow>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<CodexUsageSessionRow[]> {
|
||||
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<AutomationRunUsage> {
|
||||
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<string, ScopedCodexUsageModelRow>()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<OpenCodeUsageProcessedDatabase> {
|
||||
const dbStat = await stat(dbPath)
|
||||
return {
|
||||
path: dbPath,
|
||||
mtimeMs: dbStat.mtimeMs,
|
||||
size: dbStat.size
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | null {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
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<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractModelLabel(data: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<OpenCodeUsageAttributedEvent | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<OpenCodeUsageProcessedDatabase> {
|
||||
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<string, unknown> | null {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
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<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractModelLabel(data: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string> {
|
||||
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<OpenCodeUsageAttributedEvent | null> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
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<string, OpenCodeUsageDailyPoint>()
|
||||
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<string, OpenCodeUsageBreakdownRow>()
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
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<string, OpenCodeUsageDailyPoint>()
|
||||
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<string, OpenCodeUsageBreakdownRow>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -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<UsageOverviewDailyPoint['intensity'], string> = {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user