import fsSync from 'node:fs'; import Database from 'better-sqlite3'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, getOpenCodeDatabasePath, normalizeProviderTimestamp, readObjectRecord, readJsonRecord, readOptionalString, } from '@/shared/utils.js'; const PROVIDER = 'opencode'; type OpenCodeHistoryRow = { message_id: string; message_time_created: number | null; message_data: string | null; part_id: string | null; part_time_created: number | null; part_data: string | null; }; type OpenCodeTokenTotals = { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; reasoningTokens: number; }; const openOpenCodeDatabase = (): Database.Database | null => { const dbPath = getOpenCodeDatabasePath(); if (!fsSync.existsSync(dbPath)) { return null; } return new Database(dbPath, { readonly: true, fileMustExist: true }); }; const formatToolContent = (value: unknown): string => { if (value === undefined || value === null) { return ''; } if (typeof value === 'string') { return value; } try { return JSON.stringify(value, null, 2); } catch { return String(value); } }; /** * OpenCode can persist the first prompt as a JSON string literal inside a text * part, for example `"hello"` instead of `hello`. Decode only complete JSON * string literals so normal assistant/user prose remains untouched. */ const unwrapJsonStringLiteral = (value: string): string => { const trimmed = value.trim(); if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) { return value; } try { const parsed = JSON.parse(trimmed); return typeof parsed === 'string' ? parsed : value; } catch { return value; } }; const extractText = (value: unknown): string => { if (typeof value === 'string') { return unwrapJsonStringLiteral(value); } const record = readObjectRecord(value); const text = readOptionalString(record?.text) ?? readOptionalString(record?.content) ?? ''; return unwrapJsonStringLiteral(text); }; const hasUserRole = (value: unknown): boolean => { const record = readObjectRecord(value); return readOptionalString(record?.role) === 'user'; }; const isUserTextEcho = (raw: AnyRecord): boolean => { return readOptionalString(raw.role) === 'user' || hasUserRole(raw.message) || hasUserRole(raw.part); }; const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => { if (!totals) { return undefined; } const inputTokens = totals.inputTokens; const outputTokens = totals.outputTokens; const cacheReadTokens = totals.cacheReadTokens; const cacheCreationTokens = totals.cacheCreationTokens; const reasoningTokens = totals.reasoningTokens; const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens; if (used <= 0) { return undefined; } return { used, total: used, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, }; }; /** * OpenCode stores per-message token counts on assistant `message.data` objects * (see MessageV2.Assistant). Older DBs also had session-level counters; this * matches current `opencode.db` layouts that only persist message JSON. */ const aggregateOpenCodeSessionTokenUsage = ( db: Database.Database, sessionId: string, ): AnyRecord | undefined => { const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[]; let inputTokens = 0; let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; let reasoningTokens = 0; for (const row of rows) { const info = readJsonRecord(row.data); if (readOptionalString(info?.role) !== 'assistant') { continue; } const tokens = readObjectRecord(info?.tokens); if (!tokens) { continue; } inputTokens += Number(tokens.input ?? 0); outputTokens += Number(tokens.output ?? 0); reasoningTokens += Number(tokens.reasoning ?? 0); const cache = readObjectRecord(tokens.cache); cacheReadTokens += Number(cache?.read ?? 0); cacheCreationTokens += Number(cache?.write ?? 0); } return buildTokenUsage({ inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, reasoningTokens, }); }; export class OpenCodeSessionsProvider implements IProviderSessions { /** * Normalizes live `opencode run --format json` events into frontend messages. */ normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { const raw = readObjectRecord(rawMessage); if (!raw) { return []; } const type = readOptionalString(raw.type) ?? readOptionalString(raw.event); const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId; const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp); const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageID) ?? generateMessageId('opencode'); if (type === 'text') { // The client already renders an optimistic user bubble, so provider user // echoes must not be streamed back as assistant text. if (isUserTextEcho(raw)) { return []; } const content = extractText(raw.text ?? raw.delta ?? raw.message); if (!content.trim()) { return []; } return [createNormalizedMessage({ id: baseId, sessionId: eventSessionId, timestamp, provider: PROVIDER, kind: 'stream_delta', content, })]; } if (type === 'reasoning') { const content = extractText(raw.text ?? raw.delta ?? raw.message); if (!content.trim()) { return []; } return [createNormalizedMessage({ id: baseId, sessionId: eventSessionId, timestamp, provider: PROVIDER, kind: 'thinking', content, })]; } if (type === 'tool_use') { const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool'; const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId; const toolMessage = createNormalizedMessage({ id: baseId, sessionId: eventSessionId, timestamp, provider: PROVIDER, kind: 'tool_use', toolName, toolInput: raw.input ?? raw.arguments ?? {}, toolId, }); if (raw.output !== undefined || raw.error !== undefined) { toolMessage.toolResult = { content: formatToolContent(raw.output ?? raw.error), isError: raw.error !== undefined, }; } return [toolMessage]; } if (type === 'error') { return [createNormalizedMessage({ id: baseId, sessionId: eventSessionId, timestamp, provider: PROVIDER, kind: 'error', content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error', })]; } if (type === 'step_finish') { return [createNormalizedMessage({ id: baseId, sessionId: eventSessionId, timestamp, provider: PROVIDER, kind: 'stream_end', })]; } return []; } /** * Loads OpenCode history from the shared SQLite session database. */ async fetchHistory( sessionId: string, options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; const db = openOpenCodeDatabase(); if (!db) { return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; } try { const rows = db.prepare(` SELECT m.id AS message_id, m.time_created AS message_time_created, m.data AS message_data, p.id AS part_id, p.time_created AS part_time_created, p.data AS part_data FROM message m LEFT JOIN part p ON p.session_id = m.session_id AND p.message_id = m.id WHERE m.session_id = ? ORDER BY COALESCE(m.time_created, 0), m.id, COALESCE(p.time_created, 0), p.id `).all(sessionId) as OpenCodeHistoryRow[]; const normalized = this.normalizeHistoryRows(rows, sessionId); const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId); const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); const total = normalized.length; const messages = normalizedLimit === null ? normalized : normalized.slice( Math.max(0, total - normalizedOffset - normalizedLimit), Math.max(0, total - normalizedOffset), ); return { messages, total, hasMore: normalizedLimit === null ? false : Math.max(0, total - normalizedOffset - normalizedLimit) > 0, offset: normalizedOffset, limit: normalizedLimit, tokenUsage, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message); return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; } finally { db.close(); } } private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] { const normalized: NormalizedMessage[] = []; const emittedMessageErrors = new Set(); for (const row of rows) { const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created); const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`; const messageInfo = readJsonRecord(row.message_data); const messageRole = readOptionalString(messageInfo?.role); if ( messageInfo && messageRole === 'assistant' && messageInfo.error != null && !emittedMessageErrors.has(row.message_id) ) { emittedMessageErrors.add(row.message_id); normalized.push(createNormalizedMessage({ id: `${baseId}_error`, sessionId, timestamp, provider: PROVIDER, kind: 'error', content: formatToolContent(messageInfo.error), })); } if (!row.part_id) { continue; } const partData = readJsonRecord(row.part_data) ?? {}; const partType = readOptionalString(partData.type); if (!partType) { continue; } if (partType === 'text') { const content = extractText(partData); if (content.trim()) { normalized.push(createNormalizedMessage({ id: baseId, sessionId, timestamp, provider: PROVIDER, kind: 'text', role: messageRole === 'user' ? 'user' : 'assistant', content, })); } continue; } if (partType === 'reasoning') { const content = extractText(partData); if (content.trim()) { normalized.push(createNormalizedMessage({ id: baseId, sessionId, timestamp, provider: PROVIDER, kind: 'thinking', content, })); } continue; } if (partType === 'tool') { const state = readObjectRecord(partData.state) ?? {}; const status = readOptionalString(state.status); const toolMessage = createNormalizedMessage({ id: baseId, sessionId, timestamp, provider: PROVIDER, kind: 'tool_use', toolName: readOptionalString(partData.tool) ?? 'Tool', toolInput: state.input ?? partData.input ?? {}, toolId: readOptionalString(partData.callID) ?? row.part_id, }); if (status === 'completed' || status === 'error') { toolMessage.toolResult = { content: formatToolContent(state.output ?? state.error), isError: status === 'error', }; } normalized.push(toolMessage); continue; } if (partType === 'step-finish') { normalized.push(createNormalizedMessage({ id: baseId, sessionId, timestamp, provider: PROVIDER, kind: 'stream_end', })); continue; } if (partType === 'patch' || partType === 'agent') { normalized.push(createNormalizedMessage({ id: baseId, sessionId, timestamp, provider: PROVIDER, kind: 'tool_use', toolName: partType === 'patch' ? 'Patch' : 'Agent', toolInput: partData, toolId: row.part_id, })); } } return normalized; } }