diff --git a/src/__tests__/main/storage/opencode-session-storage.test.ts b/src/__tests__/main/storage/opencode-session-storage.test.ts new file mode 100644 index 000000000..edf705cba --- /dev/null +++ b/src/__tests__/main/storage/opencode-session-storage.test.ts @@ -0,0 +1,581 @@ +/** + * Tests for OpenCodeSessionStorage + * + * Verifies: + * - SQLite session listing (v1.2+) + * - JSON fallback for pre-v1.2 + * - Deduplication when both sources exist + * - Message reading from SQLite + * - Delete unsupported for SQLite sessions + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OpenCodeSessionStorage } from '../../../main/storage/opencode-session-storage'; + +// Mock logger +vi.mock('../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock sentry +vi.mock('../../../main/utils/sentry', () => ({ + captureException: vi.fn(), +})); + +// Mock remote-fs utilities +vi.mock('../../../main/utils/remote-fs', () => ({ + readDirRemote: vi.fn(), + readFileRemote: vi.fn(), + statRemote: vi.fn(), +})); + +// ─── SQLite mock setup ─────────────────────────────────────────────────────── + +interface MockDatabase { + prepare: ReturnType; + pragma: ReturnType; + close: ReturnType; +} + +// Container object referenced by hoisted vi.mock factory +const dbMock = { instance: null as MockDatabase | null }; + +vi.mock('better-sqlite3', () => { + // Must return a constructor-compatible function + function MockDatabase() { + if (!dbMock.instance) { + throw new Error('Database not available'); + } + return dbMock.instance; + } + return { default: MockDatabase }; +}); + +// Mock fs (sync) for existsSync +const mockExistsSync = vi.fn(); +vi.mock('fs', () => ({ + default: { + existsSync: (...args: unknown[]) => mockExistsSync(...args), + }, + existsSync: (...args: unknown[]) => mockExistsSync(...args), +})); + +// Mock fs/promises +const mockFsAccess = vi.fn(); +const mockFsReaddir = vi.fn(); +const mockFsReadFile = vi.fn(); +const mockFsStat = vi.fn(); +const mockFsUnlink = vi.fn(); +const mockFsRmdir = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + access: (...args: unknown[]) => mockFsAccess(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + readFile: (...args: unknown[]) => mockFsReadFile(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + rmdir: (...args: unknown[]) => mockFsRmdir(...args), + }, +})); + +describe('OpenCodeSessionStorage', () => { + let storage: OpenCodeSessionStorage; + + beforeEach(() => { + vi.clearAllMocks(); + dbMock.instance = null; + mockExistsSync.mockReturnValue(false); + mockFsAccess.mockRejectedValue(new Error('ENOENT')); + mockFsReaddir.mockResolvedValue([]); + mockFsReadFile.mockRejectedValue(new Error('ENOENT')); + mockFsStat.mockRejectedValue(new Error('ENOENT')); + storage = new OpenCodeSessionStorage(); + }); + + describe('agentId', () => { + it('should return opencode', () => { + expect(storage.agentId).toBe('opencode'); + }); + }); + + describe('listSessions - SQLite', () => { + function setupSqliteDb( + projects: Array<{ id: string; worktree: string }>, + sessions: Array<{ + id: string; + project_id: string; + directory: string; + title: string; + time_created: number; + time_updated: number; + }>, + messages: Array<{ + id: string; + session_id: string; + time_created: number; + data: string; + }> = [] + ): void { + // DB file exists + mockExistsSync.mockReturnValue(true); + + const createPrepare = () => + vi.fn().mockImplementation((sql: string) => { + // sqlite_master check for table existence + if (sql.includes('sqlite_master')) { + return { + all: vi.fn().mockReturnValue([]), + get: vi.fn().mockImplementation((_tableName: string) => ({ + name: _tableName, + })), + }; + } + + // project listing + if (sql.includes('FROM project') && !sql.includes('sqlite_master')) { + return { + all: vi.fn().mockReturnValue(projects), + get: vi.fn().mockReturnValue(undefined), + }; + } + + // session listing + if (sql.includes('FROM session') && !sql.includes('sqlite_master')) { + return { + all: vi.fn().mockImplementation((...args: string[]) => + sessions.filter((s) => + args.some((arg) => arg === s.project_id) + ) + ), + get: vi.fn().mockReturnValue(undefined), + }; + } + + // message listing + if (sql.includes('FROM message') && !sql.includes('sqlite_master')) { + return { + all: vi.fn().mockImplementation((sessionId: string) => + messages.filter((m) => m.session_id === sessionId) + ), + get: vi.fn().mockReturnValue(undefined), + }; + } + + // part listing + if (sql.includes('FROM part') && !sql.includes('sqlite_master')) { + return { + all: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(undefined), + }; + } + + // Default fallback + return { + all: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(undefined), + }; + }); + + dbMock.instance = { + prepare: createPrepare(), + pragma: vi.fn(), + close: vi.fn(), + }; + } + + it('should list sessions from SQLite when database exists', async () => { + const now = Date.now(); + setupSqliteDb( + [{ id: 'proj_abc', worktree: '/test/project' }], + [ + { + id: 'ses_001', + project_id: 'proj_abc', + directory: '/test/project', + title: 'Test Session', + time_created: now - 60000, + time_updated: now, + }, + ] + ); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe('ses_001'); + expect(sessions[0].projectPath).toBe('/test/project'); + }); + + it('should return empty when no project matches', async () => { + setupSqliteDb( + [{ id: 'proj_abc', worktree: '/other/project' }], + [] + ); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(0); + }); + + it('should match subdirectory projects', async () => { + const now = Date.now(); + setupSqliteDb( + [{ id: 'proj_abc', worktree: '/test/project' }], + [ + { + id: 'ses_001', + project_id: 'proj_abc', + directory: '/test/project', + title: 'Session 1', + time_created: now - 60000, + time_updated: now, + }, + ] + ); + + // Querying for a subdirectory should still match the parent project + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + }); + + it('should aggregate token stats from messages', async () => { + const now = Date.now(); + setupSqliteDb( + [{ id: 'proj_abc', worktree: '/test/project' }], + [ + { + id: 'ses_001', + project_id: 'proj_abc', + directory: '/test/project', + title: 'Test', + time_created: now - 60000, + time_updated: now, + }, + ], + [ + { + id: 'msg_1', + session_id: 'ses_001', + time_created: now - 60000, + data: JSON.stringify({ + role: 'user', + tokens: { input: 100, output: 0 }, + }), + }, + { + id: 'msg_2', + session_id: 'ses_001', + time_created: now, + data: JSON.stringify({ + role: 'assistant', + tokens: { input: 0, output: 500 }, + cost: 0.01, + }), + }, + ] + ); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0].inputTokens).toBe(100); + expect(sessions[0].outputTokens).toBe(500); + expect(sessions[0].costUsd).toBe(0.01); + expect(sessions[0].messageCount).toBe(2); + expect(sessions[0].durationSeconds).toBe(60); + }); + + it('should handle multiple sessions for same project', async () => { + const now = Date.now(); + setupSqliteDb( + [{ id: 'proj_abc', worktree: '/test/project' }], + [ + { + id: 'ses_old', + project_id: 'proj_abc', + directory: '/test/project', + title: 'Old Session', + time_created: now - 120000, + time_updated: now - 60000, + }, + { + id: 'ses_new', + project_id: 'proj_abc', + directory: '/test/project', + title: 'New Session', + time_created: now - 30000, + time_updated: now, + }, + ] + ); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions.length).toBeGreaterThanOrEqual(2); + const ids = sessions.map((s) => s.sessionId); + expect(ids).toContain('ses_old'); + expect(ids).toContain('ses_new'); + }); + }); + + describe('listSessions - JSON fallback', () => { + it('should fall back to JSON when SQLite DB does not exist', async () => { + mockExistsSync.mockReturnValue(false); + // No JSON sessions either — project dir doesn't exist + mockFsAccess.mockRejectedValue(new Error('ENOENT')); + + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(0); + }); + }); + + describe('listSessions - deduplication', () => { + it('should merge SQLite and JSON sessions, deduplicating by ID', async () => { + const now = Date.now(); + + // SQLite has ses_001 + mockExistsSync.mockReturnValue(true); + dbMock.instance = { + prepare: vi.fn().mockImplementation((sql: string) => { + const stmt = createMockStatement(); + if (sql.includes('sqlite_master')) { + stmt.get = vi.fn().mockReturnValue({ name: 'session' }); + } + if (sql.includes('FROM project')) { + stmt.all = vi + .fn() + .mockReturnValue([{ id: 'proj_abc', worktree: '/test/project' }]); + } + if (sql.includes('FROM session')) { + stmt.all = vi.fn().mockReturnValue([ + { + id: 'ses_001', + project_id: 'proj_abc', + directory: '/test/project', + title: 'SQLite Session', + time_created: now - 60000, + time_updated: now, + }, + ]); + } + if (sql.includes('FROM message')) { + stmt.all = vi.fn().mockReturnValue([]); + } + return stmt; + }), + pragma: vi.fn(), + close: vi.fn(), + }; + + // JSON has ses_001 (duplicate) and ses_002 (unique) + mockFsAccess.mockResolvedValue(undefined); + mockFsReaddir.mockImplementation((dirPath: string) => { + if (dirPath.includes('project')) { + return Promise.resolve(['proj_abc.json']); + } + if (dirPath.includes('session')) { + return Promise.resolve(['ses_001.json', 'ses_002.json']); + } + return Promise.resolve([]); + }); + mockFsReadFile.mockImplementation((filePath: string) => { + if (filePath.includes('proj_abc.json')) { + return Promise.resolve( + JSON.stringify({ id: 'proj_abc', worktree: '/test/project' }) + ); + } + if (filePath.includes('ses_001.json')) { + return Promise.resolve( + JSON.stringify({ + id: 'ses_001', + projectID: 'proj_abc', + title: 'JSON Session 1', + time: { created: now - 120000, updated: now - 60000 }, + }) + ); + } + if (filePath.includes('ses_002.json')) { + return Promise.resolve( + JSON.stringify({ + id: 'ses_002', + projectID: 'proj_abc', + title: 'JSON Session 2', + time: { created: now - 180000, updated: now - 120000 }, + }) + ); + } + return Promise.reject(new Error('ENOENT')); + }); + + const sessions = await storage.listSessions('/test/project'); + + // Should have 2: ses_001 from SQLite, ses_002 from JSON (deduped ses_001) + expect(sessions).toHaveLength(2); + const ids = sessions.map((s) => s.sessionId); + expect(ids).toContain('ses_001'); + expect(ids).toContain('ses_002'); + }); + }); + + describe('readSessionMessages - SQLite', () => { + it('should read messages from SQLite', async () => { + const now = Date.now(); + mockExistsSync.mockReturnValue(true); + + const prepareFn = (sql: string) => { + if (sql.includes('sqlite_master')) { + return { + get: vi.fn().mockReturnValue({ name: 'message' }), + all: vi.fn().mockReturnValue([]), + }; + } + if (sql.includes('FROM message')) { + return { + all: vi.fn().mockReturnValue([ + { + id: 'msg_1', + session_id: 'ses_001', + time_created: now - 60000, + time_updated: now - 60000, + data: JSON.stringify({ role: 'user' }), + }, + { + id: 'msg_2', + session_id: 'ses_001', + time_created: now, + time_updated: now, + data: JSON.stringify({ role: 'assistant' }), + }, + ]), + get: vi.fn(), + }; + } + if (sql.includes('FROM part')) { + return { + all: vi.fn().mockImplementation((messageId: string) => { + if (messageId === 'msg_1') { + return [ + { + id: 'part_1', + data: JSON.stringify({ + type: 'text', + text: 'Hello, world', + }), + }, + ]; + } + if (messageId === 'msg_2') { + return [ + { + id: 'part_2', + data: JSON.stringify({ + type: 'text', + text: 'Hi there!', + }), + }, + ]; + } + return []; + }), + get: vi.fn(), + }; + } + return { all: vi.fn().mockReturnValue([]), get: vi.fn() }; + }; + + dbMock.instance = { + prepare: vi.fn().mockImplementation(prepareFn), + pragma: vi.fn(), + close: vi.fn(), + }; + + const result = await storage.readSessionMessages('/test/project', 'ses_001'); + + expect(result.total).toBe(2); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].content).toBe('Hello, world'); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[1].content).toBe('Hi there!'); + expect(result.messages[1].role).toBe('assistant'); + }); + }); + + describe('deleteMessagePair - SQLite', () => { + it('should reject deletion for SQLite-backed sessions', async () => { + const now = Date.now(); + mockExistsSync.mockReturnValue(true); + + const prepareFn = (sql: string) => { + if (sql.includes('sqlite_master')) { + return { + get: vi.fn().mockReturnValue({ name: 'message' }), + all: vi.fn().mockReturnValue([]), + }; + } + if (sql.includes('FROM message')) { + return { + all: vi.fn().mockReturnValue([ + { + id: 'msg_1', + session_id: 'ses_001', + time_created: now, + time_updated: now, + data: JSON.stringify({ role: 'user' }), + }, + ]), + get: vi.fn(), + }; + } + if (sql.includes('FROM part')) { + return { + all: vi.fn().mockReturnValue([]), + get: vi.fn(), + }; + } + return { all: vi.fn().mockReturnValue([]), get: vi.fn() }; + }; + + dbMock.instance = { + prepare: vi.fn().mockImplementation(prepareFn), + pragma: vi.fn(), + close: vi.fn(), + }; + + const result = await storage.deleteMessagePair( + '/test/project', + 'ses_001', + 'msg_1' + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('SQLite'); + }); + }); + + describe('getSessionPath', () => { + it('should return DB path when SQLite exists', () => { + mockExistsSync.mockReturnValue(true); + + const result = storage.getSessionPath('/test/project', 'ses_001'); + + expect(result).toContain('opencode.db'); + }); + + it('should return message dir path when no SQLite', () => { + mockExistsSync.mockReturnValue(false); + + const result = storage.getSessionPath('/test/project', 'ses_001'); + + expect(result).toContain('message'); + expect(result).toContain('ses_001'); + }); + }); +}); diff --git a/src/main/storage/opencode-session-storage.ts b/src/main/storage/opencode-session-storage.ts index b6e85dc8f..8af0574d0 100644 --- a/src/main/storage/opencode-session-storage.ts +++ b/src/main/storage/opencode-session-storage.ts @@ -2,13 +2,12 @@ * OpenCode Session Storage Implementation * * This module implements the AgentSessionStorage interface for OpenCode. - * OpenCode stores sessions as JSON files in ~/.local/share/opencode/storage/ * - * Directory structure: - * - project/ - Project metadata (SHA1 hash of path as ID) - * - session/{projectID}/ - Session metadata per project - * - message/{sessionID}/ - Messages per session - * - part/{messageID}/ - Message parts (text, tool, reasoning) + * OpenCode v1.2+ stores sessions in SQLite at ~/.local/share/opencode/opencode.db + * Older versions used JSON files at ~/.local/share/opencode/storage/ + * + * This implementation reads from SQLite first, falls back to JSON for pre-v1.2 + * installs, and deduplicates sessions when both sources exist (migration period). * * Session IDs: Format is `ses_{base62}` (e.g., ses_4d585107dffeO9bO3HvMdvLYyC) * Project IDs: SHA1 hash of the project path @@ -21,7 +20,9 @@ import path from 'path'; import os from 'os'; import fs from 'fs/promises'; +import fsSync from 'fs'; import { createHash } from 'crypto'; +import Database from 'better-sqlite3'; import { logger } from '../utils/logger'; import { captureException } from '../utils/sentry'; import { readFileRemote, readDirRemote, statRemote } from '../utils/remote-fs'; @@ -41,19 +42,34 @@ import type { ToolType, SshRemoteConfig } from '../../shared/types'; const LOG_CONTEXT = '[OpenCodeSessionStorage]'; /** - * Get OpenCode storage base directory (platform-specific) - * - Linux/macOS: ~/.local/share/opencode/storage - * - Windows: %APPDATA%\opencode\storage + * Get OpenCode data base directory (platform-specific) + * - Linux/macOS: ~/.local/share/opencode + * - Windows: %APPDATA%\opencode */ -function getOpenCodeStorageDir(): string { +function getOpenCodeDataDir(): string { if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return path.join(appData, 'opencode', 'storage'); + return path.join(appData, 'opencode'); } - return path.join(os.homedir(), '.local', 'share', 'opencode', 'storage'); + return path.join(os.homedir(), '.local', 'share', 'opencode'); +} + +/** + * Get OpenCode JSON storage directory (pre-v1.2) + */ +function getOpenCodeStorageDir(): string { + return path.join(getOpenCodeDataDir(), 'storage'); +} + +/** + * Get OpenCode SQLite database path (v1.2+) + */ +function getOpenCodeDbPath(): string { + return path.join(getOpenCodeDataDir(), 'opencode.db'); } const OPENCODE_STORAGE_DIR = getOpenCodeStorageDir(); +const OPENCODE_DB_PATH = getOpenCodeDbPath(); /** * OpenCode project metadata structure @@ -136,6 +152,115 @@ interface OpenCodePart { }; } +// ─── SQLite row types (v1.2+) ──────────────────────────────────────────────── + +/** + * Raw row from the SQLite `session` table + */ +interface SqliteSessionRow { + id: string; + project_id: string; + directory: string; + title: string; + version: string; + time_created: number; // Unix ms + time_updated: number; // Unix ms + summary_additions: number | null; + summary_deletions: number | null; + summary_files: number | null; +} + +/** + * Raw row from the SQLite `message` table + * The `data` column is a JSON blob containing role, model, tokens, cost, etc. + */ +interface SqliteMessageRow { + id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; // JSON blob +} + +/** + * Parsed message data from the SQLite `data` JSON blob + */ +interface SqliteMessageData { + role?: 'user' | 'assistant'; + model?: { + providerID?: string; + modelID?: string; + }; + agent?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + }; + }; + cost?: number; +} + +/** + * Parsed part data from the SQLite `data` JSON blob + */ +interface SqlitePartData { + type?: 'text' | 'reasoning' | 'tool' | 'step-start' | 'step-finish'; + text?: string; + tool?: string; + state?: { + status?: string; + input?: unknown; + output?: unknown; + }; +} + +// ─── SQLite helpers ────────────────────────────────────────────────────────── + +/** + * Open the OpenCode SQLite database in read-only mode. + * Returns null if the database file doesn't exist. + */ +function openOpenCodeDb(dbPath: string = OPENCODE_DB_PATH): Database.Database | null { + try { + if (!fsSync.existsSync(dbPath)) { + return null; + } + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + db.pragma('journal_mode = WAL'); + return db; + } catch (error) { + logger.warn(`Failed to open OpenCode SQLite database: ${error}`, LOG_CONTEXT); + return null; + } +} + +/** + * Safely parse a JSON string, returning null on failure + */ +function safeJsonParse(json: string): T | null { + try { + return JSON.parse(json) as T; + } catch { + return null; + } +} + +/** + * Check if a table exists in a SQLite database + */ +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .get(tableName) as { name: string } | undefined; + return !!row; +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── + /** * Generate the project ID hash from a path (SHA1) */ @@ -208,7 +333,8 @@ async function listJsonFilesRemote( /** * OpenCode Session Storage Implementation * - * Provides access to OpenCode's local session storage at ~/.local/share/opencode/storage/ + * Reads from SQLite (v1.2+) with JSON file fallback (pre-v1.2). + * During migration periods, both sources are merged with dedup by session ID. */ export class OpenCodeSessionStorage implements AgentSessionStorage { readonly agentId: ToolType = 'opencode'; @@ -451,8 +577,307 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { return false; } + // ─── SQLite-based methods (OpenCode v1.2+) ────────────────────────────── + + /** + * List sessions from SQLite database for a given project path. + * Returns null if the database doesn't exist or lacks the expected schema. + */ + private listSessionsSqlite(projectPath: string): AgentSessionInfo[] | null { + const db = openOpenCodeDb(); + if (!db) return null; + + try { + if (!tableExists(db, 'session') || !tableExists(db, 'project')) { + return null; + } + + const normalizedPath = path.resolve(projectPath).replace(/\/+$/, ''); + + // Find matching project(s) — exact match or subdirectory match + const projects = db + .prepare('SELECT id, worktree FROM project') + .all() as Array<{ id: string; worktree: string }>; + + const matchingProjectIds: string[] = []; + for (const proj of projects) { + const storedPath = path.resolve(proj.worktree).replace(/\/+$/, ''); + if ( + storedPath === normalizedPath || + normalizedPath.startsWith(storedPath + '/') || + storedPath.startsWith(normalizedPath + '/') + ) { + matchingProjectIds.push(proj.id); + } + } + + if (matchingProjectIds.length === 0) { + // Fall back to sessions with matching directory field + const directorySessions = db + .prepare( + 'SELECT id, project_id, directory, title, version, time_created, time_updated, summary_additions, summary_deletions, summary_files FROM session WHERE directory = ? OR directory LIKE ? ORDER BY time_updated DESC' + ) + .all(normalizedPath, normalizedPath + '/%') as SqliteSessionRow[]; + + if (directorySessions.length === 0) { + logger.info( + `No OpenCode project or sessions found in SQLite for: ${normalizedPath}`, + LOG_CONTEXT + ); + return []; + } + + return this.convertSqliteSessionRows(directorySessions, projectPath, db); + } + + // Query sessions for all matching projects + const placeholders = matchingProjectIds.map(() => '?').join(','); + const sessions = db + .prepare( + `SELECT id, project_id, directory, title, version, time_created, time_updated, summary_additions, summary_deletions, summary_files FROM session WHERE project_id IN (${placeholders}) ORDER BY time_updated DESC` + ) + .all(...matchingProjectIds) as SqliteSessionRow[]; + + logger.info( + `Found ${sessions.length} OpenCode sessions in SQLite for: ${normalizedPath}`, + LOG_CONTEXT + ); + + return this.convertSqliteSessionRows(sessions, projectPath, db); + } catch (error) { + logger.warn(`Error reading OpenCode SQLite database: ${error}`, LOG_CONTEXT); + return null; + } finally { + db.close(); + } + } + + /** + * Convert SQLite session rows to AgentSessionInfo array, loading message stats + */ + private convertSqliteSessionRows( + rows: SqliteSessionRow[], + projectPath: string, + db: Database.Database + ): AgentSessionInfo[] { + const hasMessageTable = tableExists(db, 'message'); + const hasPartTable = tableExists(db, 'part'); + const sessions: AgentSessionInfo[] = []; + + for (const row of rows) { + let messageCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + let firstMessage = row.title || ''; + let durationSeconds = 0; + + if (hasMessageTable) { + const messages = db + .prepare( + 'SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC' + ) + .all(row.id) as Array<{ id: string; data: string; time_created: number }>; + + messageCount = messages.length; + + if (messages.length >= 2) { + const first = messages[0].time_created; + const last = messages[messages.length - 1].time_created; + if (first && last) { + durationSeconds = Math.max(0, Math.floor((last - first) / 1000)); + } + } + + // Aggregate stats and find preview message + let foundPreview = false; + for (const msg of messages) { + const data = safeJsonParse(msg.data); + if (!data) continue; + + if (data.tokens) { + totalInputTokens += data.tokens.input || 0; + totalOutputTokens += data.tokens.output || 0; + totalCacheReadTokens += data.tokens.cache?.read || 0; + totalCacheWriteTokens += data.tokens.cache?.write || 0; + } + if (data.cost) { + totalCost += data.cost; + } + + // Get preview from first assistant message with text + if (!foundPreview && data.role === 'assistant' && hasPartTable) { + const parts = db + .prepare( + 'SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC' + ) + .all(msg.id) as Array<{ data: string }>; + for (const part of parts) { + const partData = safeJsonParse(part.data); + if (partData?.type === 'text' && partData.text?.trim()) { + firstMessage = partData.text; + foundPreview = true; + break; + } + } + } + + // Fall back to first user message if no assistant text found + if (!foundPreview && data.role === 'user' && hasPartTable) { + const parts = db + .prepare( + 'SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC' + ) + .all(msg.id) as Array<{ data: string }>; + for (const part of parts) { + const partData = safeJsonParse(part.data); + if (partData?.type === 'text' && partData.text?.trim()) { + firstMessage = partData.text; + break; + } + } + } + } + } + + const createdAt = row.time_created + ? new Date(row.time_created).toISOString() + : new Date().toISOString(); + const updatedAt = row.time_updated + ? new Date(row.time_updated).toISOString() + : createdAt; + + sessions.push({ + sessionId: row.id, + projectPath, + timestamp: createdAt, + modifiedAt: updatedAt, + firstMessage: firstMessage.slice(0, 200), + messageCount, + sizeBytes: 0, + costUsd: totalCost, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: totalCacheReadTokens, + cacheCreationTokens: totalCacheWriteTokens, + durationSeconds, + }); + } + + return sessions; + } + + /** + * Load messages for a session from SQLite. + * Returns null if the database doesn't exist or lacks the expected schema. + */ + private loadSessionMessagesSqlite(sessionId: string): { + messages: OpenCodeMessage[]; + parts: Map; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalCost: number; + } | null { + const db = openOpenCodeDb(); + if (!db) return null; + + try { + if (!tableExists(db, 'message')) return null; + + const messageRows = db + .prepare( + 'SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ? ORDER BY time_created ASC' + ) + .all(sessionId) as SqliteMessageRow[]; + + if (messageRows.length === 0) return null; + + const hasPartTable = tableExists(db, 'part'); + const messages: OpenCodeMessage[] = []; + const parts = new Map(); + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + + for (const row of messageRows) { + const data = safeJsonParse(row.data); + if (!data) continue; + + const msg: OpenCodeMessage = { + id: row.id, + sessionID: sessionId, + role: data.role || 'user', + time: { created: row.time_created }, + model: data.model, + agent: data.agent, + tokens: data.tokens, + cost: data.cost, + }; + messages.push(msg); + + if (data.tokens) { + totalInputTokens += data.tokens.input || 0; + totalOutputTokens += data.tokens.output || 0; + totalCacheReadTokens += data.tokens.cache?.read || 0; + totalCacheWriteTokens += data.tokens.cache?.write || 0; + } + if (data.cost) { + totalCost += data.cost; + } + + // Load parts from SQLite + if (hasPartTable) { + const partRows = db + .prepare( + 'SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC' + ) + .all(row.id) as Array<{ id: string; data: string }>; + + const messageParts: OpenCodePart[] = []; + for (const partRow of partRows) { + const partData = safeJsonParse(partRow.data); + if (partData) { + messageParts.push({ + id: partRow.id, + messageID: row.id, + type: partData.type || 'text', + text: partData.text, + tool: partData.tool, + state: partData.state, + }); + } + } + parts.set(row.id, messageParts); + } + } + + return { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + }; + } catch (error) { + logger.warn(`Error loading messages from OpenCode SQLite: ${error}`, LOG_CONTEXT); + return null; + } finally { + db.close(); + } + } + + // ─── JSON-based methods (pre-v1.2 fallback) ───────────────────────────── + /** - * Load all messages for a session + * Load all messages for a session (JSON files) */ private async loadSessionMessages(sessionId: string): Promise<{ messages: OpenCodeMessage[]; @@ -626,15 +1051,49 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { projectPath: string, sshConfig?: SshRemoteConfig ): Promise { - // Use SSH remote access if config provided + // Use SSH remote access if config provided (JSON only for SSH — no remote SQLite) if (sshConfig) { return this.listSessionsRemote(projectPath, sshConfig); } + // Try SQLite first (v1.2+), then fall back to JSON, merge and dedup + const sqliteSessions = this.listSessionsSqlite(projectPath); + const jsonSessions = await this.listSessionsJson(projectPath); + + if (sqliteSessions && sqliteSessions.length > 0) { + if (jsonSessions.length > 0) { + // Merge: SQLite is authoritative, add JSON-only sessions + const sqliteIds = new Set(sqliteSessions.map((s) => s.sessionId)); + const merged = [...sqliteSessions]; + for (const jsonSession of jsonSessions) { + if (!sqliteIds.has(jsonSession.sessionId)) { + merged.push(jsonSession); + } + } + merged.sort( + (a, b) => + new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime() + ); + logger.info( + `Merged ${sqliteSessions.length} SQLite + ${merged.length - sqliteSessions.length} JSON-only sessions for: ${projectPath}`, + LOG_CONTEXT + ); + return merged; + } + return sqliteSessions; + } + + // SQLite unavailable or empty — use JSON results + return jsonSessions; + } + + /** + * List sessions from JSON files (pre-v1.2 format) + */ + private async listSessionsJson(projectPath: string): Promise { const projectId = await this.findProjectId(projectPath); if (!projectId) { - logger.info(`No OpenCode project found for path: ${projectPath}`, LOG_CONTEXT); return []; } @@ -646,7 +1105,6 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { try { await fs.access(sessionDir); } catch { - logger.info(`No OpenCode sessions directory for project: ${projectPath}`, LOG_CONTEXT); return []; } @@ -743,10 +1201,12 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { // Sort by modified date (newest first) sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); - logger.info( - `Found ${sessions.length} OpenCode sessions for project: ${projectPath}`, - LOG_CONTEXT - ); + if (sessions.length > 0) { + logger.info( + `Found ${sessions.length} OpenCode sessions (JSON) for: ${projectPath}`, + LOG_CONTEXT + ); + } return sessions; } @@ -913,9 +1373,22 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { options?: SessionReadOptions, sshConfig?: SshRemoteConfig ): Promise { - const { messages, parts } = sshConfig - ? await this.loadSessionMessagesRemote(sessionId, sshConfig) - : await this.loadSessionMessages(sessionId); + // Try SQLite first for local sessions, fall back to JSON + let loaded: { + messages: OpenCodeMessage[]; + parts: Map; + } | null = null; + + if (sshConfig) { + loaded = await this.loadSessionMessagesRemote(sessionId, sshConfig); + } else { + loaded = this.loadSessionMessagesSqlite(sessionId); + if (!loaded) { + loaded = await this.loadSessionMessages(sessionId); + } + } + + const { messages, parts } = loaded; const sessionMessages: SessionMessage[] = []; @@ -974,9 +1447,21 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { const results: SessionSearchResult[] = []; for (const session of sessions) { - const { messages, parts } = sshConfig - ? await this.loadSessionMessagesRemote(session.sessionId, sshConfig) - : await this.loadSessionMessages(session.sessionId); + let loaded: { + messages: OpenCodeMessage[]; + parts: Map; + } | null = null; + + if (sshConfig) { + loaded = await this.loadSessionMessagesRemote(session.sessionId, sshConfig); + } else { + loaded = this.loadSessionMessagesSqlite(session.sessionId); + if (!loaded) { + loaded = await this.loadSessionMessages(session.sessionId); + } + } + + const { messages, parts } = loaded; let titleMatch = false; let userMatches = 0; @@ -1063,11 +1548,14 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { sessionId: string, sshConfig?: SshRemoteConfig ): string | null { - // OpenCode uses a more complex structure with multiple directories - // Return the message directory as the "session path" if (sshConfig) { return this.getRemoteMessageDir(sessionId); } + // For SQLite-backed sessions, return the database path + if (fsSync.existsSync(OPENCODE_DB_PATH)) { + return OPENCODE_DB_PATH; + } + // Fallback to JSON message directory return this.getMessageDir(sessionId); } @@ -1085,7 +1573,21 @@ export class OpenCodeSessionStorage implements AgentSessionStorage { } try { - // Load all messages for the session + // Check if this session exists in SQLite — deletion not supported for SQLite sessions + // (we open the DB read-only and shouldn't modify OpenCode's database) + const sqliteResult = this.loadSessionMessagesSqlite(sessionId); + if (sqliteResult && sqliteResult.messages.length > 0) { + logger.warn( + 'Delete message pair not supported for SQLite-backed OpenCode sessions', + LOG_CONTEXT + ); + return { + success: false, + error: 'Delete not supported for OpenCode v1.2+ SQLite sessions', + }; + } + + // Load all messages for the session (JSON files) const { messages, parts } = await this.loadSessionMessages(sessionId); if (messages.length === 0) {