feat(ai-vault): support OpenCode SQLite session storage (#5925)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Salah Alkhwlani 2026-06-23 21:18:17 +03:00 committed by GitHub
parent 8c4f57e8ef
commit 03fd869b92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1473 additions and 34 deletions

View File

@ -7,6 +7,8 @@ import {
parseRovoSessionFile
} from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import {
parseClaudeSessionFile,
parseCodexSessionFile,
@ -20,6 +22,15 @@ import {
} from './session-scanner-secondary-parsers'
import type { SessionFileCandidate } from './session-scanner-types'
/**
* Parse a single agent session file into an `AiVaultSession`. Routes to the
* appropriate agent-specific parser based on `candidate.agent`. For OpenCode
* SQLite candidates (synthetic `db#id` paths), routes to
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
* @param candidate - The session file candidate to parse.
* @param platform - The platform to use for resume command generation.
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
*/
export async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
@ -35,8 +46,20 @@ export async function parseAgentSessionFile(
return parseCopilotSessionFile(candidate.file, platform)
case 'cursor':
return parseCursorSessionFile(candidate.file, platform)
case 'opencode':
case 'opencode': {
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
// <dbPath>#<sessionId> candidate path. Legacy file-based sessions use
// real filesystem paths and fall through to the JSON parser.
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
if (sqliteCandidate) {
return parseOpenCodeSqliteSession({
dbPath: sqliteCandidate.dbPath,
sessionId: sqliteCandidate.sessionId,
platform
})
}
return parseOpenCodeSessionFile(candidate.file, platform)
}
case 'grok':
return parseGrokSessionFile(candidate.file, platform)
case 'hermes':

View File

@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
opencodeDbPaths: [],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),

View File

@ -0,0 +1,73 @@
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { dirname, join } from 'path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
export function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
return storageDirs.map(async (storageDir, index) =>
discoverOpenCodeSessions({
storageDir,
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
limitPerAgent: limit,
issues
})
)
}
function opencodeStorageDirs(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[]
): string[] {
return [
options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR,
...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage'))
]
}
async function opencodeDbPathsForSource(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
storageDir: string,
sourceIndex: number
): Promise<readonly string[]> {
if (options.opencodeDbPaths) {
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
}
if (sourceIndex === 0) {
return listOpenCodeDatabases()
}
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
return wslHomeDir
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
: []
}
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
try {
const entries = await readdir(dataDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDir, entry.name))
.sort()
} catch {
return []
}
}

View File

@ -0,0 +1,215 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import Database from '../sqlite/sync-database'
let tempRoots: string[] = []
let tempDbDirs: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
for (const dir of tempDbDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempRoots = []
tempDbDirs = []
})
function isolatedScanRoots(root: string) {
return {
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir: join(root, 'codex-sessions'),
geminiSessionsDir: join(root, 'gemini-sessions'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
}
}
function createTempOpenCodeDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-'))
tempDbDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function applyOpenCodeSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_archived INTEGER,
model TEXT,
agent TEXT,
cost REAL DEFAULT 0 NOT NULL,
tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL,
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
`)
}
describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => {
it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const opencodeDataDir = join(root, 'custom-opencode')
const opencodeStorageDir = join(opencodeDataDir, 'storage')
await mkdir(opencodeStorageDir, { recursive: true })
const dbPath = join(opencodeDataDir, 'opencode.db')
const db = new Database(dbPath)
applyOpenCodeSchema(db)
db.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode',
'Custom SQLite session', '1.0.0',
1777634010000, 1777634011000, NULL, 'build', 0,
8, 13, 21, 34, 0)`
).run()
db.close()
const result = await scanAiVaultSessions({
...roots,
opencodeStorageDir,
opencodeDbPaths: undefined,
platform: 'darwin',
limit: 50
})
const session = result.sessions.find((s) => s.sessionId === 'custom-db-session')
expect(session).toBeDefined()
expect(session!.agent).toBe('opencode')
expect(session!.title).toBe('Custom SQLite session')
expect(session!.filePath).toBe(dbPath)
expect(session!.totalTokens).toBe(42)
})
it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
// Legacy file session under storage/session/<projectId>/<sessionId>.json
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true })
await writeFile(
join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'),
JSON.stringify({
id: 'legacy-session',
directory: '/tmp/legacy',
title: 'Legacy file session',
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
})
)
await writeFile(
join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'),
JSON.stringify({
role: 'user',
summary: { title: 'Legacy file session' },
time: { created: 1_777_634_000_000 },
tokens: { input: 5, output: 2 }
})
)
// SQLite session — same sessionId as the legacy file (dedup should keep SQLite)
const { db, path: dbPath } = createTempOpenCodeDb()
applyOpenCodeSchema(db)
db.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0',
1777634002000, 1777634003000, ?, 'build', 0,
100, 40, 10, 5, 0)`
).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }))
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } }))
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
).run(JSON.stringify({ type: 'text', text: 'sqlite hello' }))
db.close()
// A second SQLite-only session
const { db: db2, path: dbPath2 } = createTempOpenCodeDb()
applyOpenCodeSchema(db2)
db2
.prepare(
`INSERT INTO session (id, project_id, slug, directory, title, version,
time_created, time_updated, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0',
1777634004000, 1777634005000, NULL, 'build', 0,
50, 20, 0, 0, 0)`
)
.run()
db2.close()
const result = await scanAiVaultSessions({
...roots,
opencodeDbPaths: [dbPath, dbPath2],
platform: 'darwin',
limit: 50
})
const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode')
const sessionIds = opencodeSessions.map((s) => s.sessionId).sort()
expect(sessionIds).toEqual(['legacy-session', 'sqlite-only'])
// Why: dedup keeps the SQLite entry (newer time_updated, source of truth)
const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session')
expect(legacyEntry).toBeDefined()
expect(legacyEntry!.title).toBe('SQLite session')
expect(legacyEntry!.cwd).toBe('/tmp/sqlite')
expect(legacyEntry!.filePath).toBe(dbPath)
expect(legacyEntry!.totalTokens).toBe(150)
expect(legacyEntry!.resumeCommand).toBe(
"cd '/tmp/sqlite' && opencode --session 'legacy-session'"
)
})
})

View File

@ -0,0 +1,242 @@
import { basename, extname, join } from 'path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { discoverFiles } from './session-scanner-discovery'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type {
FileWithMtime,
SessionFileCandidate,
SessionFileDiscovery
} from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: keep the SQLite discovery + dedup layer separate from the parser so
// each file stays under the max-lines lint rule and the discovery layer can
// be tested in isolation.
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
}
function openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
db.pragma('query_only = ON')
return db
}
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 canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionListQuery(db: SyncDatabase): string {
const modelSelect = sessionColumnSelect(db, 'model')
const agentSelect = sessionColumnSelect(db, 'agent')
const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read']
const tokenSelects = tokenColumns
.map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`)
.join(', ')
const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0'
const parentIdPredicate = columnExists(db, 'session', 'parent_id')
? 'AND s.parent_id IS NULL'
: ''
const archivedPredicate = columnExists(db, 'session', 'time_archived')
? 'AND s.time_archived IS NULL'
: ''
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,
${modelSelect} AS model_json, ${agentSelect} AS agent,
${tokenSelects}, ${costSelect} AS cost,
${messageCountSubquery} AS message_count
FROM session s
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
ORDER BY s.time_updated DESC
LIMIT ?`
}
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
return {
agent: 'opencode' as AiVaultAgent,
file: {
path: buildOpenCodeSqliteCandidatePath(dbPath, row.id),
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
codexHome: null
}
}
function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] {
const candidatesBySessionId = new Map<string, SessionFileCandidate>()
for (const candidate of candidates) {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
if (!parsed) {
continue
}
const previous = candidatesBySessionId.get(parsed.sessionId)
if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) {
candidatesBySessionId.set(parsed.sessionId, candidate)
}
}
return [...candidatesBySessionId.values()].sort((left, right) => {
return right.file.mtimeMs - left.file.mtimeMs
})
}
/**
* List OpenCode sessions from one or more SQLite databases as synthetic
* `SessionFileCandidate` entries. Each candidate's file path is a synthetic
* `<dbPath>#<sessionId>` string that the parser dispatcher routes to
* `parseOpenCodeSqliteSession`. Databases that lack the `session` table are
* silently skipped; errors are recorded as scan issues.
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
* @param args.limit - Maximum number of sessions to return per database.
* @param args.issues - Collected scan issues to append errors to.
* @returns Array of synthetic candidates sorted by `time_updated` DESC.
*/
export async function listOpenCodeSqliteSessions(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
}): Promise<SessionFileCandidate[]> {
const candidates: SessionFileCandidate[] = []
for (const dbPath of args.dbPaths) {
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
if (!canReadOpenCodeSessions(db)) {
continue
}
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
for (const row of rows) {
candidates.push(rowToCandidate(row, dbPath))
}
} catch (err) {
args.issues.push({
agent: 'opencode',
path: dbPath,
message: errorMessage(err)
})
} finally {
db?.close()
}
}
return dedupeAndSortSqliteCandidates(candidates)
}
// Why: extract the sessionId from a legacy file path like
// storage/session/<projectId>/<sessionId>.json. Falls back to the filename
// without extension when the opencode id format doesn't match a UUID.
function sessionIdFromLegacyFilePath(filePath: string): string {
return basename(filePath, extname(filePath))
}
/**
* Discover OpenCode sessions from both the legacy file layout and the SQLite
* DB, deduplicating at the file level before parsing. On mixed installs the
* same session may appear once via a stale legacy JSON file and once via the
* SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries
* whose sessionId matches a SQLite entry are dropped. Legacy installs without
* the `session` table fall through to the file scanner unchanged.
* @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`).
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
* @param args.limitPerAgent - Maximum number of candidates per source.
* @param args.issues - Collected scan issues to append errors to.
* @returns A `SessionFileDiscovery` with deduplicated file entries.
*/
export async function discoverOpenCodeSessions(args: {
storageDir: string
dbPaths: readonly string[]
limitPerAgent: number
issues: AiVaultScanIssue[]
}): Promise<SessionFileDiscovery> {
const [fileDiscovery, sqliteCandidates] = await Promise.all([
discoverFiles({
rootDir: join(args.storageDir, 'session'),
limit: args.limitPerAgent,
agent: 'opencode',
issues: args.issues,
extensions: ['.json']
}),
listOpenCodeSqliteSessions({
dbPaths: args.dbPaths,
limit: args.limitPerAgent,
issues: args.issues
})
])
const sqliteFiles = sqliteCandidates.map((c) => c.file)
// Why: on mixed installs the same OpenCode session may appear once via the
// SQLite DB and once via a stale legacy JSON file. SQLite is the source of
// truth on 1.17.x, so drop file-based duplicates when a SQLite entry with
// the same sessionId already exists. Deduping at the file level also avoids
// parsing the same session twice.
if (sqliteFiles.length === 0) {
return {
agent: 'opencode' as const,
rootDir: fileDiscovery.rootDir,
files: fileDiscovery.files
}
}
const sqliteSessionIds = new Set<string>()
for (const file of sqliteFiles) {
const parsed = splitOpenCodeSqliteCandidate(file.path)
if (parsed) {
sqliteSessionIds.add(parsed.sessionId)
}
}
const dedupedFileDiscovery: FileWithMtime[] = []
for (const file of fileDiscovery.files) {
if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) {
dedupedFileDiscovery.push(file)
}
}
return {
agent: 'opencode' as const,
rootDir: fileDiscovery.rootDir,
files: [...dedupedFileDiscovery, ...sqliteFiles]
}
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
buildOpenCodeSqliteCandidatePath,
looksLikeOpenCodeSqliteCandidate,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
describe('splitOpenCodeSqliteCandidate', () => {
it('splits a synthetic db#sessionId path', () => {
const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc')
expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' })
})
it('splits a stable-db path', () => {
const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz')
expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' })
})
it('rejects a path whose db basename is not opencode*.db', () => {
expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull()
expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull()
})
it('rejects a path without a separator', () => {
expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull()
})
it('rejects an empty sessionId', () => {
expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull()
})
})
describe('looksLikeOpenCodeSqliteCandidate', () => {
it('returns true for a synthetic path', () => {
expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true)
})
it('returns false for a real filesystem path', () => {
expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false)
})
})
describe('buildOpenCodeSqliteCandidatePath', () => {
it('joins dbPath and sessionId with #', () => {
expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1')
})
})

