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:
parent
8c4f57e8ef
commit
03fd869b92
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
|
@ -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'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue