diff --git a/package.json b/package.json index 87c015f..258ff7f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "claude-code", "cursor", "codex", + "opencode", "ai-coding", "token-usage", "cost-tracking", diff --git a/src/currency.ts b/src/currency.ts index 41bd34f..53bdb1f 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -40,7 +40,7 @@ function getFractionDigits(code: string): number { return new Intl.NumberFormat('en', { style: 'currency', currency: code, - }).resolvedOptions().maximumFractionDigits + }).resolvedOptions().maximumFractionDigits ?? 2 } function getCacheDir(): string { diff --git a/src/parser.ts b/src/parser.ts index 0094829..4055fea 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -367,7 +367,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { hasPlanMode: tools.includes('EnterPlanMode'), speed: call.speed, timestamp: call.timestamp, - bashCommands: [], + bashCommands: call.bashCommands, deduplicationKey: call.deduplicationKey, } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 4ac0414..9f5d592 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -259,6 +259,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars webSearchRequests: 0, costUSD, tools: pendingTools, + bashCommands: [], timestamp, speed: 'standard', deduplicationKey: dedupKey, diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index a14e102..839538a 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -188,6 +188,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse webSearchRequests: 0, costUSD, tools: cursorTools, + bashCommands: [], timestamp, speed: 'standard', deduplicationKey: dedupKey, diff --git a/src/providers/index.ts b/src/providers/index.ts index ada07f2..cee5d61 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -17,11 +17,29 @@ async function loadCursor(): Promise { } } +let opencodeProvider: Provider | null = null +let opencodeLoadAttempted = false + +async function loadOpenCode(): Promise { + if (opencodeLoadAttempted) return opencodeProvider + opencodeLoadAttempted = true + try { + const { opencode } = await import('./opencode.js') + opencodeProvider = opencode + return opencode + } catch { + return null + } +} + const coreProviders: Provider[] = [claude, codex] export async function getAllProviders(): Promise { - const cursor = await loadCursor() - return cursor ? [...coreProviders, cursor] : [...coreProviders] + const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()]) + const all = [...coreProviders] + if (cursor) all.push(cursor) + if (opencode) all.push(opencode) + return all } export const providers = coreProviders @@ -44,6 +62,10 @@ export async function getProvider(name: string): Promise { const cursor = await loadCursor() return cursor ?? undefined } + if (name === 'opencode') { + const oc = await loadOpenCode() + return oc ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts new file mode 100644 index 0000000..9dd32ff --- /dev/null +++ b/src/providers/opencode.ts @@ -0,0 +1,320 @@ +import { readdir } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +type MessageRow = { + id: string + time_created: number + data: string +} + +type PartRow = { + message_id: string + data: string +} + +type SessionRow = { + id: string + directory: string + title: string + time_created: number +} + +type MessageData = { + role: string + modelID?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } +} + +type PartData = { + type: string + text?: string + tool?: string + state?: { input?: { command?: string } } +} + +const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +function getDataDir(dataDir?: string): string { + const base = + dataDir ?? + process.env['XDG_DATA_HOME'] ?? + join(homedir(), '.local', 'share') + return join(base, 'opencode') +} + +async function findDbFiles(dir: string): Promise { + try { + const entries = await readdir(dir) + return entries + .filter((f) => f.startsWith('opencode') && f.endsWith('.db')) + .map((f) => join(dir, f)) + } catch { + return [] + } +} + +function parseTimestamp(raw: number): string { + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM session LIMIT 1" + ) + db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM message LIMIT 1" + ) + return true + } catch { + return false + } +} + +function createParser( + source: SessionSource, + seenKeys: Set, +): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + // Path is encoded as `${dbPath}:${sessionId}`. Session IDs are UUIDs + // (no colons), so the last segment after splitting on ':' is always + // the session ID. Rejoining handles Windows drive letters (C:\...). + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open OpenCode database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) { + process.stderr.write('codeburn: OpenCode storage format not recognized. You may need to update CodeBurn.\n') + return + } + + const messages = db.query( + 'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC', + [sessionId], + ) + + const parts = db.query( + 'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id', + [sessionId], + ) + + const partsByMsg = new Map() + for (const part of parts) { + try { + const parsed = JSON.parse(part.data) as PartData + const list = partsByMsg.get(part.message_id) ?? [] + list.push(parsed) + partsByMsg.set(part.message_id, list) + } catch { + // skip corrupt part data + } + } + + let currentUserMessage = '' + + for (const msg of messages) { + let data: MessageData + try { + data = JSON.parse(msg.data) as MessageData + } catch { + continue + } + + if (data.role === 'user') { + const textParts = (partsByMsg.get(msg.id) ?? []) + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + if (textParts.length > 0) { + currentUserMessage = textParts.join(' ') + } + continue + } + + if (data.role !== 'assistant') continue + + const tokens = { + input: data.tokens?.input ?? 0, + output: data.tokens?.output ?? 0, + reasoning: data.tokens?.reasoning ?? 0, + cacheRead: data.tokens?.cache?.read ?? 0, + cacheWrite: data.tokens?.cache?.write ?? 0, + } + + const allZero = + tokens.input === 0 && + tokens.output === 0 && + tokens.reasoning === 0 && + tokens.cacheRead === 0 && + tokens.cacheWrite === 0 + if (allZero && (data.cost ?? 0) === 0) continue + + const msgParts = partsByMsg.get(msg.id) ?? [] + const toolParts = msgParts.filter((p) => p.type === 'tool') + const tools = toolParts + .map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '') + .filter(Boolean) + + const bashCommands = toolParts + .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') + .flatMap((p) => extractBashCommands(p.state!.input!.command!)) + + const dedupKey = `opencode:${sessionId}:${msg.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = data.modelID ?? 'unknown' + let costUSD = calculateCost( + model, + tokens.input, + tokens.output + tokens.reasoning, + tokens.cacheWrite, + tokens.cacheRead, + 0, + ) + + if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) { + costUSD = data.cost + } + + yield { + provider: 'opencode', + model, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheCreationInputTokens: tokens.cacheWrite, + cacheReadInputTokens: tokens.cacheRead, + cachedInputTokens: tokens.cacheRead, + reasoningTokens: tokens.reasoning, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: parseTimestamp(msg.time_created), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: currentUserMessage, + sessionId, + } + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + const rows = db.query( + 'SELECT id, directory, title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', + ) + + return rows.map((row) => ({ + path: `${dbPath}:${row.id}`, + project: row.directory ? sanitize(row.directory) : sanitize(row.title), + provider: 'opencode', + })) + } catch { + return [] + } finally { + db.close() + } +} + +export function createOpenCodeProvider(dataDir?: string): Provider { + const dir = getDataDir(dataDir) + + return { + name: 'opencode', + displayName: 'OpenCode', + + modelDisplayName(model: string): string { + const stripped = model.replace(/^[^/]+\//, '') + return getShortModelName(stripped) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + + const dbPaths = await findDbFiles(dir) + if (dbPaths.length === 0) return [] + + const sessions: SessionSource[] = [] + for (const dbPath of dbPaths) { + sessions.push(...await discoverFromDb(dbPath)) + } + return sessions + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const opencode = createOpenCodeProvider() diff --git a/src/providers/types.ts b/src/providers/types.ts index f63279a..3ab967a 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -20,6 +20,7 @@ export type ParsedProviderCall = { webSearchRequests: number costUSD: number tools: string[] + bashCommands: string[] timestamp: string speed: 'standard' | 'fast' deduplicationKey: string diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index fcb0a51..9e71eae 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -6,7 +6,7 @@ describe('provider registry', () => { expect(providers.map(p => p.name)).toEqual(['claude', 'codex']) }) - it('includes cursor after async load', async () => { + it('includes sqlite providers after async load', async () => { const all = await getAllProviders() const names = all.map(p => p.name) expect(names).toContain('claude') @@ -14,6 +14,24 @@ describe('provider registry', () => { expect(names.length).toBeGreaterThanOrEqual(2) }) + it('opencode model display names strip provider prefix', async () => { + const all = await getAllProviders() + const oc = all.find(p => p.name === 'opencode') + if (!oc) return + expect(oc.modelDisplayName('anthropic/claude-opus-4-6-20260205')).toBe('Opus 4.6') + expect(oc.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro') + }) + + it('opencode tool display names normalize builtins', async () => { + const all = await getAllProviders() + const oc = all.find(p => p.name === 'opencode') + if (!oc) return + expect(oc.toolDisplayName('bash')).toBe('Bash') + expect(oc.toolDisplayName('edit')).toBe('Edit') + expect(oc.toolDisplayName('task')).toBe('Agent') + expect(oc.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + it('claude tool display names are identity', () => { const claude = providers.find(p => p.name === 'claude')! expect(claude.toolDisplayName('Bash')).toBe('Bash') diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts new file mode 100644 index 0000000..b1c75b3 --- /dev/null +++ b/tests/providers/opencode.test.ts @@ -0,0 +1,558 @@ +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createOpenCodeProvider } from '../../src/providers/opencode.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'opencode-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createTestDb(dir: string): string { + const ocDir = join(dir, 'opencode') + mkdirSync(ocDir, { recursive: true }) + const dbPath = join(ocDir, 'opencode.db') + + const Database = require('better-sqlite3') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, + time_archived INTEGER + ) + `) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.exec(` + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, + time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const Database = require('better-sqlite3') + const db = new Database(dbPath) + fn(db) + db.close() +} + +function insertSession( + db: TestDb, + id: string, + opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null } = {}, +): void { + db.prepare(` + INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', 1700000000000, opts.archived ?? null, opts.parentId ?? null) +} + +type MessageFixture = { + role: string + modelID?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } +} + +type PartFixture = { + type: string + text?: string + tool?: string + state?: { status: string; input: { command?: string } } +} + +function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void { + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run(id, sessionId, timeCreated, JSON.stringify(data)) +} + +function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void { + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run(id, messageId, sessionId, JSON.stringify(data)) +} + +async function collectCalls(provider: ReturnType, dbPath: string, sessionId: string, seenKeys?: Set): Promise { + const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('opencode provider - model display names', () => { + it('strips provider prefix and delegates to shared lookup', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6') + }) + + it('strips google provider prefix', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro') + }) + + it('strips openai provider prefix', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('openai/gpt-4o')).toBe('GPT-4o') + }) + + it('passes through models without prefix unchanged', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('gpt-4o')).toBe('GPT-4o') + expect(provider.modelDisplayName('gpt-4o-mini')).toBe('GPT-4o Mini') + }) + + it('returns unknown models as-is', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('big-pickle')).toBe('big-pickle') + }) + + it('has correct displayName', () => { + const provider = createOpenCodeProvider() + expect(provider.displayName).toBe('OpenCode') + expect(provider.name).toBe('opencode') + }) +}) + +skipUnlessSqlite('opencode provider - tool display names', () => { + it('maps opencode builtins', () => { + const provider = createOpenCodeProvider() + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('edit')).toBe('Edit') + expect(provider.toolDisplayName('task')).toBe('Agent') + expect(provider.toolDisplayName('fetch')).toBe('WebFetch') + expect(provider.toolDisplayName('grep')).toBe('Grep') + expect(provider.toolDisplayName('write')).toBe('Write') + expect(provider.toolDisplayName('skill')).toBe('Skill') + }) + + it('returns unknown tools as-is', () => { + const provider = createOpenCodeProvider() + expect(provider.toolDisplayName('github_search_code')).toBe('github_search_code') + }) +}) + +skipUnlessSqlite('opencode provider - session discovery', () => { + it('discovers sessions with correct path format', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('opencode') + expect(sessions[0]!.project).toBe('home-user-myproject') + expect(sessions[0]!.path).toBe(`${dbPath}:sess-1`) + }) + + it('excludes archived sessions', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-archived', { archived: 1700000001000 }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('excludes child sessions', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-child', { parentId: 'parent-id' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns empty for non-existent path', async () => { + const provider = createOpenCodeProvider('/nonexistent/path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('returns empty for empty database', async () => { + createTestDb(tmpDir) + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('discovers sessions across multiple channel databases', async () => { + const ocDir = join(tmpDir, 'opencode') + await mkdir(ocDir, { recursive: true }) + + const Database = require('better-sqlite3') + for (const file of ['opencode.db', 'opencode-dev.db']) { + const dbPath = join(ocDir, file) + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER) + `) + db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`) + db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`) + db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`sess-${file}`, 'proj-1', 'slug-1', '/home/user/myproject', 'My Project', '1.0', 1700000000000) + db.close() + } + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.path)).toEqual( + expect.arrayContaining([ + expect.stringContaining('opencode.db:sess-opencode.db'), + expect.stringContaining('opencode-dev.db:sess-opencode-dev.db'), + ]), + ) + expect(sessions.every(s => s.provider === 'opencode')).toBe(true) + }) + + it('ignores non-opencode db files in the directory', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + }) + await writeFile(join(tmpDir, 'opencode', 'other.db'), '') + await writeFile(join(tmpDir, 'opencode', 'opencode.txt'), '') + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + }) + + it('sanitizes title when directory is empty', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions[0]!.project).toBe('My Session Title') + }) + + it('discovers multiple sessions in one database', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' }) + insertSession(db, 'sess-2', { directory: '/home/user/project-b', title: 'B' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(2) + }) +}) + +skipUnlessSqlite('opencode provider - session parsing', () => { + it('parses assistant messages with all fields', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fix the login bug' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { + type: 'tool', tool: 'bash', + state: { status: 'completed', input: { command: 'npm test && git push' } }, + }) + insertPart(db, 'part-3', 'msg-2', 'sess-1', { + type: 'tool', tool: 'edit', state: { status: 'completed', input: {} }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const calls = await collectCalls(provider, dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('opencode') + expect(call.model).toBe('claude-opus-4-6') + expect(call.inputTokens).toBe(100) + expect(call.outputTokens).toBe(200) + expect(call.reasoningTokens).toBe(50) + expect(call.cacheReadInputTokens).toBe(500) + expect(call.cacheCreationInputTokens).toBe(300) + expect(call.cachedInputTokens).toBe(500) + expect(call.webSearchRequests).toBe(0) + expect(call.speed).toBe('standard') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.tools).toEqual(['Bash', 'Edit']) + expect(call.bashCommands).toEqual(['npm', 'git']) + expect(call.userMessage).toBe('fix the login bug') + expect(call.sessionId).toBe('sess-1') + expect(call.timestamp).toBe(new Date(1700000001000).toISOString()) + expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2') + }) + + it('skips zero-token messages with zero cost', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) + + it('deduplicates messages across parses', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const seenKeys = new Set() + const calls1 = await collectCalls(provider, dbPath, 'sess-1', seenKeys) + const calls2 = await collectCalls(provider, dbPath, 'sess-1', seenKeys) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + expect(seenKeys.has('opencode:sess-1:msg-1')).toBe(true) + }) + + it('falls back to pre-calculated cost for unknown models', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0.42) + }) + + it('uses calculated cost over pre-calculated for known models', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 999.99, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.costUSD).not.toBe(999.99) + }) + + it('handles missing tokens field gracefully', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.10, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(0) + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.costUSD).toBe(0.10) + }) + + it('uses "unknown" for missing modelID', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('unknown') + }) + + it('handles corrupt JSON in message and part data', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run('msg-corrupt', 'sess-1', 1700000000500, 'not valid json {]') + + insertMessage(db, 'msg-valid', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run('part-corrupt', 'msg-valid', 'sess-1', 'corrupt {[}') + + insertPart(db, 'part-valid', 'msg-valid', 'sess-1', { + type: 'tool', tool: 'bash', state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-opus-4-6') + expect(calls[0]!.tools).toEqual(['Bash']) + }) + + it('converts seconds-epoch timestamps to milliseconds', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(1700000001 * 1000).toISOString()) + }) + + it('skips non-user non-assistant roles', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'system', modelID: 'claude-opus-4-6', + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) + + it('returns empty for invalid db path', async () => { + const provider = createOpenCodeProvider(tmpDir) + const source = { path: '/nonexistent/db.db:sess-1', project: 'test', provider: 'opencode' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('tracks user messages per assistant response', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'first question' }) + + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01, + tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + insertMessage(db, 'msg-u2', 'sess-1', 1700000002000, { role: 'user' }) + insertPart(db, 'part-u2', 'msg-u2', 'sess-1', { type: 'text', text: 'second question' }) + + insertMessage(db, 'msg-a2', 'sess-1', 1700000003000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.02, + tokens: { input: 80, output: 80, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[1]!.userMessage).toBe('second question') + }) + + it('joins multiple text parts in user messages', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-a', 'msg-u1', 'sess-1', { type: 'text', text: 'hello' }) + insertPart(db, 'part-b', 'msg-u1', 'sess-1', { type: 'text', text: 'world' }) + + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01, + tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls[0]!.userMessage).toBe('hello world') + }) + + it('yields nothing for session with only user messages', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'hello?' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) +})