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, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall, } from './types.js' type MessageRow = { id: string time_created: number data: Uint8Array | string } type PartRow = { message_id: string data: Uint8Array | string } type SessionRow = { id: string directory: Uint8Array | string title: Uint8Array | 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 normalizeToolName(rawTool?: string): string { if (!rawTool) return '' if (rawTool.startsWith('mcp__')) return rawTool const builtIn = toolNameMap[rawTool] if (builtIn) return builtIn // OpenCode stores MCP calls as `_` with no separate server field. // Built-ins are handled above, and server ids are assumed not to contain `_`. const serverSeparator = rawTool.indexOf('_') if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) { const server = rawTool.slice(0, serverSeparator) const tool = rawTool.slice(serverSeparator + 1) return `mcp__${server}__${tool}` } return rawTool } 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() } type SchemaCheckResult = | { ok: true } | { ok: false; missing: string[] } /// Inspects OpenCode's SQLite schema. Returns the list of expected tables that /// are missing rather than just a boolean so the caller can produce an actionable /// warning ("missing 'part' table") instead of a generic "format not recognized". /// Only emits the warning when meaningful tables are absent — a brand-new /// OpenCode install with an empty DB but valid schema does NOT trigger it. function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult { const required = ['session', 'message', 'part'] const missing: string[] = [] for (const table of required) { try { db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`) } catch (err) { if (isSqliteBusyError(err)) throw err missing.push(table) } } return missing.length === 0 ? { ok: true } : { ok: false, missing } } function validateSchema(db: SqliteDatabase): boolean { return validateSchemaDetailed(db).ok } const warnedOpenCodeSchemas = new Set() function warnUnrecognizedOpenCodeSchemaOnce(missing: string[]): void { const key = missing.slice().sort().join(',') if (warnedOpenCodeSchemas.has(key)) return warnedOpenCodeSchemas.add(key) process.stderr.write( `codeburn: OpenCode database is missing expected tables (${missing.join(', ')}). ` + `Run OpenCode once to apply migrations, or report at https://github.com/getagentseal/codeburn/issues if this persists on a current OpenCode install.\n` ) } 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 { const schema = validateSchemaDetailed(db) if (!schema.ok) { // Warn at most once per process per missing-table set so a directory // with a half-migrated OpenCode DB doesn't spam stderr on every // session iteration. Show which tables we couldn't find so the // user (or a triage agent) knows whether to re-run OpenCode's // migration or report a CodeBurn schema gap. warnUnrecognizedOpenCodeSchemaOnce(schema.missing) return } const messages = db.query( 'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC', [sessionId], ) const parts = db.query( 'SELECT message_id, CAST(data AS BLOB) AS 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(blobToText(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(blobToText(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) => normalizeToolName(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, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', ) return rows.map((row) => { const dir = blobToText(row.directory) const title = blobToText(row.title) return { path: `${dbPath}:${row.id}`, project: dir ? sanitize(dir) : sanitize(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()