From 524a8c0f3d05b955f545175a3c19fe50580d74e7 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:18:01 +0000 Subject: [PATCH 1/4] feat: migrate opencode session storage from JSON files to SQLite OpenCode 1.2 replaced flat JSON file storage with a SQLite database. Rewrites the worker's opencode-storage module to query the SQLite DB directly using bun:sqlite instead of walking JSON files on disk. --- src/index.ts | 6 +- src/sessions/agents/opencode-storage.ts | 341 +++++++++--------------- src/shared/constants.ts | 2 +- src/worker/session-index.ts | 31 +-- 4 files changed, 140 insertions(+), 240 deletions(-) diff --git a/src/index.ts b/src/index.ts index 18aaaf43..fc29c504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1272,21 +1272,21 @@ workerCmd await import('./sessions/agents/opencode-storage'); if (subcommand === 'list') { - const sessions = await listOpencodeSessions(); + const sessions = listOpencodeSessions(); console.log(JSON.stringify(sessions)); } else if (subcommand === 'messages') { if (!sessionId) { console.error('Usage: perry worker sessions messages '); process.exit(1); } - const result = await getOpencodeSessionMessages(sessionId); + const result = getOpencodeSessionMessages(sessionId); console.log(JSON.stringify(result)); } else if (subcommand === 'delete') { if (!sessionId) { console.error('Usage: perry worker sessions delete '); process.exit(1); } - const result = await deleteOpencodeSession(sessionId); + const result = deleteOpencodeSession(sessionId); console.log(JSON.stringify(result)); } else { console.error(`Unknown subcommand: ${subcommand}`); diff --git a/src/sessions/agents/opencode-storage.ts b/src/sessions/agents/opencode-storage.ts index fa059eda..0a7cab1d 100644 --- a/src/sessions/agents/opencode-storage.ts +++ b/src/sessions/agents/opencode-storage.ts @@ -1,13 +1,12 @@ -import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; +import { Database } from 'bun:sqlite'; export interface OpencodeSessionInfo { id: string; title: string; directory: string; mtime: number; - file: string; messageCount: number; } @@ -25,249 +24,167 @@ export interface OpencodeSessionMessages { messages: OpencodeMessage[]; } -function getStorageBase(homeDir?: string): string { +function getDbPath(homeDir?: string): string { const home = homeDir || os.homedir(); - return path.join(home, '.local', 'share', 'opencode', 'storage'); + return path.join(home, '.local', 'share', 'opencode', 'opencode.db'); } -export async function listOpencodeSessions(homeDir?: string): Promise { - const storageBase = getStorageBase(homeDir); - const sessionDir = path.join(storageBase, 'session'); - const messageDir = path.join(storageBase, 'message'); - const sessions: OpencodeSessionInfo[] = []; - +function withDb(homeDir: string | undefined, readonly: boolean, fn: (db: Database) => T): T { + const db = new Database(getDbPath(homeDir), { readonly }); try { - const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true }); - - for (const projectDir of projectDirs) { - if (!projectDir.isDirectory()) continue; - - const projectPath = path.join(sessionDir, projectDir.name); - const sessionFiles = await fs.readdir(projectPath); - - for (const sessionFile of sessionFiles) { - if (!sessionFile.startsWith('ses_') || !sessionFile.endsWith('.json')) continue; - - const filePath = path.join(projectPath, sessionFile); - try { - const stat = await fs.stat(filePath); - const content = await fs.readFile(filePath, 'utf-8'); - const data = JSON.parse(content); - - if (!data.id) continue; - - let messageCount = 0; - try { - const msgDir = path.join(messageDir, data.id); - const msgFiles = await fs.readdir(msgDir); - messageCount = msgFiles.filter( - (f) => f.startsWith('msg_') && f.endsWith('.json') - ).length; - } catch { - // No messages directory - } - - sessions.push({ - id: data.id, - title: data.title || '', - directory: data.directory || '', - mtime: data.time?.updated || Math.floor(stat.mtimeMs), - file: filePath, - messageCount, - }); - } catch { - continue; - } - } - } - } catch { - // Storage doesn't exist + return fn(db); + } finally { + db.close(); } - - return sessions; } -export async function getOpencodeSessionMessages( - sessionId: string, - homeDir?: string -): Promise { - const storageBase = getStorageBase(homeDir); - const sessionDir = path.join(storageBase, 'session'); - const messageDir = path.join(storageBase, 'message'); - const partDir = path.join(storageBase, 'part'); - - const sessionFile = await findSessionFile(sessionDir, sessionId); - if (!sessionFile) { - return { id: sessionId, messages: [] }; - } - - let internalId: string; +export function listOpencodeSessions(homeDir?: string): OpencodeSessionInfo[] { try { - const content = await fs.readFile(sessionFile, 'utf-8'); - const data = JSON.parse(content); - internalId = data.id; - if (!internalId) { - return { id: sessionId, messages: [] }; - } + return withDb(homeDir, true, (db) => { + const rows = db + .query< + { + id: string; + title: string; + directory: string; + time_updated: number; + message_count: number; + }, + [] + >( + `SELECT s.id, s.title, s.directory, s.time_updated, + COUNT(m.id) as message_count + FROM session s + LEFT JOIN message m ON m.session_id = s.id + GROUP BY s.id` + ) + .all(); + + return rows.map((row) => ({ + id: row.id, + title: row.title || '', + directory: row.directory || '', + mtime: row.time_updated || 0, + messageCount: row.message_count, + })); + }); } catch { - return { id: sessionId, messages: [] }; + return []; } +} - const msgDir = path.join(messageDir, internalId); - const messages: OpencodeMessage[] = []; - +export function getOpencodeSessionMessages( + sessionId: string, + homeDir?: string +): OpencodeSessionMessages { try { - const msgFiles = (await fs.readdir(msgDir)) - .filter((f) => f.startsWith('msg_') && f.endsWith('.json')) - .sort(); - - for (const msgFile of msgFiles) { - const msgPath = path.join(msgDir, msgFile); - try { - const content = await fs.readFile(msgPath, 'utf-8'); - const msg = JSON.parse(content); - - if (!msg.id || (msg.role !== 'user' && msg.role !== 'assistant')) continue; - - const partMsgDir = path.join(partDir, msg.id); - try { - const partFiles = (await fs.readdir(partMsgDir)) - .filter((f) => f.startsWith('prt_') && f.endsWith('.json')) - .sort(); - - for (const partFile of partFiles) { - const partPath = path.join(partMsgDir, partFile); - try { - const partContent = await fs.readFile(partPath, 'utf-8'); - const part = JSON.parse(partContent); - const timestamp = msg.time?.created - ? new Date(msg.time.created).toISOString() - : undefined; - - if (part.type === 'text' && part.text) { - messages.push({ - type: msg.role, - content: part.text, - timestamp, - }); - } else if (part.type === 'tool') { - const toolName = part.state?.title || part.tool || ''; - const callId = part.callID || part.id || ''; - - messages.push({ - type: 'tool_use', - toolName, - toolId: callId, - toolInput: part.state?.input ? JSON.stringify(part.state.input) : '', - timestamp, - }); - - if (part.state?.output) { - messages.push({ - type: 'tool_result', - content: part.state.output, - toolId: callId, - timestamp, - }); - } - } - } catch { - continue; - } - } - } catch { - continue; + return withDb(homeDir, true, (db) => { + const msgRows = db + .query<{ id: string; data: string; time_created: number }, [string]>( + `SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created` + ) + .all(sessionId); + + const partRows = db + .query<{ message_id: string; data: string }, [string]>( + `SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id` + ) + .all(sessionId); + + const partsByMessage = new Map(); + for (const part of partRows) { + const list = partsByMessage.get(part.message_id); + if (list) { + list.push(part.data); + } else { + partsByMessage.set(part.message_id, [part.data]); } - } catch { - continue; } - } - } catch { - // No messages - } - return { id: sessionId, messages }; -} + const messages: OpencodeMessage[] = []; -async function findSessionFile(sessionDir: string, sessionId: string): Promise { - try { - const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true }); + for (const msg of msgRows) { + const msgData = safeParse<{ role?: string }>(msg.data); + if (!msgData) continue; + if (msgData.role !== 'user' && msgData.role !== 'assistant') continue; - for (const projectDir of projectDirs) { - if (!projectDir.isDirectory()) continue; + const role = msgData.role; + const timestamp = msg.time_created ? new Date(msg.time_created).toISOString() : undefined; - const filePath = path.join(sessionDir, projectDir.name, `${sessionId}.json`); - try { - await fs.access(filePath); - return filePath; - } catch { - continue; + const partDataList = partsByMessage.get(msg.id) ?? []; + for (const raw of partDataList) { + const parsed = safeParse(raw); + if (!parsed) continue; + messages.push(...convertPart(parsed, role, timestamp)); + } } - } + + return { id: sessionId, messages }; + }); } catch { - // Directory doesn't exist + return { id: sessionId, messages: [] }; } - - return null; } -export async function deleteOpencodeSession( +export function deleteOpencodeSession( sessionId: string, homeDir?: string -): Promise<{ success: boolean; error?: string }> { - const storageBase = getStorageBase(homeDir); - const sessionDir = path.join(storageBase, 'session'); - const messageDir = path.join(storageBase, 'message'); - const partDir = path.join(storageBase, 'part'); - - const sessionFile = await findSessionFile(sessionDir, sessionId); - if (!sessionFile) { - return { success: false, error: 'Session not found' }; +): { success: boolean; error?: string } { + try { + withDb(homeDir, false, (db) => { + db.query(`DELETE FROM session WHERE id = ?`).run(sessionId); + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete session: ${message}` }; } +} + +interface PartData { + type?: string; + text?: string; + tool?: string; + callID?: string; + id?: string; + state?: { title?: string; input?: unknown; output?: string }; +} - let internalId: string | null = null; +function safeParse(json: string): T | null { try { - const content = await fs.readFile(sessionFile, 'utf-8'); - const data = JSON.parse(content); - internalId = data.id; + return JSON.parse(json) as T; } catch { - // Continue with session file deletion only + return null; } +} - try { - await fs.unlink(sessionFile); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to delete session file: ${message}` }; +function convertPart(part: PartData, role: string, timestamp?: string): OpencodeMessage[] { + if (part.type === 'text' && part.text) { + return [{ type: role, content: part.text, timestamp }]; } - if (internalId) { - const msgDir = path.join(messageDir, internalId); - try { - const msgFiles = await fs.readdir(msgDir); - for (const msgFile of msgFiles) { - if (!msgFile.startsWith('msg_') || !msgFile.endsWith('.json')) continue; - const msgPath = path.join(msgDir, msgFile); - try { - const content = await fs.readFile(msgPath, 'utf-8'); - const msg = JSON.parse(content); - if (msg.id) { - const partMsgDir = path.join(partDir, msg.id); - try { - await fs.rm(partMsgDir, { recursive: true }); - } catch { - // Parts may not exist - } - } - } catch { - // Skip malformed messages - } - } - await fs.rm(msgDir, { recursive: true }); - } catch { - // Messages directory may not exist - } + if (part.type !== 'tool') return []; + + const toolName = part.state?.title || part.tool || ''; + const toolId = part.callID || part.id || ''; + const messages: OpencodeMessage[] = [ + { + type: 'tool_use', + toolName, + toolId, + toolInput: part.state?.input ? JSON.stringify(part.state.input) : '', + timestamp, + }, + ]; + + if (part.state?.output) { + messages.push({ + type: 'tool_result', + content: part.state.output, + toolId, + timestamp, + }); } - return { success: true }; + return messages; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d1eb720f..0cb091bd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -14,7 +14,7 @@ export const CONTAINER_PREFIX = 'workspace-'; export const AGENT_SESSION_PATHS = { claudeCode: '.claude/projects', - opencode: '.local/share/opencode/storage', + opencode: '.local/share/opencode', codex: '.codex/sessions', pi: '.pi/agent/sessions', } as const; diff --git a/src/worker/session-index.ts b/src/worker/session-index.ts index 851276c7..9ece1630 100644 --- a/src/worker/session-index.ts +++ b/src/worker/session-index.ts @@ -46,18 +46,9 @@ class SessionIndex { startWatchers(): void { const claudeDir = path.join(os.homedir(), '.claude', 'projects'); - const opencodeDir = path.join( - os.homedir(), - '.local', - 'share', - 'opencode', - 'storage', - 'session' - ); const piDir = path.join(os.homedir(), '.pi', 'agent', 'sessions'); this.watchDirectory(claudeDir, 'claude'); - this.watchDirectory(opencodeDir, 'opencode'); this.watchDirectory(piDir, 'pi'); } @@ -110,7 +101,7 @@ class SessionIndex { await fs.unlink(session.filePath); } else { const { deleteOpencodeSession } = await import('../sessions/agents/opencode-storage'); - const result = await deleteOpencodeSession(id); + const result = deleteOpencodeSession(id); if (!result.success) { return result; } @@ -158,7 +149,7 @@ class SessionIndex { private async discoverOpencodeSessions(): Promise { try { const { listOpencodeSessions } = await import('../sessions/agents/opencode-storage'); - const sessions = await listOpencodeSessions(); + const sessions = listOpencodeSessions(); for (const session of sessions) { this.sessions.set(session.id, { @@ -166,7 +157,7 @@ class SessionIndex { agentType: 'opencode', title: session.title, directory: session.directory, - filePath: session.file, + filePath: '', messageCount: session.messageCount, firstPrompt: session.title || null, lastActivity: session.mtime, @@ -228,7 +219,7 @@ class SessionIndex { } } - private watchDirectory(dir: string, agentType: 'claude' | 'opencode' | 'pi'): void { + private watchDirectory(dir: string, agentType: 'claude' | 'pi'): void { try { const watcher = watch(dir, { recursive: true }, (event, filename) => { if (!filename) return; @@ -258,7 +249,7 @@ class SessionIndex { private async handleFileChange( baseDir: string, filename: string, - agentType: 'claude' | 'opencode' | 'pi' + agentType: 'claude' | 'pi' ): Promise { const filePath = path.join(baseDir, filename); @@ -273,7 +264,7 @@ class SessionIndex { const sessionId = path.basename(filename, '.jsonl'); this.sessions.delete(sessionId); } - } else if (agentType === 'pi') { + } else { if (!filename.endsWith('.jsonl')) return; try { @@ -286,14 +277,6 @@ class SessionIndex { const sessionId = idParts.length > 1 ? idParts[idParts.length - 1] : basename; this.sessions.delete(sessionId); } - } else { - if (!filename.endsWith('.json') || !filename.includes('ses_')) return; - - try { - await this.discoverOpencodeSessions(); - } catch { - // Re-discovery failed - } } } @@ -400,7 +383,7 @@ class SessionIndex { ): Promise<{ id: string; messages: Message[]; total: number }> { try { const { getOpencodeSessionMessages } = await import('../sessions/agents/opencode-storage'); - const result = await getOpencodeSessionMessages(session.id); + const result = getOpencodeSessionMessages(session.id); const total = result.messages.length; const startIndex = Math.max(0, total - opts.offset - opts.limit); From 4b6a748434f35d2d0ed9bfe4ea51fd8c1ff11ea5 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:25:53 +0000 Subject: [PATCH 2/4] fix: remove opencode from ripgrep-based session search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode sessions are now in SQLite, not flat files — ripgrep can't search them. Remove the dead opencode search paths and result parsing from both the container and host search functions. --- src/agent/router.ts | 9 --------- src/sessions/agents/index.ts | 11 ----------- 2 files changed, 20 deletions(-) diff --git a/src/agent/router.ts b/src/agent/router.ts index 3123cd05..ee64b0e8 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -1289,7 +1289,6 @@ export function createRouter(ctx: RouterContext) { const safeQuery = query.replace(/['"\\]/g, '\\$&'); const searchPaths = [ path.join(homeDir, '.claude', 'projects'), - path.join(homeDir, '.local', 'share', 'opencode', 'storage'), path.join(homeDir, '.codex', 'sessions'), path.join(homeDir, '.pi', 'agent', 'sessions'), ].filter((p) => { @@ -1333,14 +1332,6 @@ export function createRouter(ctx: RouterContext) { sessionId = match[1]; agentType = 'claude-code'; } - } else if (file.includes('/.local/share/opencode/storage/')) { - if (file.includes('/session/') && file.endsWith('.json')) { - const match = file.match(/\/(ses_[^/]+)\.json$/); - if (match) { - sessionId = match[1]; - agentType = 'opencode'; - } - } } else if (file.includes('/.codex/sessions/')) { const match = file.match(/\/([^/]+)\.jsonl$/); if (match) { diff --git a/src/sessions/agents/index.ts b/src/sessions/agents/index.ts index 928a068e..713bcadf 100644 --- a/src/sessions/agents/index.ts +++ b/src/sessions/agents/index.ts @@ -108,7 +108,6 @@ export async function searchSessions( const searchPaths = [ '/home/workspace/.claude/projects', - '/home/workspace/.local/share/opencode/storage', '/home/workspace/.codex/sessions', '/home/workspace/.pi/agent/sessions', ]; @@ -136,16 +135,6 @@ export async function searchSessions( sessionId = match[1]; agentType = 'claude-code'; } - } else if (file.includes('/.local/share/opencode/storage/')) { - if (file.includes('/session/') && file.endsWith('.json')) { - const match = file.match(/\/(ses_[^/]+)\.json$/); - if (match) { - sessionId = match[1]; - agentType = 'opencode'; - } - } else if (file.includes('/part/') || file.includes('/message/')) { - continue; - } } else if (file.includes('/.codex/sessions/')) { const match = file.match(/\/([^/]+)\.jsonl$/); if (match) { From 525671b33ede459c35c067cc5694d7783aff389a Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:34:38 +0000 Subject: [PATCH 3/4] fix: update OpenCode test to seed SQLite instead of JSON files The server integration test was creating JSON files for the old storage format. Uses bun -e inside the container to create a SQLite DB with test session data instead. --- test/worker/server.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/worker/server.test.ts b/test/worker/server.test.ts index ac36813f..bf937c65 100644 --- a/test/worker/server.test.ts +++ b/test/worker/server.test.ts @@ -102,13 +102,20 @@ EOF`, it('discovers OpenCode sessions with message counts', async () => { const sessionId = 'ses_test123'; + const now = Date.now(); + const seedScript = ` + const{Database}=require("bun:sqlite"); + const db=new Database(process.env.HOME+"/.local/share/opencode/opencode.db"); + db.run("CREATE TABLE IF NOT EXISTS session(id TEXT PRIMARY KEY,title TEXT,directory TEXT,time_created INTEGER,time_updated INTEGER)"); + db.run("CREATE TABLE IF NOT EXISTS message(id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,time_updated INTEGER,data TEXT)"); + db.query("INSERT INTO session VALUES(?,?,?,?,?)").run("${sessionId}","Test OpenCode Session","/home/workspace",${now},${now}); + db.query("INSERT INTO message VALUES(?,?,?,?,?)").run("msg_1","${sessionId}",${now},${now},JSON.stringify({role:"user"})); + db.query("INSERT INTO message VALUES(?,?,?,?,?)").run("msg_2","${sessionId}",${now + 1},${now + 1},JSON.stringify({role:"assistant"})); + db.close(); + `.replace(/\n\s*/g, ''); await execInWorkspace( containerName, - `mkdir -p ~/.local/share/opencode/storage/session/global && \ - mkdir -p ~/.local/share/opencode/storage/message/${sessionId} && \ - echo '{"id":"${sessionId}","title":"Test OpenCode Session","directory":"/home/workspace"}' > ~/.local/share/opencode/storage/session/global/${sessionId}.json && \ - echo '{"id":"msg_1","role":"user"}' > ~/.local/share/opencode/storage/message/${sessionId}/msg_1.json && \ - echo '{"id":"msg_2","role":"assistant"}' > ~/.local/share/opencode/storage/message/${sessionId}/msg_2.json`, + `mkdir -p ~/.local/share/opencode && bun -e '${seedScript}'`, { user: 'workspace' } ); From 4da6dd87c52e421088ee5631978b0d568834e5df Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:51:53 +0000 Subject: [PATCH 4/4] fix: enable foreign keys for cascade delete in opencode sessions SQLite disables foreign key constraints by default. Without PRAGMA foreign_keys = ON, deleting a session leaves orphaned message and part rows. --- src/sessions/agents/opencode-storage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sessions/agents/opencode-storage.ts b/src/sessions/agents/opencode-storage.ts index 0a7cab1d..ac0d367d 100644 --- a/src/sessions/agents/opencode-storage.ts +++ b/src/sessions/agents/opencode-storage.ts @@ -132,6 +132,7 @@ export function deleteOpencodeSession( ): { success: boolean; error?: string } { try { withDb(homeDir, false, (db) => { + db.run('PRAGMA foreign_keys = ON'); db.query(`DELETE FROM session WHERE id = ?`).run(sessionId); }); return { success: true };