diff --git a/src/core/parser-opencode.test.ts b/src/core/parser-opencode.test.ts index e19dabc..2d08870 100644 --- a/src/core/parser-opencode.test.ts +++ b/src/core/parser-opencode.test.ts @@ -13,6 +13,40 @@ import * as path from 'path'; import { describe, it, expect } from 'vitest'; import { parseOpenCodeSessions } from './parser-opencode'; +type NodeSqliteModule = typeof import('node:sqlite'); + +function loadNodeSqliteForTest(): NodeSqliteModule | null { + interface SqliteCapable { getBuiltinModule?: (id: string) => unknown } + return ((process as SqliteCapable).getBuiltinModule?.('node:sqlite') as NodeSqliteModule | undefined) ?? null; +} + +/** Seeds a minimal opencode.db with one session: user message → assistant reply with a text part. */ +function seedOpenCodeDb(sqlite: NodeSqliteModule, dbPath: string): void { + const db = new sqlite.DatabaseSync(dbPath); + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, slug TEXT, directory TEXT, title TEXT, + time_created INTEGER, time_updated INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT); + `); + db.prepare('INSERT INTO session VALUES (?,?,?,?,?,?)') + .run('db-sess', 'slug', '/Users/me/proj', 'title', 1700000000000, 1700000005000); + const insertMsg = db.prepare('INSERT INTO message VALUES (?,?,?,?)'); + insertMsg.run('u1', 'db-sess', 1700000000000, JSON.stringify({ + role: 'user', time: { created: 1700000000000 }, summary: { title: 'hi' }, + })); + insertMsg.run('a1', 'db-sess', 1700000001000, JSON.stringify({ + role: 'assistant', parentID: 'u1', + time: { created: 1700000001000, completed: 1700000002000 }, + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + tokens: { input: 1000, output: 50 }, + })); + db.prepare('INSERT INTO part VALUES (?,?,?,?)').run('p1', 'a1', 'db-sess', JSON.stringify({ + type: 'text', text: 'hello back', + })); + db.close(); +} + function withStorage( rawSession: object, messages: object[], @@ -116,4 +150,29 @@ describe('parseOpenCodeSessions', () => { ); fs.rmSync(dir, { recursive: true, force: true }); }); + + it('parses sessions from the SQLite opencode.db layout', () => { + // Newer OpenCode migrates JSON storage into ~/.local/share/opencode/opencode.db. + // The db keeps ids in columns and the rest of the payload as JSON in `data`. + const sqlite = loadNodeSqliteForTest(); + if (!sqlite) return; // node:sqlite unavailable on this runtime — nothing to test + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-sqlite-test-')); + const storageDir = path.join(root, 'storage'); + fs.mkdirSync(storageDir, { recursive: true }); + seedOpenCodeDb(sqlite, path.join(root, 'opencode.db')); + + try { + const sessions = parseOpenCodeSessions(storageDir); + expect(sessions).toHaveLength(1); + expect(sessions[0].requests).toHaveLength(1); + expect(sessions[0].requests[0].promptTokens).toBe(1000); + expect(sessions[0].requests[0].completionTokens).toBe(50); + // Nested model payload is surfaced as the flat model id + expect(sessions[0].requests[0].modelId).toBe('claude-sonnet-4'); + expect(sessions[0].workspaceRootPath).toBe('/Users/me/proj'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/core/parser-opencode.ts b/src/core/parser-opencode.ts index 0705c37..8fe074c 100644 --- a/src/core/parser-opencode.ts +++ b/src/core/parser-opencode.ts @@ -5,11 +5,19 @@ /* OpenCode session parser * - * Data layout (macOS): + * Legacy data layout (JSON files, macOS/Linux): * ~/.local/share/opencode/storage/session/global/.json -- session metadata * ~/.local/share/opencode/storage/message//.json -- message metadata * ~/.local/share/opencode/storage/part//.json -- content parts (text, tool, step-start/finish) * + * Current data layout (newer OpenCode versions migrate the JSON files into SQLite): + * ~/.local/share/opencode/opencode.db + * session(id, slug, directory, title, time_created, time_updated, ...) -- plain columns + * message(id, session_id, data) -- data = OcMessage JSON without id/sessionID (ids live in columns) + * part(id, message_id, session_id, data) -- data = OcPart JSON without ids + * Read via the node:sqlite builtin (Node >= 22.5). When unavailable (e.g. an + * older extension host) the SQLite source is skipped gracefully. + * * Sessions have: id, slug, version, projectID, directory, title, time.created/updated * Messages have: id, sessionID, role (user|assistant), time, agent, model {providerID, modelID}, tokens, cost * Parts have: id, sessionID, messageID, type (text|tool|step-start|step-finish), text, tool, callID, state, tokens, cost @@ -81,9 +89,11 @@ export function findOpenCodeDirs(): string[] { const home = process.env.HOME || process.env.USERPROFILE || ''; const dirs: string[] = []; - // macOS / Linux - const linuxPath = path.join(home, '.local', 'share', 'opencode', 'storage'); - if (fs.existsSync(linuxPath)) dirs.push(linuxPath); + // macOS / Linux — the storage dir may be gone after the SQLite migration, + // so the presence of opencode.db alone also qualifies the install. + const base = path.join(home, '.local', 'share', 'opencode'); + const storagePath = path.join(base, 'storage'); + if (fs.existsSync(storagePath) || fs.existsSync(path.join(base, 'opencode.db'))) dirs.push(storagePath); return dirs; } @@ -261,15 +271,9 @@ function buildOpenCodeRequest( }); } -function parseOpenCodeSession(rawSession: OcSession, storageDir: string): Session | null { - if (!rawSession.id) return null; - - const msgDir = path.join(storageDir, 'message', rawSession.id); - const rawMessages = readAllJsonInDir(msgDir); - rawMessages.sort((a, b) => (a.time?.created || 0) - (b.time?.created || 0)); +function assembleOpenCodeSession(rawSession: OcSession, rawMessages: OcMessage[], partsByMsg: Map): Session | null { if (rawMessages.length === 0) return null; - const partsByMsg = indexPartsByMessage(rawMessages, storageDir); const { wsId, wsName } = getOpenCodeWorkspace(rawSession); const requests: SessionRequest[] = []; let firstTs: number | null = null; @@ -304,6 +308,140 @@ function parseOpenCodeSession(rawSession: OcSession, storageDir: string): Sessio }); } +function parseOpenCodeSession(rawSession: OcSession, storageDir: string): Session | null { + if (!rawSession.id) return null; + + const msgDir = path.join(storageDir, 'message', rawSession.id); + const rawMessages = readAllJsonInDir(msgDir); + rawMessages.sort((a, b) => (a.time?.created || 0) - (b.time?.created || 0)); + if (rawMessages.length === 0) return null; + + const partsByMsg = indexPartsByMessage(rawMessages, storageDir); + return assembleOpenCodeSession(rawSession, rawMessages, partsByMsg); +} + +type NodeSqlite = typeof import('node:sqlite'); + +interface SqliteSessionRow { + id: string; + slug: string | null; + directory: string | null; + title: string | null; + time_created: number | null; + time_updated: number | null; +} + +interface SqliteMessageRow { id: string; session_id: string; data: string } +interface SqlitePartRow { id: string; message_id: string; session_id: string; data: string } + +/* node:sqlite ships with Node >= 22.5 but not with every extension host, so it + * is loaded lazily; callers skip the SQLite source when it is unavailable. */ +function loadNodeSqlite(): NodeSqlite | null { + try { + const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule; + return (getBuiltinModule?.call(process, 'node:sqlite') as NodeSqlite | undefined) ?? null; + } catch { + return null; + } +} + +function parseDbJson(data: string): T | null { + try { + return JSON.parse(data) as T; + } catch { + return null; + } +} + +function indexDbMessagesBySession(rows: SqliteMessageRow[]): Map { + const messagesBySession = new Map(); + for (const row of rows) { + const parsed = parseDbJson>(row.data); + if (!parsed) continue; + const msg: OcMessage = { + ...parsed, + id: row.id, + sessionID: row.session_id, + // The db payload nests the model; the parser reads the flat modelID. + modelID: parsed.modelID ?? parsed.model?.modelID, + }; + const list = messagesBySession.get(row.session_id); + if (list) list.push(msg); + else messagesBySession.set(row.session_id, [msg]); + } + return messagesBySession; +} + +function indexDbPartsByMessage(rows: SqlitePartRow[]): Map { + const partsByMsg = new Map(); + for (const row of rows) { + const parsed = parseDbJson>(row.data); + if (!parsed) continue; + const part: OcPart = { ...parsed, id: row.id, sessionID: row.session_id, messageID: row.message_id }; + const list = partsByMsg.get(row.message_id); + if (list) list.push(part); + else partsByMsg.set(row.message_id, [part]); + } + return partsByMsg; +} + +function dbRowToOcSession(row: SqliteSessionRow): OcSession { + return { + id: row.id, + slug: row.slug ?? undefined, + directory: row.directory ?? undefined, + title: row.title ?? undefined, + time: { created: row.time_created ?? undefined, updated: row.time_updated ?? undefined }, + }; +} + +/** Parses sessions from the OpenCode SQLite database (`opencode.db`). The db + * stores the same JSON payloads as the legacy file layout in `data` columns, + * minus the id fields, which live in dedicated columns and are re-attached + * here. `knownIds` skips sessions already parsed from the legacy layout. */ +export function parseOpenCodeDbSessions(dbPath: string, knownIds?: ReadonlySet): Session[] { + const sqlite = loadNodeSqlite(); + if (!sqlite) return []; + + const sessions: Session[] = []; + let db: InstanceType | null = null; + try { + assertTrustedPath(dbPath); + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + + const sessionRows = db.prepare( + 'SELECT id, slug, directory, title, time_created, time_updated FROM session', + ).all() as unknown as SqliteSessionRow[]; + const messageRows = db.prepare( + 'SELECT id, session_id, data FROM message ORDER BY time_created', + ).all() as unknown as SqliteMessageRow[]; + const partRows = db.prepare( + 'SELECT id, message_id, session_id, data FROM part', + ).all() as unknown as SqlitePartRow[]; + + const messagesBySession = indexDbMessagesBySession(messageRows); + const partsByMsg = indexDbPartsByMessage(partRows); + + for (const row of sessionRows) { + if (!row.id || knownIds?.has(row.id)) continue; + const rawMessages = messagesBySession.get(row.id); + if (!rawMessages || rawMessages.length === 0) continue; + rawMessages.sort((a, b) => (a.time?.created || 0) - (b.time?.created || 0)); + + const session = assembleOpenCodeSession(dbRowToOcSession(row), rawMessages, partsByMsg); + if (session) sessions.push(session); + } + } catch { + // Unreadable/locked db (e.g. concurrent OpenCode process mid-migration): + // return whatever was assembled, mirroring readJsonSafe's tolerance. + return sessions; + } finally { + db?.close(); + } + + return sessions; +} + export function parseOpenCodeSessions(storageDir: string): Session[] { const sessions: Session[] = []; const sessionDir = path.join(storageDir, 'session', 'global'); @@ -314,5 +452,13 @@ export function parseOpenCodeSessions(storageDir: string): Session[] { if (session) sessions.push(session); } + // Newer OpenCode versions migrate the JSON layout into a SQLite db that + // lives one level above the storage dir (~/.local/share/opencode/opencode.db). + const dbPath = path.join(path.dirname(storageDir), 'opencode.db'); + if (fs.existsSync(dbPath)) { + const knownIds = new Set(sessions.map((s) => s.sessionId)); + sessions.push(...parseOpenCodeDbSessions(dbPath, knownIds)); + } + return sessions; }