/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ import { app } from 'electron' import { dirname, join } from 'path' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' const SCHEMA_VERSION = 1 const STALE_MS = 5 * 60_000 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') } function getOpenCodeUsageFile(): string { if (!_openCodeUsageFile) { _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') } return _openCodeUsageFile } function getRangeCutoff(range: OpenCodeUsageRange): string | null { if (range === 'all') { return null } const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 const now = new Date() now.setHours(0, 0, 0, 0) now.setDate(now.getDate() - (days - 1)) const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function getLocalDay(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 getWorktreeFingerprint(worktreesByRepo: Map): string { const rows = [...worktreesByRepo.entries()] .flatMap(([repoId, worktrees]) => worktrees.map((worktree) => JSON.stringify({ repoId, worktreeId: worktree.worktreeId, path: worktree.path, displayName: worktree.displayName }) ) ) .sort() return JSON.stringify(rows) } 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 { private state: OpenCodeUsagePersistedState private readonly store: Store private scanPromise: Promise | null = null constructor(store: Store) { this.store = store this.state = this.load() } private load(): OpenCodeUsagePersistedState { try { const usageFile = getOpenCodeUsageFile() if (!existsSync(usageFile)) { return getDefaultState() } const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState return normalizePersistedState({ ...getDefaultState(), ...parsed, scanState: { ...getDefaultState().scanState, ...parsed.scanState } }) } catch (error) { console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) return getDefaultState() } } private writeToDisk(): void { const usageFile = getOpenCodeUsageFile() const dir = dirname(usageFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') renameSync(tmpFile, usageFile) } async setEnabled(enabled: boolean): Promise { this.state.scanState.enabled = enabled this.writeToDisk() return this.getScanState() } getScanState(): OpenCodeUsageScanState { return { ...this.state.scanState, isScanning: this.scanPromise !== null, hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 } } getSnapshot( scope: OpenCodeUsageScope, range: OpenCodeUsageRange, recentSessionLimit = 10 ): OpenCodeUsageSnapshot { 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) } } async refresh(force = false): Promise { if (!this.state.scanState.enabled) { return this.getScanState() } const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() if (!force && this.state.scanState.lastScanCompletedAt) { const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { return this.getScanState() } } await this.runScan() return this.getScanState() } private async runScan(): Promise { if (this.scanPromise) { await this.scanPromise return } this.state.scanState.lastScanStartedAt = Date.now() this.state.scanState.lastScanError = null this.writeToDisk() this.scanPromise = (async () => { try { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) const result = await scanOpenCodeUsageDatabases( createWorktreeRefs(repos, worktreesByRepo), this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedDatabases : [] ) this.state.processedDatabases = result.processedDatabases this.state.sessions = result.sessions this.state.dailyAggregates = result.dailyAggregates this.state.worktreeFingerprint = worktreeFingerprint this.state.scanState.lastScanCompletedAt = Date.now() this.state.scanState.lastScanError = null this.writeToDisk() } catch (error) { this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) this.writeToDisk() } finally { this.scanPromise = null } })() await this.scanPromise } async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): Promise { await this.refresh(false) return this.buildSummary(scope, range) } private buildSummary(scope: OpenCodeUsageScope, range: OpenCodeUsageRange): OpenCodeUsageSummary { const filteredDaily = this.getFilteredDaily(scope, range) const filteredSessions = this.getFilteredSessions(scope, range) let inputTokens = 0 let cachedInputTokens = 0 let outputTokens = 0 let reasoningOutputTokens = 0 let totalTokens = 0 let events = 0 let estimatedCostUsd: number | null = null const byModel = new Map() const byProject = new Map() for (const row of filteredDaily) { inputTokens += row.inputTokens cachedInputTokens += row.cachedInputTokens outputTokens += row.outputTokens reasoningOutputTokens += row.reasoningOutputTokens totalTokens += row.totalTokens events += row.eventCount estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) byModel.set( row.model ?? 'Unknown model', (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens ) byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) } const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null const topProject = [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null return { scope, range, sessions: filteredSessions.length, events, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens, totalTokens, estimatedCostUsd, topModel, topProject, hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 } } async getDaily( scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): Promise { await this.refresh(false) return this.buildDaily(scope, range) } private buildDaily( scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyPoint[] { const byDay = new Map() for (const row of this.getFilteredDaily(scope, range)) { const existing = byDay.get(row.day) ?? { day: row.day, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 } existing.inputTokens += row.inputTokens existing.cachedInputTokens += row.cachedInputTokens existing.outputTokens += row.outputTokens existing.reasoningOutputTokens += row.reasoningOutputTokens existing.totalTokens += row.totalTokens byDay.set(row.day, existing) } return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) } async getBreakdown( scope: OpenCodeUsageScope, range: OpenCodeUsageRange, kind: OpenCodeUsageBreakdownKind ): Promise { await this.refresh(false) return this.buildBreakdown(scope, range, kind) } private buildBreakdown( scope: OpenCodeUsageScope, range: OpenCodeUsageRange, kind: OpenCodeUsageBreakdownKind ): OpenCodeUsageBreakdownRow[] { const rows = new Map() const filteredDaily = this.getFilteredDaily(scope, range) const filteredSessions = this.getFilteredSessions(scope, range) for (const daily of filteredDaily) { const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel const existing = rows.get(key) ?? { key, label, sessions: 0, events: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, estimatedCostUsd: null } existing.events += daily.eventCount existing.inputTokens += daily.inputTokens existing.cachedInputTokens += daily.cachedInputTokens existing.outputTokens += daily.outputTokens existing.reasoningOutputTokens += daily.reasoningOutputTokens existing.totalTokens += daily.totalTokens existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) rows.set(key, existing) } if (kind === 'model') { for (const session of filteredSessions) { for (const entry of session.modelBreakdown) { const row = rows.get(entry.modelKey) if (row) { row.sessions++ } } } } else { for (const session of filteredSessions) { for (const entry of session.locationBreakdown) { const row = rows.get(entry.locationKey) if (row) { row.sessions++ } } } } return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) } async getRecentSessions( scope: OpenCodeUsageScope, range: OpenCodeUsageRange, limit = 10 ): Promise { await this.refresh(false) return this.buildRecentSessions(scope, range, limit) } private buildRecentSessions( scope: OpenCodeUsageScope, 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 }) ) } private getFilteredDaily( scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false } if (cutoff && row.day < cutoff) { return false } return true }) } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { const cutoff = getRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { const day = getLocalDay(session.lastTimestamp) if (!day || day < cutoff) { return false } } return true }) } private async getCurrentWorktreeFingerprint(): Promise { const repos = this.store.getRepos() return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) } }