View File

@ -0,0 +1,56 @@
import { basename } from 'path'
// Why: keep the synthetic candidate-path helpers separate from the SQLite
// discovery/parser so both the scanner and the agent-parser dispatcher can
// import them without pulling in the SyncDatabase dependency.
const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
/**
* Build a synthetic candidate path that encodes the SQLite DB path and session ID
* as `<dbPath>#<sessionId>`. Used by the discovery layer so SQLite-backed
* sessions flow through the same FileWithMtime pipeline as file-backed ones.
* @param dbPath - Absolute path to the opencode.db file.
* @param sessionId - The OpenCode session ID (primary key in the session table).
* @returns The synthetic candidate path string.
*/
export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
}
/**
* Parse a synthetic candidate path back into its DB path and session ID parts.
* Validates that the DB basename matches `opencode*.db` so real filesystem paths
* that happen to contain `#` are never misrouted to the SQLite parser.
* @param candidatePath - The synthetic path to parse.
* @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise.
*/
export function splitOpenCodeSqliteCandidate(
candidatePath: string
): { dbPath: string; sessionId: string } | null {
const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
return null
}
const dbPath = candidatePath.slice(0, separatorIndex)
const sessionId = candidatePath.slice(separatorIndex + 1)
if (!dbPath || !sessionId) {
return null
}
// Why: OpenCode DB files are named opencode*.db; reject anything else so we
// never misroute a real filesystem path that happens to contain '#'.
if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) {
return null
}
return { dbPath, sessionId }
}
/**
* Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite
* candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null).
* @param candidatePath - The path to test.
* @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise.
*/
export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
return splitOpenCodeSqliteCandidate(candidatePath) !== null
}

