Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/main/ai-vault-search/session-search-deleted-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,31 @@ it('keeps a synthetic row when this pass did not enumerate its container', async
).resolves.toMatchObject({ retired: [], unverifiable: [row] })
})

it('keeps a ZCode SQLite row when its database read failed during discovery', async () => {
const db = join(harness.root, 'db.sqlite')
await writeFile(db, '')
const row = `${db}#zcode-session`

const result = await retire([row], { roots: [] })
expect(result.retired).toEqual([])
expect(result.unverifiable).toEqual([row])
expect(removed).toEqual([])
})

it('retires a ZCode SQLite row only after a successful full database enumeration', async () => {
const db = join(harness.root, 'db.sqlite')
await writeFile(db, '')
const kept = `${db}#session-1`
const deleted = `${db}#session-2`

const result = await retire([kept, deleted], {
roots: [],
enumeratedContainers: new Map([[db, new Set(['session-1'])]])
})
expect(result.retired).toEqual([deleted])
expect(result.unverifiable).toEqual([])
})

it('retires a synthetic row when the container it came from is gone', async () => {
const db = join(harness.root, 'opencode.db')
await writeFile(db, '')
Expand Down
9 changes: 4 additions & 5 deletions src/main/ai-vault-search/session-search-synthetic-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'

/**
* A row whose path names a container and an entry inside it rather than a file
* of its own. OpenCode's SQLite sessions are the one shape today
* (`<opencode.db>#<sessionId>`), which is why this reads through that source's
* own splitter rather than reinventing the encoding.
* of its own. OpenCode and ZCode SQLite sessions share the same encoding,
* with distinct database names validated by the source splitter.
*/
export type SessionSearchSyntheticSource = { container: string; id: string }

export function splitSyntheticSessionSource(path: string): SessionSearchSyntheticSource | null {
const openCode = splitOpenCodeSqliteCandidate(path)
return openCode ? { container: openCode.dbPath, id: openCode.sessionId } : null
const sqlite = splitOpenCodeSqliteCandidate(path) ?? splitOpenCodeSqliteCandidate(path, 'zcode')
return sqlite ? { container: sqlite.dbPath, id: sqlite.sessionId } : null
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/main/ai-vault/session-first-user-prompt-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import {
parseOpenCodeSqliteSessionViaWorker,
parseOpenCode2SqliteSessionViaWorker
parseOpenCode2SqliteSessionViaWorker,
parseZcodeSqliteSessionViaWorker
} from './session-scanner-opencode-sqlite-worker-spawn'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type { FileWithMtime } from './session-scanner-types'
Expand Down Expand Up @@ -76,12 +77,14 @@ async function parseSessionForFullFirstUserPrompt(args: {
codexHome: string | null
}): Promise<AiVaultSession | null> {
// Full capture belongs inside the reader, including the guest reader for WSL.
if (args.agent === 'opencode' || args.agent === 'opencode2') {
if (args.agent === 'opencode' || args.agent === 'opencode2' || args.agent === 'zcode') {
const parse =
args.agent === 'opencode2'
? parseOpenCode2SqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath)
: args.agent === 'zcode'
? parseZcodeSqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath, args.agent)
if (fromSynthetic) {
return parse({
fullFirstUserPrompt: true,
Expand Down
20 changes: 16 additions & 4 deletions src/main/ai-vault/session-scanner-agent-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-
import {
captureOpenCodeSqliteSessionViaWorker,
captureOpenCode2SqliteSessionViaWorker,
captureZcodeSqliteSessionViaWorker,
parseOpenCode2SqliteSessionViaWorker,
parseOpenCodeSqliteSessionViaWorker
parseOpenCodeSqliteSessionViaWorker,
parseZcodeSqliteSessionViaWorker
} from './session-scanner-opencode-sqlite-worker-spawn'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
Expand All @@ -38,23 +40,27 @@ async function readOpenCodeSqliteCandidate(
platform: NodeJS.Platform,
messages?: TranscriptMessageSink,
signal?: AbortSignal,
agent?: 'opencode2'
agent?: 'opencode2' | 'zcode'
): Promise<AiVaultSession | null> {
throwIfSignalAborted(signal)
const request = { ...sqliteCandidate, platform, signal }
if (!messages?.active) {
const parse =
agent === 'opencode2'
? parseOpenCode2SqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
: agent === 'zcode'
? parseZcodeSqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
const session = await parse(request)
throwIfSignalAborted(signal)
return session
}
const capture =
agent === 'opencode2'
? captureOpenCode2SqliteSessionViaWorker
: captureOpenCodeSqliteSessionViaWorker
: agent === 'zcode'
? captureZcodeSqliteSessionViaWorker
: captureOpenCodeSqliteSessionViaWorker
const result = await capture(request)
for (const message of result.messages) {
throwIfSignalAborted(signal)
Expand Down Expand Up @@ -119,6 +125,12 @@ export async function parseAgentSessionFile(
}
return null
}
case 'zcode': {
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path, 'zcode')
return sqliteCandidate
? readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages, signal, 'zcode')
: null
}
case 'grok':
return parseGrokSessionFile(candidate.file, platform, messages)
case 'hermes':
Expand Down
1 change: 1 addition & 0 deletions src/main/ai-vault/session-scanner-codex-workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
museSessionsDir: join(root, 'muse-sessions'),
zcodeDbPath: join(root, 'zcode-db.sqlite'),
platform: 'darwin'
})

Expand Down
9 changes: 9 additions & 0 deletions src/main/ai-vault/session-scanner-every-agent-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from './session-scanner-test-fixtures'
import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures'
import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures'
import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'

// Why this is shared rather than inline in one test: it is the only place that
// writes one transcript in every supported agent's own format. A scan test and
Expand Down Expand Up @@ -34,5 +35,13 @@ export async function writeEveryAgentVault(root: string): Promise<EveryAgentVaul
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
await writeMuseScannerFixture(roots.museSessionsDir)
roots.opencodeDbPaths = [await writeOpenCode2SqliteFixture(root)]
writeOpenCodeSqliteDatabase(roots.zcodeDbPath, [
{
id: 'zcode-session',
directory: '/tmp/zcode',
title: 'ZCode title',
turns: [{ role: 'user', parts: ['ZCode session prompt'] }]
}
])
return { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile }
}
24 changes: 17 additions & 7 deletions src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-schema'
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
import { boundedText, toolCallText } from './session-transcript-message-content'
import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility'
import type SyncDatabase from '../sqlite/sync-database'

// Why: the session list needs the newest few messages, and the search index
Expand Down Expand Up @@ -119,7 +120,7 @@ function captureRole(role: string | null): TranscriptMessageRole | null {
return role === 'user' || role === 'assistant' ? role : null
}

function buildCaptureQuery(): string {
function buildCaptureQuery(agent: 'opencode' | 'zcode'): string {
// Message order, then part order within a message: the same key the preview
// read uses, run forwards and without the newest-N window.
return `SELECT m.id AS message_id,
Expand All @@ -130,6 +131,7 @@ function buildCaptureQuery(): string {
FROM message m
JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
${zcodeVisibleMessageFilter(agent)}
AND json_extract(m.data, '$.role') IN ('user','assistant')
AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES}
ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC
Expand All @@ -149,20 +151,24 @@ function buildCaptureQuery(): string {
*/
export function readOpenCodeSessionMessages(
db: SyncDatabase,
sessionId: string
sessionId: string,
agent: 'opencode' | 'zcode' = 'opencode'
): TranscriptMessage[] {
const agentName = agent === 'zcode' ? 'ZCode' : 'OpenCode'
if (!canReadOpenCodeMessageParts(db)) {
// Thrown for the same reason the part limit below throws: an empty capture
// returned here is committed under a complete-read cursor, so the session
// stays out of search with nothing on its row to say why and no retry.
throw new Error(
`OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.`
`${agentName} session ${sessionId} uses an unreadable message-part schema; its transcript was not read.`
)
}
const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1)
const rows = db
.prepare(buildCaptureQuery(agent))
.all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1)
if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) {
throw new Error(
`OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.`
`${agentName} session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.`
)
}

Expand All @@ -178,7 +184,7 @@ export function readOpenCodeSessionMessages(
captured += message.text.length
if (captured > OPENCODE_CAPTURE_TEXT_LIMIT) {
throw new Error(
`OpenCode session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.`
`${agentName} session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.`
)
}
messages.push(message)
Expand Down Expand Up @@ -237,13 +243,17 @@ export async function captureOpenCodeSqliteSession(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
agent?: 'opencode' | 'zcode'
}): Promise<OpenCodeSqliteCapture> {
return readOpenCodeDatabase({
dbPath: args.dbPath,
read: (db) => {
const session = readOpenCodeSqliteSession({ db, ...args })
// No session row is no transcript: the id names nothing in this database.
return { session, messages: session ? readOpenCodeSessionMessages(db, args.sessionId) : [] }
return {
session,
messages: session ? readOpenCodeSessionMessages(db, args.sessionId, args.agent) : []
}
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => {
return {
listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions,
listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions,
// ZCode reuses the OpenCode SQLite lister; the worker only varies the agent it stamps.
listZcodeSqliteSessionsViaWorker: (args: Parameters<typeof listOpenCodeSqliteSessions>[0]) =>
listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }),
parseOpenCodeSqliteSessionViaWorker: parseOpenCodeSqliteSession
}
})
Expand Down Expand Up @@ -54,6 +57,7 @@ function isolatedScanRoots(root: string) {
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
museSessionsDir: join(root, 'muse-sessions'),
zcodeDbPath: join(root, 'zcode-db.sqlite'),
ompSessionsDir: join(root, 'omp-sessions'),
primeAgentSessionsDir: join(root, 'prime-agent-sessions')
}
Expand Down
11 changes: 8 additions & 3 deletions src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ export async function handleOpenCodeSqliteRequest(
: await listOpenCodeSqliteSessions({
dbPaths: request.dbPaths,
limit: request.limit ?? Infinity,
issues
issues,
agent: request.agent === 'zcode' ? 'zcode' : 'opencode'
})
return { id: request.id, ok: true, value: { candidates, issues } }
}
if (request.kind === 'capture') {
const capture =
request.agent === 'opencode2'
? await captureOpenCode2SqliteSession(request)
: await captureOpenCodeSqliteSession(request)
: await captureOpenCodeSqliteSession({
...request,
agent: request.agent === 'zcode' ? 'zcode' : 'opencode'
})
return { id: request.id, ok: true, value: capture }
}
const parse = async () =>
Expand All @@ -51,7 +55,8 @@ export async function handleOpenCodeSqliteRequest(
: await parseOpenCodeSqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
platform: request.platform,
agent: request.agent === 'zcode' ? 'zcode' : 'opencode'
})
const session = request.fullFirstUserPrompt
? await withFullFirstUserPromptCapture(parse)
Expand Down
25 changes: 17 additions & 8 deletions src/main/ai-vault/session-scanner-opencode-sqlite-list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
Expand Down Expand Up @@ -54,13 +54,17 @@ function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] {
return (limited ? statement.all(limit) : statement.all()) as SessionRow[]
}

function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
function rowToCandidate(
row: SessionRow,
dbPath: string,
agent: 'opencode' | 'zcode'
): SessionFileCandidate {
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
return {
agent: 'opencode' as AiVaultAgent,
agent,
file: {
path: buildOpenCodeSqliteCandidatePath(dbPath, row.id),
mtimeMs,
Expand All @@ -70,10 +74,13 @@ function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
}
}

function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] {
function dedupeAndSortSqliteCandidates(
candidates: SessionFileCandidate[],
agent: 'opencode' | 'zcode'
): SessionFileCandidate[] {
const candidatesBySessionId = new Map<string, SessionFileCandidate>()
for (const candidate of candidates) {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, agent)
if (!parsed) {
continue
}
Expand Down Expand Up @@ -102,21 +109,23 @@ export async function listOpenCodeSqliteSessions(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
agent?: 'opencode' | 'zcode'
}): Promise<SessionFileCandidate[]> {
const candidates: SessionFileCandidate[] = []
const agent = args.agent ?? 'opencode'
for (const dbPath of args.dbPaths) {
try {
const rows = readOpenCodeDatabase({
dbPath,
read: (db) => readSessionRows(db, args.limit)
})
for (const row of rows) {
candidates.push(rowToCandidate(row, dbPath))
candidates.push(rowToCandidate(row, dbPath, agent))
}
} catch (err) {
// A whole DB failed, not one transcript: kinded so the panel says so.
args.issues.push(openCodeDatabaseScanIssue(dbPath, err))
args.issues.push(openCodeDatabaseScanIssue(dbPath, err, agent))
}
}
return dedupeAndSortSqliteCandidates(candidates)
return dedupeAndSortSqliteCandidates(candidates, agent)
}
Loading
Loading