mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Add OpenCode usage analytics (#1986)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner'
|
||||
|
||||
const WORKTREE = '/workspace/repo'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
function createTempDb(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-'))
|
||||
tempDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function worktrees() {
|
||||
return [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
path: WORKTREE,
|
||||
displayName: 'Repo',
|
||||
canonicalPath: WORKTREE
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('parseOpenCodeUsageRow', () => {
|
||||
it('reads assistant message tokens, cost, model, cwd, and timestamp', () => {
|
||||
const parsed = parseOpenCodeUsageRow({
|
||||
id: 'message-1',
|
||||
session_id: 'session-1',
|
||||
time_created: 1_777_777_700_000,
|
||||
time_updated: null,
|
||||
directory: null,
|
||||
title: null,
|
||||
worktree: null,
|
||||
session_model: null,
|
||||
data: JSON.stringify({
|
||||
providerID: 'anthropic',
|
||||
modelID: 'claude-sonnet-4-5',
|
||||
path: { cwd: `${WORKTREE}/packages/app` },
|
||||
cost: 0.0123,
|
||||
tokens: {
|
||||
input: 1000,
|
||||
output: 250,
|
||||
reasoning: 100,
|
||||
total: 1350,
|
||||
cache: { read: 400, write: 25 }
|
||||
},
|
||||
time: {
|
||||
completed: 1_777_777_800_000
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(parsed).toEqual({
|
||||
sessionId: 'session-1',
|
||||
timestamp: new Date(1_777_777_800_000).toISOString(),
|
||||
cwd: `${WORKTREE}/packages/app`,
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
estimatedCostUsd: 0.0123,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeUsageDatabase', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
it('uses materialized session token totals when the OpenCode DB has them', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec(`
|
||||
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
cost REAL,
|
||||
tokens_input INTEGER,
|
||||
tokens_output INTEGER,
|
||||
tokens_reasoning INTEGER,
|
||||
tokens_cache_read INTEGER,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
`)
|
||||
db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE)
|
||||
db.prepare(
|
||||
`INSERT INTO session (
|
||||
id, project_id, directory, title, model, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
'session-1',
|
||||
'project-1',
|
||||
`${WORKTREE}/packages/app`,
|
||||
'Build feature',
|
||||
JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }),
|
||||
0.06,
|
||||
1000,
|
||||
500,
|
||||
100,
|
||||
250,
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
|
||||
expect(parsed.sessions).toHaveLength(1)
|
||||
expect(parsed.sessions[0]).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
primaryModel: 'anthropic/claude-sonnet-4-5',
|
||||
primaryProjectLabel: 'Repo',
|
||||
eventCount: 1,
|
||||
totalInputTokens: 1000,
|
||||
totalCachedInputTokens: 250,
|
||||
totalOutputTokens: 500,
|
||||
totalReasoningOutputTokens: 100,
|
||||
totalTokens: 1600,
|
||||
estimatedCostUsd: 0.06
|
||||
})
|
||||
expect(parsed.dailyAggregates).toEqual([
|
||||
expect.objectContaining({
|
||||
projectLabel: 'Repo',
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 250,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1600,
|
||||
estimatedCostUsd: 0.06
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('supports session_message tables without a type column', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
data TEXT
|
||||
);
|
||||
`)
|
||||
db.prepare(
|
||||
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000)
|
||||
db.prepare(
|
||||
'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
'message-1',
|
||||
'session-1',
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000,
|
||||
JSON.stringify({
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-5.5',
|
||||
cost: 0.03,
|
||||
tokens: {
|
||||
input: 800,
|
||||
output: 200,
|
||||
reasoning: 50,
|
||||
cache: { read: 100, write: 0 }
|
||||
}
|
||||
})
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
|
||||
expect(parsed.sessions[0]).toMatchObject({
|
||||
primaryModel: 'openai/gpt-5.5',
|
||||
primaryProjectLabel: 'Repo',
|
||||
totalTokens: 1050,
|
||||
estimatedCostUsd: 0.03
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers session_message rows over legacy message rows to avoid double counting', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
type TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
data TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
data TEXT
|
||||
);
|
||||
`)
|
||||
db.prepare(
|
||||
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000)
|
||||
db.prepare(
|
||||
'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
'session-message-1',
|
||||
'session-1',
|
||||
'assistant',
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000,
|
||||
JSON.stringify({
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-5.5',
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } }
|
||||
})
|
||||
)
|
||||
db.prepare(
|
||||
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
'legacy-message-1',
|
||||
'session-1',
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000,
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-5.5',
|
||||
tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } }
|
||||
})
|
||||
)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
|
||||
expect(parsed.sessions[0]?.totalTokens).toBe(120)
|
||||
expect(parsed.sessions[0]?.eventCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,931 @@
|
||||
/* 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 Database from 'better-sqlite3'
|
||||
import { existsSync } from 'fs'
|
||||
import { readdir, realpath, stat } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { isAbsolute, join, posix, win32 } from 'path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsageLocationBreakdown,
|
||||
OpenCodeUsageLocationModelBreakdown,
|
||||
OpenCodeUsageModelBreakdown,
|
||||
OpenCodeUsageParsedEvent,
|
||||
OpenCodeUsagePersistedDatabase,
|
||||
OpenCodeUsageProcessedDatabase,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
|
||||
export type OpenCodeUsageWorktreeRef = {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
path: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function ensureNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function normalizeComparablePath(pathValue: string, platform = process.platform): string {
|
||||
const normalized = pathValue.replace(/\\/g, '/')
|
||||
return platform === 'win32' || looksLikeWindowsPath(pathValue)
|
||||
? normalized.toLowerCase()
|
||||
: normalized
|
||||
}
|
||||
|
||||
function normalizeFsPath(pathValue: string, platform = process.platform): string {
|
||||
if (platform === 'win32' || looksLikeWindowsPath(pathValue)) {
|
||||
return win32.normalize(win32.resolve(pathValue))
|
||||
}
|
||||
return posix.normalize(posix.resolve(pathValue))
|
||||
}
|
||||
|
||||
function looksLikeWindowsPath(pathValue: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function getXdgDataHome(): string {
|
||||
if (process.env.XDG_DATA_HOME?.trim()) {
|
||||
return process.env.XDG_DATA_HOME.trim()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local')
|
||||
}
|
||||
return join(homedir(), '.local', 'share')
|
||||
}
|
||||
|
||||
function getOpenCodeDataDirectory(): string {
|
||||
return join(getXdgDataHome(), 'opencode')
|
||||
}
|
||||
|
||||
function getOpenCodeDatabasePathFromEnv(): string | null {
|
||||
const raw = process.env.OPENCODE_DB?.trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
if (raw === ':memory:') {
|
||||
return null
|
||||
}
|
||||
return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw)
|
||||
}
|
||||
|
||||
export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
const envPath = getOpenCodeDatabasePathFromEnv()
|
||||
if (envPath) {
|
||||
return existsSync(envPath) ? [envPath] : []
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(getOpenCodeDataDirectory(), entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessedDatabaseInfo(
|
||||
dbPath: string
|
||||
): Promise<OpenCodeUsageProcessedDatabase> {
|
||||
const dbStat = await stat(dbPath)
|
||||
return {
|
||||
path: dbPath,
|
||||
mtimeMs: dbStat.mtimeMs,
|
||||
size: dbStat.size
|
||||
}
|
||||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function tableExists(db: Database.Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
|
||||
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 extractString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : 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)
|
||||
return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.'
|
||||
}
|
||||
|
||||
async function buildWorktreesWithCanonicalPaths(
|
||||
worktrees: OpenCodeUsageWorktreeRef[]
|
||||
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
|
||||
const canonicalized = await Promise.all(
|
||||
worktrees.map(async (worktree) => ({
|
||||
...worktree,
|
||||
canonicalPath: await canonicalizePath(worktree.path)
|
||||
}))
|
||||
)
|
||||
return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return (left ?? 0) + (right ?? 0)
|
||||
}
|
||||
|
||||
function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession {
|
||||
return {
|
||||
sessionId: event.sessionId,
|
||||
firstTimestamp: event.timestamp,
|
||||
lastTimestamp: event.timestamp,
|
||||
primaryModel: event.model,
|
||||
hasMixedModels: false,
|
||||
primaryProjectLabel: event.projectLabel,
|
||||
hasMixedLocations: false,
|
||||
primaryWorktreeId: event.worktreeId,
|
||||
primaryRepoId: event.repoId,
|
||||
eventCount: 0,
|
||||
totalInputTokens: 0,
|
||||
totalCachedInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null,
|
||||
locationBreakdown: [],
|
||||
modelBreakdown: [],
|
||||
locationModelBreakdown: []
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDailyAggregate(
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
return {
|
||||
day: event.day,
|
||||
model: event.model,
|
||||
projectKey: event.projectKey,
|
||||
projectLabel: event.projectLabel,
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLocationBreakdown(
|
||||
target: OpenCodeUsageLocationBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
locationKey: event.projectKey,
|
||||
projectLabel: event.projectLabel,
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function mergeModelBreakdown(
|
||||
target: OpenCodeUsageModelBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const key = event.model ?? 'unknown'
|
||||
const existing = target.find((entry) => entry.modelKey === key) ?? null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
modelKey: key,
|
||||
modelLabel: event.model ?? 'Unknown model',
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function mergeLocationModelBreakdown(
|
||||
target: OpenCodeUsageLocationModelBreakdown[],
|
||||
event: OpenCodeUsageAttributedEvent
|
||||
): void {
|
||||
const modelKey = event.model ?? 'unknown'
|
||||
const existing =
|
||||
target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ??
|
||||
null
|
||||
if (existing) {
|
||||
existing.eventCount++
|
||||
existing.inputTokens += event.inputTokens
|
||||
existing.cachedInputTokens += event.cachedInputTokens
|
||||
existing.outputTokens += event.outputTokens
|
||||
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
existing.totalTokens += event.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd)
|
||||
return
|
||||
}
|
||||
|
||||
target.push({
|
||||
locationKey: event.projectKey,
|
||||
modelKey,
|
||||
modelLabel: event.model ?? 'Unknown model',
|
||||
repoId: event.repoId,
|
||||
worktreeId: event.worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: event.inputTokens,
|
||||
cachedInputTokens: event.cachedInputTokens,
|
||||
outputTokens: event.outputTokens,
|
||||
reasoningOutputTokens: event.reasoningOutputTokens,
|
||||
totalTokens: event.totalTokens,
|
||||
estimatedCostUsd: event.estimatedCostUsd
|
||||
})
|
||||
}
|
||||
|
||||
function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): {
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
} {
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
|
||||
for (const event of events) {
|
||||
const session = sessionsById.get(event.sessionId) ?? createEmptySession(event)
|
||||
if (!sessionsById.has(event.sessionId)) {
|
||||
sessionsById.set(event.sessionId, session)
|
||||
}
|
||||
if (event.timestamp < session.firstTimestamp) {
|
||||
session.firstTimestamp = event.timestamp
|
||||
}
|
||||
if (event.timestamp >= session.lastTimestamp) {
|
||||
session.lastTimestamp = event.timestamp
|
||||
}
|
||||
session.eventCount++
|
||||
session.totalInputTokens += event.inputTokens
|
||||
session.totalCachedInputTokens += event.cachedInputTokens
|
||||
session.totalOutputTokens += event.outputTokens
|
||||
session.totalReasoningOutputTokens += event.reasoningOutputTokens
|
||||
session.totalTokens += event.totalTokens
|
||||
session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd)
|
||||
mergeLocationBreakdown(session.locationBreakdown, event)
|
||||
mergeModelBreakdown(session.modelBreakdown, event)
|
||||
mergeLocationModelBreakdown(session.locationModelBreakdown, event)
|
||||
|
||||
const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::')
|
||||
const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event)
|
||||
if (!dailyByKey.has(dailyKey)) {
|
||||
dailyByKey.set(dailyKey, daily)
|
||||
}
|
||||
daily.eventCount++
|
||||
daily.inputTokens += event.inputTokens
|
||||
daily.cachedInputTokens += event.cachedInputTokens
|
||||
daily.outputTokens += event.outputTokens
|
||||
daily.reasoningOutputTokens += event.reasoningOutputTokens
|
||||
daily.totalTokens += event.totalTokens
|
||||
daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
||||
left.day === right.day
|
||||
? left.projectLabel.localeCompare(right.projectLabel)
|
||||
: left.day.localeCompare(right.day)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeSessions(sessionsById: Map<string, OpenCodeUsageSession>): OpenCodeUsageSession[] {
|
||||
for (const session of sessionsById.values()) {
|
||||
session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
||||
const primaryLocation = session.locationBreakdown[0] ?? null
|
||||
const primaryModel = session.modelBreakdown[0] ?? null
|
||||
session.primaryProjectLabel =
|
||||
session.locationBreakdown.length <= 1
|
||||
? (primaryLocation?.projectLabel ?? 'Unknown location')
|
||||
: 'Multiple locations'
|
||||
session.hasMixedLocations = session.locationBreakdown.length > 1
|
||||
session.primaryWorktreeId = primaryLocation?.worktreeId ?? null
|
||||
session.primaryRepoId = primaryLocation?.repoId ?? null
|
||||
session.primaryModel =
|
||||
session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models'
|
||||
session.hasMixedModels = session.modelBreakdown.length > 1
|
||||
}
|
||||
|
||||
return [...sessionsById.values()].sort((left, right) =>
|
||||
right.lastTimestamp.localeCompare(left.lastTimestamp)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeSessions(
|
||||
target: Map<string, OpenCodeUsageSession>,
|
||||
sessions: OpenCodeUsageSession[]
|
||||
): void {
|
||||
for (const session of sessions) {
|
||||
const existing = target.get(session.sessionId)
|
||||
if (!existing) {
|
||||
target.set(session.sessionId, structuredClone(session))
|
||||
continue
|
||||
}
|
||||
|
||||
existing.firstTimestamp =
|
||||
session.firstTimestamp < existing.firstTimestamp
|
||||
? session.firstTimestamp
|
||||
: existing.firstTimestamp
|
||||
existing.lastTimestamp =
|
||||
session.lastTimestamp > existing.lastTimestamp
|
||||
? session.lastTimestamp
|
||||
: existing.lastTimestamp
|
||||
existing.eventCount += session.eventCount
|
||||
existing.totalInputTokens += session.totalInputTokens
|
||||
existing.totalCachedInputTokens += session.totalCachedInputTokens
|
||||
existing.totalOutputTokens += session.totalOutputTokens
|
||||
existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens
|
||||
existing.totalTokens += session.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd)
|
||||
|
||||
for (const location of session.locationBreakdown) {
|
||||
const existingLocation =
|
||||
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
|
||||
null
|
||||
if (existingLocation) {
|
||||
existingLocation.eventCount += location.eventCount
|
||||
existingLocation.inputTokens += location.inputTokens
|
||||
existingLocation.cachedInputTokens += location.cachedInputTokens
|
||||
existingLocation.outputTokens += location.outputTokens
|
||||
existingLocation.reasoningOutputTokens += location.reasoningOutputTokens
|
||||
existingLocation.totalTokens += location.totalTokens
|
||||
existingLocation.estimatedCostUsd = addCost(
|
||||
existingLocation.estimatedCostUsd,
|
||||
location.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.locationBreakdown.push({ ...location })
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of session.modelBreakdown) {
|
||||
const existingModel =
|
||||
existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null
|
||||
if (existingModel) {
|
||||
existingModel.eventCount += model.eventCount
|
||||
existingModel.inputTokens += model.inputTokens
|
||||
existingModel.cachedInputTokens += model.cachedInputTokens
|
||||
existingModel.outputTokens += model.outputTokens
|
||||
existingModel.reasoningOutputTokens += model.reasoningOutputTokens
|
||||
existingModel.totalTokens += model.totalTokens
|
||||
existingModel.estimatedCostUsd = addCost(
|
||||
existingModel.estimatedCostUsd,
|
||||
model.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.modelBreakdown.push({ ...model })
|
||||
}
|
||||
}
|
||||
|
||||
for (const locationModel of session.locationModelBreakdown) {
|
||||
const existingLocationModel =
|
||||
existing.locationModelBreakdown.find(
|
||||
(entry) =>
|
||||
entry.locationKey === locationModel.locationKey &&
|
||||
entry.modelKey === locationModel.modelKey
|
||||
) ?? null
|
||||
if (existingLocationModel) {
|
||||
existingLocationModel.eventCount += locationModel.eventCount
|
||||
existingLocationModel.inputTokens += locationModel.inputTokens
|
||||
existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens
|
||||
existingLocationModel.outputTokens += locationModel.outputTokens
|
||||
existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens
|
||||
existingLocationModel.totalTokens += locationModel.totalTokens
|
||||
existingLocationModel.estimatedCostUsd = addCost(
|
||||
existingLocationModel.estimatedCostUsd,
|
||||
locationModel.estimatedCostUsd
|
||||
)
|
||||
} else {
|
||||
existing.locationModelBreakdown.push({ ...locationModel })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDailyAggregates(
|
||||
target: Map<string, OpenCodeUsageDailyAggregate>,
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
): 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.eventCount += aggregate.eventCount
|
||||
existing.inputTokens += aggregate.inputTokens
|
||||
existing.cachedInputTokens += aggregate.cachedInputTokens
|
||||
existing.outputTokens += aggregate.outputTokens
|
||||
existing.reasoningOutputTokens += aggregate.reasoningOutputTokens
|
||||
existing.totalTokens += aggregate.totalTokens
|
||||
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd)
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseOpenCodeUsageDatabase(
|
||||
dbPath: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
): Promise<OpenCodeUsagePersistedDatabase> {
|
||||
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
db.pragma('query_only = ON')
|
||||
const events: OpenCodeUsageAttributedEvent[] = []
|
||||
for (const row of selectUsageRows(db)) {
|
||||
const parsed = parseOpenCodeUsageRow(row)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...processedDatabase,
|
||||
...aggregateOpenCodeUsage(events)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanOpenCodeUsageDatabases(
|
||||
worktrees: OpenCodeUsageWorktreeRef[],
|
||||
previousProcessedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
): Promise<{
|
||||
processedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
}> {
|
||||
const dbPaths = await listOpenCodeDatabases()
|
||||
const previousByPath = new Map(
|
||||
previousProcessedDatabases.map((database) => [database.path, database])
|
||||
)
|
||||
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
|
||||
for (const [index, dbPath] of dbPaths.entries()) {
|
||||
const databaseInfo = await getProcessedDatabaseInfo(dbPath)
|
||||
const previous = previousByPath.get(dbPath)
|
||||
const canReuse =
|
||||
previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size
|
||||
const processed = canReuse
|
||||
? previous
|
||||
: await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths)
|
||||
|
||||
processedDatabases.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
|
||||
if ((index + 1) % YIELD_EVERY_DATABASES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedDatabases,
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
||||
left.day === right.day
|
||||
? left.projectLabel.localeCompare(right.projectLabel)
|
||||
: left.day.localeCompare(right.day)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeRefs(
|
||||
repos: Repo[],
|
||||
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
|
||||
): OpenCodeUsageWorktreeRef[] {
|
||||
const refs: OpenCodeUsageWorktreeRef[] = []
|
||||
for (const repo of repos) {
|
||||
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
|
||||
refs.push({
|
||||
repoId: repo.id,
|
||||
worktreeId: worktree.worktreeId,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsagePersistedState,
|
||||
OpenCodeUsageSession
|
||||
} from './types'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
import { OpenCodeUsageStore, normalizePersistedState } from './store'
|
||||
|
||||
function getDefaultState(): OpenCodeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
worktreeFingerprint: null,
|
||||
processedDatabases: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<OpenCodeUsagePersistedState>): OpenCodeUsageStore {
|
||||
const store = new OpenCodeUsageStore({
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
;(store as unknown as { state: OpenCodeUsagePersistedState }).state = {
|
||||
...getDefaultState(),
|
||||
...state
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<OpenCodeUsageSession> = {}): OpenCodeUsageSession {
|
||||
const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo'
|
||||
const repoId = overrides.primaryRepoId ?? 'repo-1'
|
||||
const projectLabel = overrides.primaryProjectLabel ?? 'Repo'
|
||||
const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5'
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
firstTimestamp: '2026-04-09T10:00:00.000Z',
|
||||
lastTimestamp: '2026-04-09T10:10:00.000Z',
|
||||
primaryModel: model,
|
||||
hasMixedModels: false,
|
||||
primaryProjectLabel: projectLabel,
|
||||
hasMixedLocations: false,
|
||||
primaryWorktreeId: worktreeId,
|
||||
primaryRepoId: repoId,
|
||||
eventCount: 1,
|
||||
totalInputTokens: 1000,
|
||||
totalCachedInputTokens: 400,
|
||||
totalOutputTokens: 250,
|
||||
totalReasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05,
|
||||
locationBreakdown: [
|
||||
{
|
||||
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
projectLabel,
|
||||
repoId,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
modelBreakdown: [
|
||||
{
|
||||
modelKey: model ?? 'unknown',
|
||||
modelLabel: model ?? 'Unknown model',
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
locationModelBreakdown: [
|
||||
{
|
||||
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
modelKey: model ?? 'unknown',
|
||||
modelLabel: model ?? 'Unknown model',
|
||||
repoId,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeDaily(
|
||||
overrides: Partial<OpenCodeUsageDailyAggregate> = {}
|
||||
): OpenCodeUsageDailyAggregate {
|
||||
const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo'
|
||||
return {
|
||||
day: '2026-04-09',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
||||
projectLabel: worktreeId ? 'Repo' : 'outside/repo',
|
||||
repoId: worktreeId ? 'repo-1' : null,
|
||||
worktreeId,
|
||||
eventCount: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350,
|
||||
estimatedCostUsd: 0.05,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenCodeUsageStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
makeSession({
|
||||
primaryProjectLabel: 'outside/repo',
|
||||
primaryWorktreeId: null,
|
||||
primaryRepoId: null
|
||||
})
|
||||
],
|
||||
dailyAggregates: [
|
||||
makeDaily({
|
||||
projectKey: 'cwd:/outside/repo',
|
||||
projectLabel: 'outside/repo',
|
||||
repoId: null,
|
||||
worktreeId: null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
|
||||
expect(summary.hasAnyOpenCodeData).toBe(false)
|
||||
expect(summary.sessions).toBe(0)
|
||||
expect(summary.events).toBe(0)
|
||||
})
|
||||
|
||||
it('uses recorded OpenCode costs and token totals without model pricing inference', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
makeSession({ sessionId: 'session-1' }),
|
||||
makeSession({
|
||||
sessionId: 'session-2',
|
||||
primaryModel: 'openai/gpt-5.5',
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null,
|
||||
modelBreakdown: [
|
||||
{
|
||||
modelKey: 'openai/gpt-5.5',
|
||||
modelLabel: 'openai/gpt-5.5',
|
||||
eventCount: 1,
|
||||
inputTokens: 1500,
|
||||
cachedInputTokens: 200,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
dailyAggregates: [
|
||||
makeDaily(),
|
||||
makeDaily({
|
||||
model: 'openai/gpt-5.5',
|
||||
eventCount: 2,
|
||||
inputTokens: 1500,
|
||||
cachedInputTokens: 200,
|
||||
outputTokens: 500,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 2000,
|
||||
estimatedCostUsd: null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
const daily = await store.getDaily('orca', '30d')
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
sessions: 2,
|
||||
events: 3,
|
||||
inputTokens: 2500,
|
||||
cachedInputTokens: 600,
|
||||
outputTokens: 750,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 3350,
|
||||
estimatedCostUsd: 0.05,
|
||||
topModel: 'openai/gpt-5.5',
|
||||
topProject: 'Repo',
|
||||
hasAnyOpenCodeData: true
|
||||
})
|
||||
expect(daily).toEqual([
|
||||
{
|
||||
day: '2026-04-09',
|
||||
inputTokens: 2500,
|
||||
cachedInputTokens: 600,
|
||||
outputTokens: 750,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 3350
|
||||
}
|
||||
])
|
||||
expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({
|
||||
sessions: 1,
|
||||
estimatedCostUsd: null
|
||||
})
|
||||
})
|
||||
|
||||
it('returns recent sessions with OpenCode event and token fields', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
})
|
||||
|
||||
const sessions = await store.getRecentSessions('orca', '30d', 5)
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
lastActiveAt: '2026-04-09T10:10:00.000Z',
|
||||
durationMinutes: 10,
|
||||
projectLabel: 'Repo',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
events: 1,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 400,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 100,
|
||||
totalTokens: 1350
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes persisted OpenCode state by schema version', () => {
|
||||
expect(
|
||||
normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
schemaVersion: 0,
|
||||
processedDatabases: [
|
||||
{
|
||||
path: '/tmp/opencode.db',
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
}
|
||||
],
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
})
|
||||
).toEqual(getDefaultState())
|
||||
|
||||
expect(
|
||||
normalizePersistedState({
|
||||
...getDefaultState(),
|
||||
processedDatabases: [
|
||||
{
|
||||
path: '/tmp/opencode.db',
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
}
|
||||
]
|
||||
}).processedDatabases
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,464 @@
|
||||
/* 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,
|
||||
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, UsageWorktreeRef[]>): 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<void> | 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<OpenCodeUsageScanState> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(force = false): Promise<OpenCodeUsageScanState> {
|
||||
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<void> {
|
||||
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<OpenCodeUsageSummary> {
|
||||
await this.refresh(false)
|
||||
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 {
|
||||
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<OpenCodeUsageDailyPoint[]> {
|
||||
await this.refresh(false)
|
||||
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))
|
||||
}
|
||||
|
||||
async getBreakdown(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange,
|
||||
kind: OpenCodeUsageBreakdownKind
|
||||
): Promise<OpenCodeUsageBreakdownRow[]> {
|
||||
await this.refresh(false)
|
||||
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)
|
||||
}
|
||||
|
||||
async getRecentSessions(
|
||||
scope: OpenCodeUsageScope,
|
||||
range: OpenCodeUsageRange,
|
||||
limit = 10
|
||||
): Promise<OpenCodeUsageSessionRow[]> {
|
||||
await this.refresh(false)
|
||||
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<string> {
|
||||
const repos = this.store.getRepos()
|
||||
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export type OpenCodeUsageProcessedDatabase = {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageLocationBreakdown = {
|
||||
locationKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsageModelBreakdown = {
|
||||
modelKey: string
|
||||
modelLabel: string
|
||||
estimatedCostUsd: number | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageLocationModelBreakdown = {
|
||||
locationKey: string
|
||||
modelKey: string
|
||||
modelLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsageSession = {
|
||||
sessionId: string
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
primaryModel: string | null
|
||||
hasMixedModels: boolean
|
||||
primaryProjectLabel: string
|
||||
hasMixedLocations: boolean
|
||||
primaryWorktreeId: string | null
|
||||
primaryRepoId: string | null
|
||||
eventCount: number
|
||||
totalInputTokens: number
|
||||
totalCachedInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalReasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
locationBreakdown: OpenCodeUsageLocationBreakdown[]
|
||||
modelBreakdown: OpenCodeUsageModelBreakdown[]
|
||||
locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[]
|
||||
}
|
||||
|
||||
export type OpenCodeUsageDailyAggregate = {
|
||||
day: string
|
||||
model: string | null
|
||||
projectKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
eventCount: number
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
estimatedCostUsd: number | null
|
||||
}
|
||||
|
||||
export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & {
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
}
|
||||
|
||||
export type OpenCodeUsagePersistedState = {
|
||||
schemaVersion: number
|
||||
worktreeFingerprint: string | null
|
||||
processedDatabases: OpenCodeUsagePersistedDatabase[]
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
scanState: {
|
||||
enabled: boolean
|
||||
lastScanStartedAt: number | null
|
||||
lastScanCompletedAt: number | null
|
||||
lastScanError: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodeUsageParsedEvent = {
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
estimatedCostUsd: number | null
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & {
|
||||
day: string
|
||||
projectKey: string
|
||||
projectLabel: string
|
||||
repoId: string | null
|
||||
worktreeId: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user