import fsSync from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; import { getOpenCodeDatabasePath, normalizeProviderTimestamp, normalizeSessionName, readJsonRecord, readOptionalString, } from '@/shared/utils.js'; type OpenCodeSessionRow = { id: string; directory: string | null; title: string | null; time_created: number | null; time_updated: number | null; worktree: string | null; }; type SynchronizeRowsResult = { processed: number; firstSessionId: string | null; }; /** * Session indexer for OpenCode's SQLite-backed session store. */ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider = 'opencode' as const; /** * Scans OpenCode's shared opencode.db and upserts active sessions into DB. */ async synchronize(since?: Date): Promise { const result = this.synchronizeRows(since); return result.processed; } /** * Handles watcher changes for opencode.db. */ async synchronizeFile(filePath: string): Promise { if (path.basename(filePath) !== 'opencode.db') { return null; } const result = this.synchronizeRows(undefined, 1); return result.firstSessionId; } private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult { const dbPath = getOpenCodeDatabasePath(); if (!fsSync.existsSync(dbPath)) { return { processed: 0, firstSessionId: null }; } const db = new Database(dbPath, { readonly: true, fileMustExist: true }); try { const sinceMillis = since?.getTime() ?? null; const limitClause = limit ? 'LIMIT ?' : ''; const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis]; const rows = db.prepare(` SELECT s.id AS id, s.directory AS directory, s.title AS title, s.time_created AS time_created, s.time_updated AS time_updated, p.worktree AS worktree FROM session s LEFT JOIN project p ON p.id = s.project_id WHERE s.time_archived IS NULL AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?) ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC ${limitClause} `).all(...params) as OpenCodeSessionRow[]; let processed = 0; let firstSessionId: string | null = null; for (const row of rows) { const indexedSessionId = this.upsertSession(db, row); if (!indexedSessionId) { continue; } if (!firstSessionId) { firstSessionId = indexedSessionId; } processed += 1; } return { processed, firstSessionId }; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message); return { processed: 0, firstSessionId: null }; } finally { db.close(); } } private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null { const sessionId = readOptionalString(row.id); const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree); if (!sessionId || !projectPath) { return null; } const fallbackTitle = 'Untitled OpenCode Session'; const existingSession = sessionsDb.getSessionById(sessionId); const existingName = existingSession?.custom_name; const nextName = existingName && existingName !== fallbackTitle ? existingName : readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); // OpenCode stores every session in one shared sqlite database, so jsonl_path // must stay null to avoid deleting opencode.db when one app session is removed. sessionsDb.createSession( sessionId, this.provider, projectPath, normalizeSessionName(nextName, fallbackTitle), normalizeProviderTimestamp(row.time_created), normalizeProviderTimestamp(row.time_updated ?? row.time_created), null, ); return sessionId; } private readFirstUserText(db: Database.Database, sessionId: string): string | undefined { try { const row = db.prepare(` SELECT p.data AS data FROM message m INNER JOIN part p ON p.session_id = m.session_id AND p.message_id = m.id WHERE m.session_id = ? AND json_extract(m.data, '$.role') = 'user' AND json_extract(p.data, '$.type') = 'text' ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0) LIMIT 1 `).get(sessionId) as { data: string | null } | undefined; const data = readJsonRecord(row?.data); return readOptionalString(data?.text); } catch { return undefined; } } }