View File

@ -0,0 +1,499 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
let tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function createTempDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
tempDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function applyOpenCodeSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER,
workspace_id TEXT,
path TEXT,
agent TEXT,
model TEXT,
cost REAL DEFAULT 0 NOT NULL,
tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL,
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL,
vcs TEXT,
name TEXT,
icon_url TEXT,
icon_color TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_initialized INTEGER,
sandboxes TEXT NOT NULL,
commands TEXT,
icon_url_override TEXT
);
`)
}
function applyMinimalOpenCodeSchema(db: Database.Database): void {
db.exec(`CREATE TABLE session (
id TEXT PRIMARY KEY,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);`)
}
function insertSession(
db: Database.Database,
args: {
id: string
title?: string
directory?: string
timeCreated: number
timeUpdated: number
parentId?: string | null
timeArchived?: number | null
model?: string | null
agent?: string | null
tokensInput?: number
tokensOutput?: number
tokensReasoning?: number
tokensCacheRead?: number
cost?: number
}
): void {
db.prepare(
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
time_created, time_updated, time_archived, model, agent, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0',
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, 0)`
).run(
args.id,
args.parentId ?? null,
`slug-${args.id}`,
args.directory ?? '/tmp/opencode',
args.title ?? 'OpenCode title',
args.timeCreated,
args.timeUpdated,
args.timeArchived ?? null,
args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }),
args.agent ?? 'build',
args.cost ?? 0,
args.tokensInput ?? 100,
args.tokensOutput ?? 40,
args.tokensReasoning ?? 10,
args.tokensCacheRead ?? 5
)
}
function insertMessage(
db: Database.Database,
args: {
id: string
sessionId: string
role: 'user' | 'assistant'
timeCreated: number
summaryTitle?: string | null
summaryBody?: string | null
}
): void {
const data = JSON.stringify({
role: args.role,
time: { created: args.timeCreated },
agent: 'build',
summary:
args.summaryTitle || args.summaryBody
? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null }
: undefined
})
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?)`
).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data)
}
function insertPart(
db: Database.Database,
args: {
id: string
messageId: string
sessionId: string
timeCreated: number
type?: 'text' | 'tool' | 'reasoning'
text?: string
}
): void {
const data = JSON.stringify({
type: args.type ?? 'text',
text: args.text ?? 'hello world'
})
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)`
).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data)
}
describe('listOpenCodeSqliteSessions', () => {
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_old',
title: 'Old',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertSession(db, {
id: 'ses_new',
title: 'New',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(2)
expect(candidates[0].agent).toBe('opencode')
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
})
it('dedups matching session ids across databases and keeps the newest row', async () => {
const { db: oldDb, path: oldPath } = createTempDb()
applyOpenCodeSchema(oldDb)
insertSession(oldDb, {
id: 'ses_duplicate',
title: 'Old duplicate',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
oldDb.close()
const { db: newDb, path: newPath } = createTempDb()
applyOpenCodeSchema(newDb)
insertSession(newDb, {
id: 'ses_duplicate',
title: 'New duplicate',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
newDb.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [oldPath, newPath],
limit: 10,
issues: []
})
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
})
it('excludes archived and child sessions', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_normal',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertSession(db, {
id: 'ses_archived',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_002_000,
timeArchived: 1_777_634_002_500
})
insertSession(db, {
id: 'ses_child',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_003_000,
parentId: 'ses_normal'
})
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates.map((c) => c.file.path)).toEqual([
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
])
})
it('returns [] when the session table is missing (legacy install)', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates).toEqual([])
})
it('records an issue when the DB file does not exist', async () => {
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: ['/nonexistent/opencode.db'],
limit: 10,
issues
})
expect(candidates).toEqual([])
expect(issues).toHaveLength(1)
expect(issues[0].agent).toBe('opencode')
expect(issues[0].path).toBe('/nonexistent/opencode.db')
})
it('lists sessions from a minimal readable session table', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
})
})
describe('parseOpenCodeSqliteSession', () => {
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_1',
title: 'OpenCode title',
directory: '/tmp/opencode',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
tokensInput: 100,
tokensOutput: 40,
tokensReasoning: 10,
tokensCacheRead: 5,
cost: 0.01
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_1',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryTitle: 'OpenCode title'
})
insertPart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_1',
timeCreated: 1_777_634_000_500,
text: 'Plan the work'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_1',
role: 'assistant',
timeCreated: 1_777_634_000_900
})
insertPart(db, {
id: 'prt_2',
messageId: 'msg_2',
sessionId: 'ses_1',
timeCreated: 1_777_634_001_000,
text: 'Done'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.agent).toBe('opencode')
expect(session!.sessionId).toBe('ses_1')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode title')
expect(session!.cwd).toBe('/tmp/opencode')
expect(session!.model).toBe('glm-5.2')
expect(session!.totalTokens).toBe(150)
expect(session!.messageCount).toBe(2)
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
expect(session!.previewMessages).toHaveLength(2)
expect(session!.previewMessages[0].text).toBe('Plan the work')
expect(session!.previewMessages[0].role).toBe('user')
expect(session!.previewMessages[1].text).toBe('Done')
expect(session!.previewMessages[1].role).toBe('assistant')
})
it('falls back to summary.body for title when session.title is empty', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_2',
title: '',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_2',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryBody: 'fallback title from summary'
})
insertPart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_2',
timeCreated: 1_777_634_000_500,
text: 'hello'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_2',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.title).toBe('fallback title from summary')
})
it('returns null when the session id is not found', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_real',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_missing',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('returns null when the DB has no session table', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('parses a minimal readable session table without optional columns or messages', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_minimal',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.sessionId).toBe('ses_minimal')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode ses_mini')
expect(session!.cwd).toBeNull()
expect(session!.model).toBeNull()
expect(session!.messageCount).toBe(0)
expect(session!.totalTokens).toBe(0)
expect(session!.previewMessages).toEqual([])
})
it('extracts model from older modelID schema', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_3',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_3',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.model).toBe('claude-sonnet-4-5')
})
})

