import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import { addPreviewMessage, createAccumulator, finalizeSession, updateTimeline } from './session-scanner-accumulator' import { normalizeFullFirstUserPromptText, shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' import { normalizeTitleText } from './session-scanner-values' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files // to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module // parses individual sessions from the DB into AiVaultSession objects. The // discovery layer (listing candidates) lives in // session-scanner-opencode-sqlite-discovery.ts. const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 // Why (#8864): a heavy session can hold ~10K parts (25-150 KB tool-output // blobs). Join preview parts against only the newest N messages instead of // scanning every part of the session, using the real (session_id, time_created, // id) index. Bounds the read to those messages' parts; the 15 s parse timeout // caps the residual for a single pathological giant part. const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 // Bounds a pathological single message; a real typed prompt is a handful of parts. const FIRST_USER_PROMPT_PART_LIMIT = 512 type SessionRow = { id: string title: string | null directory: string | null time_created: number time_updated: number model_json: string | null agent: string | null tokens_input: number tokens_output: number tokens_reasoning: number tokens_cache_read: number cost: number message_count: number } type PreviewRow = { role: string | null part_data: string time_created: number summary_title: string | null summary_body: string | null } function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && columnExists(db, 'session', 'time_created') && columnExists(db, 'session', 'time_updated') ) } function sessionColumnSelect(db: SyncDatabase, columnName: string): string { return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' } function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' } function canCountOpenCodeMessages(db: SyncDatabase): boolean { return ( tableExists(db, 'message') && columnExists(db, 'message', 'session_id') && columnExists(db, 'message', 'data') ) } function buildSessionQuery(db: SyncDatabase): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id AND json_extract(m.data, '$.role') IN ('user','assistant'))` : '0' return `SELECT s.id, ${sessionColumnSelect(db, 'title')} AS title, ${sessionColumnSelect(db, 'directory')} AS directory, s.time_created, s.time_updated, ${sessionColumnSelect(db, 'model')} AS model_json, ${sessionColumnSelect(db, 'agent')} AS agent, ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, ${sessionNumberColumnSelect(db, 'cost')} AS cost, ${messageCountSubquery} AS message_count FROM session s WHERE s.id = ? LIMIT 1` } function extractModelId(modelJson: string | null): string | null { if (!modelJson) { return null } try { const parsed = JSON.parse(modelJson) as unknown const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null if (!record) { return null } // Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}. // Older schemas used {"modelID":"..."}; accept both. return ( (typeof record.id === 'string' && record.id.trim()) || (typeof record.modelID === 'string' && record.modelID.trim()) || null ) } catch { return null } } function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] { if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') { return role } return 'unknown' } function extractPartText(partData: string): string | null { try { const parsed = JSON.parse(partData) as unknown const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null if (!record) { return null } if (typeof record.text === 'string') { return record.text } return null } catch { return null } } function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { if ( !canCountOpenCodeMessages(db) || !tableExists(db, 'part') || !columnExists(db, 'message', 'id') || !columnExists(db, 'part', 'message_id') || !columnExists(db, 'part', 'time_created') || !columnExists(db, 'part', 'data') ) { return null } try { // Why: pin to the single earliest user message that actually has text parts, // then take all of its parts. Ordering parts across every user message would // pad a short first prompt with later turns and truncate a long one. const rows = db .prepare( `SELECT p.data AS part_data FROM part p WHERE p.message_id = ( SELECT m.id FROM message m JOIN part fp ON fp.message_id = m.id WHERE m.session_id = ? AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' ORDER BY m.time_created ASC, m.id ASC LIMIT 1 ) AND json_extract(p.data, '$.type') = 'text' ORDER BY p.time_created ASC, p.rowid ASC LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` ) .all(sessionId) as { part_data: string }[] const parts: string[] = [] for (const row of rows) { const text = extractPartText(row.part_data) if (text) { parts.push(text) } } if (parts.length === 0) { return null } return normalizeFullFirstUserPromptText(parts.join('\n')) } catch { return null } } function buildPreviewQuery(db: SyncDatabase): string | null { if ( !canCountOpenCodeMessages(db) || !tableExists(db, 'part') || !columnExists(db, 'message', 'id') || !columnExists(db, 'part', 'message_id') || !columnExists(db, 'part', 'time_created') || !columnExists(db, 'part', 'data') ) { return null } return `SELECT json_extract(m.data, '$.role') AS role, p.data AS part_data, p.time_created, json_extract(m.data, '$.summary.title') AS summary_title, json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id WHERE json_extract(m.data, '$.role') IN ('user','assistant') AND json_extract(p.data, '$.type') = 'text' ORDER BY p.time_created DESC LIMIT ?` } /** * Parse a single OpenCode session from the SQLite database into an * `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost) * and up to 5 preview messages by joining the `message` and `part` tables. * The database is opened read-only with `PRAGMA query_only = ON` as a * belt-and-suspenders guard against mutations. * @param args.dbPath - Absolute path to the opencode.db file. * @param args.sessionId - The session ID (primary key in the `session` table). * @param args.platform - The platform to use for resume command generation. * @returns The parsed `AiVaultSession`, or `null` if the session does not exist * or the database lacks the required schema. */ export async function parseOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform }): Promise { return readOpenCodeDatabase({ dbPath: args.dbPath, read: (db) => readSession({ db, ...args }) }) } // Extracted so the open wrapper owns the handle's lifetime. function readSession(args: { db: SyncDatabase dbPath: string sessionId: string platform: NodeJS.Platform }): AiVaultSession | null { const { db, dbPath, sessionId, platform } = args if (!canReadOpenCodeSessions(db)) { return null } const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined if (!row || row.id !== sessionId) { return null } const mtimeMs = typeof row.time_updated === 'number' && row.time_updated > 0 ? row.time_updated : row.time_created // Why: discovery uses a synthetic db#session path only for parser routing. // The UI's log open/reveal actions need a real filesystem path. const accumulator = createAccumulator({ agent: 'opencode', file: { path: dbPath, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }, sessionId }) accumulator.title = normalizeTitleText(row.title ?? '') accumulator.cwd = row.directory accumulator.model = extractModelId(row.model_json) accumulator.totalTokens = (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) accumulator.messageCount = row.message_count ?? 0 updateTimeline(accumulator, row.time_created) updateTimeline(accumulator, row.time_updated) const previewSql = buildPreviewQuery(db) if (previewSql) { // Why: SQL already dropped anything older than the newest-N window, so the // accumulator never shifts and cannot detect the truncation itself. Ask for // one extra row so an exactly-full window is not mistaken for a trimmed one. const probedRows = db .prepare(previewSql) .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { accumulator.previewMessagesTruncated = true } // Newest-first, so the probe row to drop is the oldest one at the tail. const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) // Why: query returns newest-first; push in chronological order so the // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT // messages. for (let i = previewRows.length - 1; i >= 0; i--) { const previewRow = previewRows[i] if (!previewRow) { continue } const text = extractPartText(previewRow.part_data) if (!text) { continue } addPreviewMessage(accumulator, { role: mapPreviewRole(previewRow.role), text, timestamp: previewRow.time_created, // Preview window is newest-N; first-prompt is loaded separately below. seedFirstUserPrompt: false }) if (previewRow.role === 'user' && !accumulator.title) { accumulator.title = normalizeTitleText(previewRow.summary_title ?? '') || normalizeTitleText(previewRow.summary_body ?? '') } } } // Why: list preview only joins the newest messages. On-demand copy needs the // session's earliest real user text part, not a later turn still in the window. if (shouldCaptureFullFirstUserPrompt()) { accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) } return finalizeSession(accumulator, platform) }