View File

@ -0,0 +1,263 @@
import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import { normalizeTitleText } from './session-scanner-values'
import 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
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 openReadonlyDatabase(dbPath: string): SyncDatabase {
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
// Why: belt-and-suspenders guard so a bug in the SELECT list can never
// mutate the user's opencode.db.
db.pragma('query_only = ON')
return db
}
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<string, unknown>)
: 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<string, unknown>)
: null
if (!record) {
return null
}
if (typeof record.text === 'string') {
return record.text
}
return null
} 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 message m
JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
AND 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<AiVaultSession | null> {
const { dbPath, sessionId, platform } = args
let db: SyncDatabase | null = null
try {
db = openReadonlyDatabase(dbPath)
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) {
const previewRows = db
.prepare(previewSql)
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[]
// 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
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
normalizeTitleText(previewRow.summary_title ?? '') ||
normalizeTitleText(previewRow.summary_body ?? '')
}
}
}
return finalizeSession(accumulator, platform)
} finally {
db?.close()
}
}

View File

@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join(
'session-state'
)
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
const GROK_SESSIONS_DIR = join(
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
'sessions'
@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: {
...(options.additionalCodexSessionsDirs ?? [])
])
return Promise.all<SessionFileDiscovery>([
return Promise.all([
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
// and the SQLite scanner (1.17.x); dedup by sessionId happens inside.
...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues),
...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
@ -109,7 +110,6 @@ function standardDiscoveries(
discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] })
),
...cursorDiscoveries(options, wslHomeDirs, limit, issues),
...opencodeDiscoveries(options, wslHomeDirs, limit, issues),
...grokDiscoveries(options, wslHomeDirs, limit, issues),
...devinDiscoveries(options, wslHomeDirs, limit, issues),
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
@ -139,21 +139,6 @@ function cursorDiscoveries(
)
}
function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return sessionRootDirs(
join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
wslHomeDirs,
['.local', 'share', 'opencode', 'storage', 'session']
).map((rootDir) =>
discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] })
)
}
function grokDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],

View File

@ -14,6 +14,9 @@ export type AiVaultScanOptions = {
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
// Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB
// here so they don't depend on the real ~/.local/share/opencode.
opencodeDbPaths?: readonly string[]
grokSessionsDir?: string
devinTranscriptsDir?: string
hermesSessionsDir?: string

View File

@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) {
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
// Why: prevent the SQLite scanner from picking up the real
// ~/.local/share/opencode/opencode.db during tests.
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),

View File

@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
/**
* Scan all supported AI agent session stores and return a unified, sorted,
* deduplicated list of sessions for the AI Vault panel. Discovers sessions
* from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based
* stores (OpenCode 1.17.x). Results are sorted by session sort time DESC
* and truncated to `limit`.
* @param options - Optional scan configuration (limits, custom dirs, platform).
* @returns The list of sessions, scan issues, and a timestamp.
*/
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {

View File

@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import Database from '../sqlite/sync-database'
import { columnExists, tableExists } from './schema-helpers'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import type {
OpenCodeUsageAttributedEvent,
@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
function tableExists(db: Database.Database, tableName: string): boolean {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { found?: number } | undefined
return row?.found === 1
}
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
return rows.some((row) => row.name === columnName)
}
function getProjectJoin(db: Database.Database): string {
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
? 'LEFT JOIN project p ON p.id = s.project_id'

View File

@ -0,0 +1,31 @@
import type SyncDatabase from '../sqlite/sync-database'
// Why: OpenCode's usage scanner and the AI Vault session scanner both need to
// probe the opencode.db schema shape across multiple DB generations. Centralizing
// the probes here avoids two private copies and keeps the contract testable.
type Database = SyncDatabase.Database
/**
* Check whether a table exists in the given SQLite database.
* @param db - A readonly or read-write SyncDatabase instance.
* @param tableName - The table name to look up in sqlite_master.
* @returns `true` if the table exists, `false` otherwise.
*/
export function tableExists(db: Database, tableName: string): boolean {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { found?: number } | undefined
return row?.found === 1
}
/**
* Check whether a column exists on a table in the given SQLite database.
* @param db - A readonly or read-write SyncDatabase instance.
* @param tableName - The table to inspect via PRAGMA table_info.
* @param columnName - The column name to find.
* @returns `true` if the column exists on the table, `false` otherwise.
*/
export function columnExists(db: Database, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
return rows.some((row) => row.name === columnName)
}