From a0ed253a2313b35e43dfb997988a56bf0a183409 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 09:58:26 +0800 Subject: [PATCH 1/5] feat(ai-vault): index ZCode CLI sessions via SQLite worker --- .../session-first-user-prompt-read.ts | 11 +- .../ai-vault/session-scanner-agent-parser.ts | 20 ++- .../session-scanner-every-agent-fixture.ts | 9 ++ ...session-scanner-opencode-sqlite-capture.ts | 16 +- ...ession-scanner-opencode-sqlite-dispatch.ts | 11 +- .../session-scanner-opencode-sqlite-list.ts | 23 ++- .../session-scanner-opencode-sqlite-open.ts | 21 ++- .../session-scanner-opencode-sqlite-paths.ts | 9 +- ...n-scanner-opencode-sqlite-process-entry.ts | 2 +- ...n-scanner-opencode-sqlite-worker-client.ts | 6 +- ...scanner-opencode-sqlite-worker-protocol.ts | 6 +- ...on-scanner-opencode-sqlite-worker-spawn.ts | 30 +++- .../session-scanner-opencode-sqlite.ts | 24 ++- .../ai-vault/session-scanner-parse-cache.ts | 1 + .../session-scanner-source-discovery.ts | 2 + .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 2 + .../ai-vault/session-scanner-zcode-sources.ts | 51 ++++++ .../ai-vault/session-scanner-zcode.test.ts | 148 ++++++++++++++++++ src/main/ai-vault/session-scanner.test.ts | 9 +- ...ion-transcript-every-agent-capture.test.ts | 11 +- src/relay/ai-vault-opencode-reader.test.ts | 4 +- src/shared/ai-vault-resume-command.ts | 1 + src/shared/ai-vault-types.ts | 2 + 24 files changed, 369 insertions(+), 51 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-zcode-sources.ts create mode 100644 src/main/ai-vault/session-scanner-zcode.test.ts diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index cf1a5bb2bede..c0aa1b8a2197 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -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' @@ -76,12 +77,14 @@ async function parseSessionForFullFirstUserPrompt(args: { codexHome: string | null }): Promise { // 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, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index c95dd8d7eac8..80a56bf2519d 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -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' @@ -38,7 +40,7 @@ async function readOpenCodeSqliteCandidate( platform: NodeJS.Platform, messages?: TranscriptMessageSink, signal?: AbortSignal, - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' ): Promise { throwIfSignalAborted(signal) const request = { ...sqliteCandidate, platform, signal } @@ -46,7 +48,9 @@ async function readOpenCodeSqliteCandidate( const parse = agent === 'opencode2' ? parseOpenCode2SqliteSessionViaWorker - : parseOpenCodeSqliteSessionViaWorker + : agent === 'zcode' + ? parseZcodeSqliteSessionViaWorker + : parseOpenCodeSqliteSessionViaWorker const session = await parse(request) throwIfSignalAborted(signal) return session @@ -54,7 +58,9 @@ async function readOpenCodeSqliteCandidate( const capture = agent === 'opencode2' ? captureOpenCode2SqliteSessionViaWorker - : captureOpenCodeSqliteSessionViaWorker + : agent === 'zcode' + ? captureZcodeSqliteSessionViaWorker + : captureOpenCodeSqliteSessionViaWorker const result = await capture(request) for (const message of result.messages) { throwIfSignalAborted(signal) @@ -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': diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 5ae04c11b570..23caa8e348d5 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -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 @@ -34,5 +35,13 @@ export async function writeEveryAgentVault(root: string): Promise OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.` @@ -237,13 +241,17 @@ export async function captureOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): Promise { 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) : [] + } } }) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index 7632aa070c04..e483f9482e11 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -30,7 +30,8 @@ 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 } } } @@ -38,7 +39,10 @@ export async function handleOpenCodeSqliteRequest( 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 () => @@ -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) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 93f9daab98cf..ecb04e243f9a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -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: agent as AiVaultAgent, file: { path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), mtimeMs, @@ -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() for (const candidate of candidates) { - const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, agent) if (!parsed) { continue } @@ -102,8 +109,10 @@ export async function listOpenCodeSqliteSessions(args: { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] + agent?: 'opencode' | 'zcode' }): Promise { const candidates: SessionFileCandidate[] = [] + const agent = args.agent ?? 'opencode' for (const dbPath of args.dbPaths) { try { const rows = readOpenCodeDatabase({ @@ -111,12 +120,12 @@ export async function listOpenCodeSqliteSessions(args: { 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) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts index 43e32da2ff5e..32f807cb1d9f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -82,8 +82,13 @@ export function readOpenCodeDatabase(args: { * @param error - The thrown value from the read. * @returns A kinded scan issue with actionable copy. */ -export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue { +export function openCodeDatabaseScanIssue( + dbPath: string, + error: unknown, + agent: 'opencode' | 'zcode' = 'opencode' +): AiVaultScanIssue { const name = basename(dbPath) + const label = agent === 'zcode' ? 'ZCode' : 'OpenCode' // `fileMustExist` already proved the database file is there before SQLite ran, // so this needs no fs probe of its own — an extra sync stat on a 9p share is // exactly the hang the WSL transcript gate exists to prevent. @@ -96,18 +101,18 @@ export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVau const overWslShare = isWslUncPath(dbPath) const detail = kind === 'contended' && !overWslShare - ? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` + ? `${label} is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` : kind === 'unreadable' - ? `OpenCode history in ${name} could not be read: ${errorMessage(error)}` - : unreadableShareDetail(dbPath, name) - return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail } + ? `${label} history in ${name} could not be read: ${errorMessage(error)}` + : unreadableShareDetail(dbPath, name, label) + return { agent, kind: 'scope', path: dbPath, message: detail } } -function unreadableShareDetail(dbPath: string, name: string): string { +function unreadableShareDetail(dbPath: string, name: string, label: string): string { // A known limitation, not a failure the user can act on: nothing they do on // the Windows side makes the share hand out SQLite's locks. Deliberately not // "flush the write-ahead log" either — checkpointing changes nothing here. return isWslUncPath(dbPath) - ? "OpenCode sessions inside WSL can't be searched from Windows yet." - : `OpenCode history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.` + ? `${label} sessions inside WSL can't be searched from Windows yet.` + : `${label} history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit ${label} cleanly to flush the log.` } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts index 7f0a61a674a5..77c0e464154b 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -26,7 +26,8 @@ export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: stri * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. */ export function splitOpenCodeSqliteCandidate( - candidatePath: string + candidatePath: string, + agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode' ): { dbPath: string; sessionId: string } | null { const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { @@ -39,7 +40,11 @@ export function splitOpenCodeSqliteCandidate( } // 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))) { + const validName = + agent === 'zcode' + ? basename(dbPath).toLowerCase() === 'db.sqlite' + : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath)) + if (!validName) { return null } return { dbPath, sessionId } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts index 9c197b1ecea7..e53d89eeeaba 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts @@ -16,7 +16,7 @@ function validRequest(value: unknown): value is OpenCodeSqliteWorkerRequest { !('id' in value) || !Number.isSafeInteger(value.id) || !('kind' in value) || - ('agent' in value && value.agent !== 'opencode2') || + ('agent' in value && value.agent !== 'opencode2' && value.agent !== 'zcode') || ('timeoutMs' in value && (typeof value.timeoutMs !== 'number' || !Number.isFinite(value.timeoutMs) || diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index 498bb9a65f5e..c5f96c030e3b 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -98,7 +98,7 @@ export class OpenCodeSqliteWorkerClient { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { if (args.dbPaths.length === 0) { @@ -161,7 +161,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { try { @@ -201,7 +201,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { try { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index d44bd62c11ab..9c8e42a5c7f4 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -12,7 +12,7 @@ export type OpenCodeSqliteListRequest = { dbPaths: readonly string[] limit: number | null /** When 'opencode2', lists from the v2 channel-scoped DB schema (session_v2). */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } export type OpenCodeSqliteParseRequest = { @@ -23,7 +23,7 @@ export type OpenCodeSqliteParseRequest = { sessionId: string platform: NodeJS.Platform /** When 'opencode2', parses from the v2 channel-scoped DB schema (session_v2). */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } // Same arguments as `parse`, different answer: the session plus every message @@ -36,7 +36,7 @@ export type OpenCodeSqliteCaptureRequest = { sessionId: string platform: NodeJS.Platform /** When 'opencode2', captures from the v2 channel-scoped schema. */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } export type OpenCodeSqliteWorkerRequest = ( diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index 1b3b1faf7ce7..68b1bacf6981 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -81,6 +81,15 @@ export function listOpenCode2SqliteSessionsViaWorker(args: { return listForHost({ ...args, agent: 'opencode2' }) } +export function listZcodeSqliteSessionsViaWorker(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] + signal?: AbortSignal +}): Promise { + return listForHost({ ...args, agent: 'zcode' }) +} + /** * Parse one OpenCode SQLite session through the shared worker client. * @param args.dbPath - Absolute path to the opencode.db file. @@ -108,6 +117,16 @@ export function parseOpenCode2SqliteSessionViaWorker(args: { return parseForHost({ ...args, agent: 'opencode2' }) } +export function parseZcodeSqliteSessionViaWorker(args: { + fullFirstUserPrompt?: boolean + dbPath: string + sessionId: string + platform: NodeJS.Platform + signal?: AbortSignal +}): Promise { + return parseForHost({ ...args, agent: 'zcode' }) +} + /** * Read one OpenCode SQLite session and its whole transcript through the shared * worker client. @@ -134,6 +153,15 @@ export function captureOpenCode2SqliteSessionViaWorker(args: { return captureForHost({ ...args, agent: 'opencode2' }) } +export function captureZcodeSqliteSessionViaWorker(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform + signal?: AbortSignal +}): Promise { + return captureForHost({ ...args, agent: 'zcode' }) +} + async function listForHost( args: Parameters[0] ): Promise { @@ -160,7 +188,7 @@ async function listForHost( const client = await openCodeWslClient(distro, first, args.signal) const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues }) return result.flatMap((candidate) => { - const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent) const original = parsed && paths.get(parsed.dbPath) return parsed && original ? [ diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 291a4ce0e884..a375a187efb5 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -74,10 +74,11 @@ function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' } -function buildSessionQuery(db: SyncDatabase): string { +function buildSessionQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id + ${agent === 'zcode' ? "AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} AND json_extract(m.data, '$.role') IN ('user','assistant'))` : '0' return `SELECT s.id, @@ -150,7 +151,11 @@ export function extractPartText(partData: string): string | null { } } -function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { +function readFirstUserPromptFromOpenCodeDb( + db: SyncDatabase, + sessionId: string, + agent: 'opencode' | 'zcode' +): string | null { if (!canReadOpenCodeMessageParts(db)) { return null } @@ -168,6 +173,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): FROM message m JOIN part fp ON fp.message_id = m.id WHERE m.session_id = ? + ${agent === 'zcode' ? "AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' ORDER BY m.time_created ASC, m.id ASC @@ -195,7 +201,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): } } -function buildPreviewQuery(db: SyncDatabase): string | null { +function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string | null { if (!canReadOpenCodeMessageParts(db)) { return null } @@ -206,6 +212,7 @@ function buildPreviewQuery(db: SyncDatabase): string | null { json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? + ${agent === 'zcode' ? "AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id @@ -231,6 +238,7 @@ export async function parseOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): Promise { return readOpenCodeDatabase({ dbPath: args.dbPath, @@ -245,12 +253,14 @@ export function readOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): AiVaultSession | null { const { db, dbPath, sessionId, platform } = args if (!canReadOpenCodeSessions(db)) { return null } - const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + const agent = args.agent ?? 'opencode' + const row = db.prepare(buildSessionQuery(db, agent)).get(sessionId) as SessionRow | undefined if (!row || row.id !== sessionId) { return null } @@ -262,7 +272,7 @@ export function readOpenCodeSqliteSession(args: { // 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', + agent: args.agent ?? 'opencode', file: { path: dbPath, mtimeMs, @@ -279,7 +289,7 @@ export function readOpenCodeSqliteSession(args: { updateTimeline(accumulator, row.time_created) updateTimeline(accumulator, row.time_updated) - const previewSql = buildPreviewQuery(db) + const previewSql = buildPreviewQuery(db, agent) if (previewSql) { // Why: SQL already dropped anything older than the newest-N window, so the // accumulator never shifts and cannot detect the truncation itself. Ask for @@ -322,7 +332,7 @@ export function readOpenCodeSqliteSession(args: { // Why: list preview only joins the newest messages. On-demand copy needs the // session's earliest real user text part, not a later turn still in the window. if (shouldCaptureFullFirstUserPrompt()) { - accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId, agent) } return finalizeSession(accumulator, platform) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a9dff0039485..4610c198cb37 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -81,6 +81,7 @@ function resumableStateFactoryFor( case 'muse': case 'opencode': case 'opencode2': + case 'zcode': case 'rovo': return null } diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 5bb53a605ba2..62b2e0562a8d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -2,6 +2,7 @@ import { delimiter } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { discoverFiles } from './session-scanner-discovery' import { opencodeDiscoveries } from './session-scanner-opencode-sources' +import { zcodeDiscoveries } from './session-scanner-zcode-sources' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources' import { normalizedWslHomeDirs } from './session-scanner-roots' @@ -28,6 +29,7 @@ export async function discoverAiVaultSessionSources(args: { // 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), + ...zcodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...antigravityDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...Object.entries(AI_VAULT_AGENT_SOURCES).flatMap(([agent, source]) => source diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 7146d237bf78..43d03d340dde 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -63,6 +63,7 @@ export function isolatedScanRoots(root: string) { // Why: prevent the SQLite scanner from picking up the real // ~/.local/share/opencode/opencode.db during tests. opencodeDbPaths: [] as readonly string[], + zcodeDbPath: join(root, 'zcode', 'db.sqlite'), grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 75a102835624..099b3593431b 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -29,6 +29,8 @@ export type AiVaultScanOptions = { // 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[] + /** Test override for the ZCode CLI's OpenCode-shaped SQLite database. */ + zcodeDbPath?: string grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-zcode-sources.ts b/src/main/ai-vault/session-scanner-zcode-sources.ts new file mode 100644 index 000000000000..ad90043eb987 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-sources.ts @@ -0,0 +1,51 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' +import { wslGatedAccess } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { listZcodeSqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +// ZCode's CLI stores its OpenCode-shaped history in a separate database. +// Keep discovery separate so an OpenCode install cannot absorb ZCode rows. +export function zcodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const paths = options.zcodeDbPath + ? [options.zcodeDbPath] + : [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) + return paths.map(async (dbPath) => ({ + agent: 'zcode' as const, + rootDir: dbPath, + files: (await zcodeDatabaseExists(dbPath, issues, options.signal)) + ? ( + await listZcodeSqliteSessionsViaWorker({ + dbPaths: [dbPath], + limit, + issues, + signal: options.signal + }) + ).map((candidate) => candidate.file) + : [] + })) +} + +async function zcodeDatabaseExists( + dbPath: string, + issues: AiVaultScanIssue[], + signal?: AbortSignal +): Promise { + try { + return await wslGatedAccess(dbPath, 'scan', signal) + } catch (error) { + throwIfSignalAborted(signal) + if (error instanceof WslTranscriptFsError) { + issues.push({ agent: 'zcode', path: dbPath, message: error.message }) + } + return false + } +} diff --git a/src/main/ai-vault/session-scanner-zcode.test.ts b/src/main/ai-vault/session-scanner-zcode.test.ts new file mode 100644 index 000000000000..29ce505e42a6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { handleOpenCodeSqliteRequest } from './session-scanner-opencode-sqlite-dispatch' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' +import SyncDatabase from '../sqlite/sync-database' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } + directories.length = 0 +}) + +describe('ZCode AI Vault SQLite worker', () => { + it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'zcode-session', + directory: '/repo', + title: 'ZCode task', + turns: [ + { role: 'user', parts: ['Fix the import'] }, + { role: 'assistant', parts: ['Import fixed'] } + ] + } + ]) + + const listed = await handleOpenCodeSqliteRequest({ + id: 1, + kind: 'list', + agent: 'zcode', + dbPaths: [dbPath], + limit: 10 + }) + expect(listed.ok).toBe(true) + if (!listed.ok) { + return + } + const candidates = (listed.value as { candidates: { agent: string; file: { path: string } }[] }) + .candidates + expect(candidates).toHaveLength(1) + expect(candidates[0]?.agent).toBe('zcode') + expect(splitOpenCodeSqliteCandidate(candidates[0]!.file.path, 'zcode')).toEqual({ + dbPath, + sessionId: 'zcode-session' + }) + expect(splitOpenCodeSqliteCandidate(candidates[0]!.file.path)).toBeNull() + + const parsed = await handleOpenCodeSqliteRequest({ + id: 2, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'zcode-session', + platform: 'darwin' + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + agent: 'zcode', + title: 'ZCode task', + resumeCommand: "cd '/repo' && zcode --resume 'zcode-session'" + } + }) + + const captured = await handleOpenCodeSqliteRequest({ + id: 3, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'zcode-session', + platform: 'darwin' + }) + expect(captured).toMatchObject({ ok: true, value: { session: { agent: 'zcode' } } }) + if (!captured.ok) { + return + } + expect((captured.value as { messages: { text: string }[] }).messages).toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Fix the import' })]) + ) + }) + + it('excludes ZCode hidden transcript messages from preview and search', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'visible-session', + turns: [ + { role: 'user', parts: ['internal reminder'] }, + { role: 'user', parts: ['visible request'] }, + { role: 'assistant', parts: ['visible response'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + db.prepare( + "UPDATE message SET data = json_set(data, '$.semantics.transcriptVisibility', 'hidden') WHERE id LIKE '%-msg-0-%'" + ).run() + } finally { + db.close() + } + + const parsed = await handleOpenCodeSqliteRequest({ + id: 4, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'visible-session', + platform: 'darwin', + fullFirstUserPrompt: true + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + messageCount: 2, + firstUserPrompt: 'visible request' + } + }) + if (!parsed.ok) { + return + } + expect(JSON.stringify(parsed.value)).not.toContain('internal reminder') + + const captured = await handleOpenCodeSqliteRequest({ + id: 5, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'visible-session', + platform: 'darwin' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(JSON.stringify(captured.value)).not.toContain('internal reminder') + }) +}) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index b8963ce80b8b..5bbe8d097513 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -23,9 +23,15 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { listOpenCodeSqliteSessionsViaWorker: ( args: Parameters[0] ) => v1List.listOpenCodeSqliteSessions(args), + listZcodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => v1List.listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => v1Parse.parseOpenCodeSqliteSession(args), + parseZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => v1Parse.parseOpenCodeSqliteSession({ ...args, agent: 'zcode' }), listOpenCode2SqliteSessionsViaWorker: ( args: Parameters[0] ) => v2List.listOpenCode2SqliteSessions(args), @@ -400,7 +406,7 @@ describe('scanAiVaultSessions', () => { await writeEveryAgentVault(root) await writeMuseScannerFixture(roots.museSessionsDir) - const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 25 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -428,6 +434,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('opencode2')).toBe( "cd '/tmp/opencode2' && opencode2 --standalone --session 'opencode2-session'" ) + expect(commandByAgent.get('zcode')).toBe("cd '/tmp/zcode' && zcode --resume 'zcode-session'") expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'") expect(commandByAgent.get('hermes')).toBe( "cd '/tmp/hermes' && hermes --resume 'hermes-session'" diff --git a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts index 0d135cdb7e84..20b28fdb49be 100644 --- a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts +++ b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts @@ -20,18 +20,27 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { listOpenCode2SqliteSessionsViaWorker: ( args: Parameters[0] ) => list2.listOpenCode2SqliteSessions(args), + listZcodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => list.listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => parse.parseOpenCodeSqliteSession(args), parseOpenCode2SqliteSessionViaWorker: ( args: Parameters[0] ) => parse2.parseOpenCode2SqliteSession(args), + parseZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => parse.parseOpenCodeSqliteSession({ ...args, agent: 'zcode' }), captureOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => capture.captureOpenCodeSqliteSession(args), captureOpenCode2SqliteSessionViaWorker: ( args: Parameters[0] - ) => capture2.captureOpenCode2SqliteSession(args) + ) => capture2.captureOpenCode2SqliteSession(args), + captureZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => capture.captureOpenCodeSqliteSession({ ...args, agent: 'zcode' }) } }) import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types' diff --git a/src/relay/ai-vault-opencode-reader.test.ts b/src/relay/ai-vault-opencode-reader.test.ts index 981ade1fd128..3d082205d5d4 100644 --- a/src/relay/ai-vault-opencode-reader.test.ts +++ b/src/relay/ai-vault-opencode-reader.test.ts @@ -223,11 +223,11 @@ describe('relay OpenCode reader', () => { list: (args) => args.agent === 'opencode2' ? listOpenCode2SqliteSessions(args) - : listOpenCodeSqliteSessions(args), + : listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }), parse: async (args) => args.agent === 'opencode2' ? parseOpenCode2SqliteSession(args) - : parseOpenCodeSqliteSession(args), + : parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }), dispose() {} }) const provider = createRelayAiVaultFilesystemProvider({ diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index fbd0658def12..cfff419c95c9 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -219,6 +219,7 @@ function buildAgentResumeInvocation( case 'cline': return `${baseCommand} --id ${sessionArg}` case 'claude': + case 'zcode': case 'cursor': case 'gemini': case 'grok': diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b2635555bf67..28f93dcfbe19 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'copilot', 'opencode', 'opencode2', + 'zcode', 'grok', 'openclaw', 'devin', @@ -62,6 +63,7 @@ export const AI_VAULT_AGENT_LABELS = { copilot: 'GitHub Copilot', opencode: 'OpenCode', opencode2: 'OpenCode 2', + zcode: 'ZCode', grok: 'Grok', openclaw: 'OpenClaw', devin: 'Devin', From 352540e344c210b635b2fcc639d5fc7972851eeb Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 10:39:31 +0800 Subject: [PATCH 2/5] refactor(ai-vault): share ZCode transcript visibility filter --- .../ai-vault/session-scanner-opencode-sqlite-capture.ts | 3 ++- src/main/ai-vault/session-scanner-opencode-sqlite.ts | 7 ++++--- src/main/ai-vault/session-scanner-zcode-visibility.ts | 9 +++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-zcode-visibility.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index 1db0eee114a1..a799b8b3863a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -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 @@ -130,7 +131,7 @@ function buildCaptureQuery(agent: 'opencode' | 'zcode'): string { FROM message m JOIN part p ON p.message_id = m.id WHERE m.session_id = ? - ${agent === 'zcode' ? "AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} + ${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 diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index a375a187efb5..d62653eb817b 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -15,6 +15,7 @@ import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-schema' import { normalizeTitleText } from './session-scanner-values' +import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -78,7 +79,7 @@ function buildSessionQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id - ${agent === 'zcode' ? "AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} + ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') IN ('user','assistant'))` : '0' return `SELECT s.id, @@ -173,7 +174,7 @@ function readFirstUserPromptFromOpenCodeDb( FROM message m JOIN part fp ON fp.message_id = m.id WHERE m.session_id = ? - ${agent === 'zcode' ? "AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} + ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' ORDER BY m.time_created ASC, m.id ASC @@ -212,7 +213,7 @@ function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? - ${agent === 'zcode' ? "AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" : ''} + ${zcodeVisibleMessageFilter(agent, 'data')} ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id diff --git a/src/main/ai-vault/session-scanner-zcode-visibility.ts b/src/main/ai-vault/session-scanner-zcode-visibility.ts new file mode 100644 index 000000000000..06c0446b0e09 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-visibility.ts @@ -0,0 +1,9 @@ +/** Keep ZCode's hidden transcript messages out of every AI Vault read path. */ +export function zcodeVisibleMessageFilter( + agent: 'opencode' | 'zcode', + column: 'm.data' | 'data' = 'm.data' +): string { + return agent === 'zcode' + ? `AND COALESCE(json_extract(${column}, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'` + : '' +} From f2b5cda10b9a8da8bf24bf3c275099f90e156ea4 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 11:36:13 +0800 Subject: [PATCH 3/5] fix(ai-vault): retain unreadable ZCode SQLite sessions --- .../session-search-deleted-sources.test.ts | 25 +++++++++++ .../session-search-synthetic-sources.ts | 9 ++-- ...session-scanner-opencode-sqlite-capture.ts | 7 ++-- .../session-scanner-zcode-sources.test.ts | 41 +++++++++++++++++++ .../ai-vault/session-scanner-zcode-sources.ts | 10 +++-- 5 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-zcode-sources.test.ts diff --git a/src/main/ai-vault-search/session-search-deleted-sources.test.ts b/src/main/ai-vault-search/session-search-deleted-sources.test.ts index 5fd4e5cd6303..39fbda9bb140 100644 --- a/src/main/ai-vault-search/session-search-deleted-sources.test.ts +++ b/src/main/ai-vault-search/session-search-deleted-sources.test.ts @@ -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, '') diff --git a/src/main/ai-vault-search/session-search-synthetic-sources.ts b/src/main/ai-vault-search/session-search-synthetic-sources.ts index 86222b9c5fe4..59541e7924f4 100644 --- a/src/main/ai-vault-search/session-search-synthetic-sources.ts +++ b/src/main/ai-vault-search/session-search-synthetic-sources.ts @@ -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 - * (`#`), 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 } /** diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index a799b8b3863a..ef15b368ef34 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -154,12 +154,13 @@ export function readOpenCodeSessionMessages( 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 @@ -167,7 +168,7 @@ export function readOpenCodeSessionMessages( .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.` ) } @@ -183,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) diff --git a/src/main/ai-vault/session-scanner-zcode-sources.test.ts b/src/main/ai-vault/session-scanner-zcode-sources.test.ts new file mode 100644 index 000000000000..4ed6667342fe --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-sources.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedAccess } from '../native-chat/wsl-transcript-fs-access' +import { zcodeDiscoveries } from './session-scanner-zcode-sources' + +vi.mock('../native-chat/wsl-transcript-fs-access', () => ({ wslGatedAccess: vi.fn() })) + +const access = vi.mocked(wslGatedAccess) + +afterEach(() => access.mockReset()) + +it.each(['EACCES', 'EPERM', 'EIO'])( + 'reports a ZCode database %s error instead of treating it as absent', + async (code) => { + access.mockRejectedValueOnce( + Object.assign(new Error(`Cannot read database: ${code}`), { code }) + ) + const issues: AiVaultScanIssue[] = [] + + const [discovery] = await Promise.all( + zcodeDiscoveries({ zcodeDbPath: '/example/db.sqlite' }, [], 10, issues) + ) + + expect(discovery?.files).toEqual([]) + expect(issues).toEqual([ + { agent: 'zcode', path: '/example/db.sqlite', message: `Cannot read database: ${code}` } + ]) + } +) + +it('silently skips a ZCode database that does not exist', async () => { + access.mockRejectedValueOnce(Object.assign(new Error('No such file'), { code: 'ENOENT' })) + const issues: AiVaultScanIssue[] = [] + + const [discovery] = await Promise.all( + zcodeDiscoveries({ zcodeDbPath: '/example/db.sqlite' }, [], 10, issues) + ) + + expect(discovery?.files).toEqual([]) + expect(issues).toEqual([]) +}) diff --git a/src/main/ai-vault/session-scanner-zcode-sources.ts b/src/main/ai-vault/session-scanner-zcode-sources.ts index ad90043eb987..68b013712e9d 100644 --- a/src/main/ai-vault/session-scanner-zcode-sources.ts +++ b/src/main/ai-vault/session-scanner-zcode-sources.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { wslGatedAccess } from '../native-chat/wsl-transcript-fs-access' -import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { listZcodeSqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' @@ -43,9 +42,14 @@ async function zcodeDatabaseExists( return await wslGatedAccess(dbPath, 'scan', signal) } catch (error) { throwIfSignalAborted(signal) - if (error instanceof WslTranscriptFsError) { - issues.push({ agent: 'zcode', path: dbPath, message: error.message }) + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false } + issues.push({ + agent: 'zcode', + path: dbPath, + message: error instanceof Error ? error.message : String(error) + }) return false } } From 66b2db3a4adc9545e7e30e1292c0ce351c2aa4b3 Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 27 Sep 2026 22:36:43 -0700 Subject: [PATCH 4/5] fix(ai-vault): narrow SQLite rows instead of asserting, and keep tests off the real ZCode db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the changed-code quality gate and two regressions this branch introduced. Type assertions replaced with checks rather than suppressions. `SqliteStatement` returns `Record`, so the column types were only a claim about the query until something verified them — a drifted schema, or a database another tool wrote, reaches the same code. `readSessionRow` and `readPartDataRow` do that check once; the worker-dispatch tests narrow through small readers instead of casting a payload that deliberately crosses a process boundary as `unknown`. Two tests were failing before these edits: - `session-scanner-opencode-sqlite-coexistence` mocks the worker-spawn module and did not export the newly added `listZcodeSqliteSessionsViaWorker`, so the scan threw. ZCode reuses the OpenCode lister, so the mock routes it the same way. - `session-scanner-codex-workers` passes an explicit directory for every other agent but none for ZCode, and the discovery falls back to `homedir()`. On a machine with ZCode installed the test read the developer's real `~/.zcode/cli/db/db.sqlite`; CI passed only because runners have none. Pinned to the temp root like every sibling source. Co-authored-by: guanbear --- .../session-scanner-codex-workers.test.ts | 1 + ...canner-opencode-sqlite-coexistence.test.ts | 4 ++ .../session-scanner-opencode-sqlite-list.ts | 4 +- .../session-scanner-opencode-sqlite.ts | 38 +++++++++++++- .../ai-vault/session-scanner-zcode.test.ts | 50 +++++++++++++++++-- 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 79271cbf39eb..4d001d00422c 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -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' }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index ff51677d27f0..e2fbcb225129 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -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[0]) => + listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: parseOpenCodeSqliteSession } }) @@ -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') } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index ecb04e243f9a..7e739ba22381 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -1,4 +1,4 @@ -import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { buildOpenCodeSqliteCandidatePath, splitOpenCodeSqliteCandidate @@ -64,7 +64,7 @@ function rowToCandidate( ? row.time_updated : row.time_created return { - agent: agent as AiVaultAgent, + agent, file: { path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), mtimeMs, diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index d62653eb817b..90fe4366cad3 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -17,6 +17,8 @@ import { import { normalizeTitleText } from './session-scanner-values' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' import type SyncDatabase from '../sqlite/sync-database' +import type { SqliteRow } from '../sqlite/sqlite-statement' +import type { SQLOutputValue } from 'node:sqlite' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files @@ -35,6 +37,36 @@ const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 // Bounds a pathological single message; a real typed prompt is a handful of parts. const FIRST_USER_PROMPT_PART_LIMIT = 512 +// Why guards and not casts: `SqliteStatement` hands back `Record`, +// so the column types are only a claim about the query until something checks them. A row +// from a schema that drifted, or from a database another tool wrote, reaches here too. +function readPartDataRow(row: SqliteRow): { part_data: string } | null { + return typeof row.part_data === 'string' ? { part_data: row.part_data } : null +} + +function readSessionRow(row: SqliteRow | undefined): SessionRow | null { + if (!row || typeof row.id !== 'string') { + return null + } + const text = (value: SQLOutputValue): string | null => (typeof value === 'string' ? value : null) + const count = (value: SQLOutputValue): number => (typeof value === 'number' ? value : 0) + return { + id: row.id, + title: text(row.title), + directory: text(row.directory), + time_created: count(row.time_created), + time_updated: count(row.time_updated), + model_json: text(row.model_json), + agent: text(row.agent), + tokens_input: count(row.tokens_input), + tokens_output: count(row.tokens_output), + tokens_reasoning: count(row.tokens_reasoning), + tokens_cache_read: count(row.tokens_cache_read), + cost: count(row.cost), + message_count: count(row.message_count) + } +} + type SessionRow = { id: string title: string | null @@ -184,7 +216,9 @@ function readFirstUserPromptFromOpenCodeDb( ORDER BY p.time_created ASC, p.rowid ASC LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` ) - .all(sessionId) as { part_data: string }[] + .all(sessionId) + .map(readPartDataRow) + .filter((row): row is { part_data: string } => row !== null) const parts: string[] = [] for (const row of rows) { @@ -261,7 +295,7 @@ export function readOpenCodeSqliteSession(args: { return null } const agent = args.agent ?? 'opencode' - const row = db.prepare(buildSessionQuery(db, agent)).get(sessionId) as SessionRow | undefined + const row = readSessionRow(db.prepare(buildSessionQuery(db, agent)).get(sessionId)) if (!row || row.id !== sessionId) { return null } diff --git a/src/main/ai-vault/session-scanner-zcode.test.ts b/src/main/ai-vault/session-scanner-zcode.test.ts index 29ce505e42a6..4783b2134506 100644 --- a/src/main/ai-vault/session-scanner-zcode.test.ts +++ b/src/main/ai-vault/session-scanner-zcode.test.ts @@ -7,6 +7,47 @@ import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite- import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' import SyncDatabase from '../sqlite/sync-database' +// Why readers and not casts: the worker dispatch returns `value: unknown` on purpose — it +// crosses a process boundary. Narrowing here keeps the repo's no-assertions rule and fails +// the test with a readable message if the payload shape ever changes. +function readCandidates(value: unknown): { agent: string; path: string }[] { + if (typeof value !== 'object' || value === null || !('candidates' in value)) { + throw new Error('expected a listing payload with candidates') + } + const { candidates } = value + if (!Array.isArray(candidates)) { + throw new Error('expected candidates to be an array') + } + return candidates.map((candidate) => { + const agent = readStringField(candidate, 'agent') + const file = isRecord(candidate) ? candidate['file'] : null + return { agent, path: readStringField(file, 'path') } + }) +} + +function readMessageTexts(value: unknown): { text: string }[] { + if (typeof value !== 'object' || value === null || !('messages' in value)) { + throw new Error('expected a capture payload with messages') + } + const { messages } = value + if (!Array.isArray(messages)) { + throw new Error('expected messages to be an array') + } + return messages.map((message) => ({ text: readStringField(message, 'text') })) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readStringField(value: unknown, field: string): string { + const read = isRecord(value) ? value[field] : null + if (typeof read !== 'string') { + throw new Error(`expected a string \`${field}\``) + } + return read +} + const directories: string[] = [] afterEach(() => { for (const directory of directories) { @@ -43,15 +84,14 @@ describe('ZCode AI Vault SQLite worker', () => { if (!listed.ok) { return } - const candidates = (listed.value as { candidates: { agent: string; file: { path: string } }[] }) - .candidates + const candidates = readCandidates(listed.value) expect(candidates).toHaveLength(1) expect(candidates[0]?.agent).toBe('zcode') - expect(splitOpenCodeSqliteCandidate(candidates[0]!.file.path, 'zcode')).toEqual({ + expect(splitOpenCodeSqliteCandidate(candidates[0]!.path, 'zcode')).toEqual({ dbPath, sessionId: 'zcode-session' }) - expect(splitOpenCodeSqliteCandidate(candidates[0]!.file.path)).toBeNull() + expect(splitOpenCodeSqliteCandidate(candidates[0]!.path)).toBeNull() const parsed = await handleOpenCodeSqliteRequest({ id: 2, @@ -82,7 +122,7 @@ describe('ZCode AI Vault SQLite worker', () => { if (!captured.ok) { return } - expect((captured.value as { messages: { text: string }[] }).messages).toEqual( + expect(readMessageTexts(captured.value)).toEqual( expect.arrayContaining([expect.objectContaining({ text: 'Fix the import' })]) ) }) From 5e65fb15472c858681912591ef3fce645d3b0eb4 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 14:03:47 +0800 Subject: [PATCH 5/5] fix(ai-vault): keep OpenCode SQLite parser within line budget --- .../session-scanner-opencode-sqlite-rows.ts | 48 ++++++++++++++++++ .../session-scanner-opencode-sqlite.ts | 49 +------------------ 2 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts new file mode 100644 index 000000000000..6d72cc4f64e1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts @@ -0,0 +1,48 @@ +import type { SqliteRow } from '../sqlite/sqlite-statement' +import type { SQLOutputValue } from 'node:sqlite' + +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 +} + +// Why guards and not casts: `SqliteStatement` hands back `Record`, +// so the column types are only a claim about the query until something checks them. A row +// from a schema that drifted, or from a database another tool wrote, reaches here too. +export function readPartDataRow(row: SqliteRow): { part_data: string } | null { + return typeof row.part_data === 'string' ? { part_data: row.part_data } : null +} + +export function readSessionRow(row: SqliteRow | undefined): SessionRow | null { + if (!row || typeof row.id !== 'string') { + return null + } + const text = (value: SQLOutputValue): string | null => (typeof value === 'string' ? value : null) + const count = (value: SQLOutputValue): number => (typeof value === 'number' ? value : 0) + return { + id: row.id, + title: text(row.title), + directory: text(row.directory), + time_created: count(row.time_created), + time_updated: count(row.time_updated), + model_json: text(row.model_json), + agent: text(row.agent), + tokens_input: count(row.tokens_input), + tokens_output: count(row.tokens_output), + tokens_reasoning: count(row.tokens_reasoning), + tokens_cache_read: count(row.tokens_cache_read), + cost: count(row.cost), + message_count: count(row.message_count) + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 90fe4366cad3..4e3d8bc74b83 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -10,6 +10,7 @@ import { shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' +import { readPartDataRow, readSessionRow } from './session-scanner-opencode-sqlite-rows' import { canCountOpenCodeMessages, canReadOpenCodeMessageParts @@ -17,8 +18,6 @@ import { import { normalizeTitleText } from './session-scanner-values' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' import type SyncDatabase from '../sqlite/sync-database' -import type { SqliteRow } from '../sqlite/sqlite-statement' -import type { SQLOutputValue } from 'node:sqlite' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files @@ -37,52 +36,6 @@ const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 // Bounds a pathological single message; a real typed prompt is a handful of parts. const FIRST_USER_PROMPT_PART_LIMIT = 512 -// Why guards and not casts: `SqliteStatement` hands back `Record`, -// so the column types are only a claim about the query until something checks them. A row -// from a schema that drifted, or from a database another tool wrote, reaches here too. -function readPartDataRow(row: SqliteRow): { part_data: string } | null { - return typeof row.part_data === 'string' ? { part_data: row.part_data } : null -} - -function readSessionRow(row: SqliteRow | undefined): SessionRow | null { - if (!row || typeof row.id !== 'string') { - return null - } - const text = (value: SQLOutputValue): string | null => (typeof value === 'string' ? value : null) - const count = (value: SQLOutputValue): number => (typeof value === 'number' ? value : 0) - return { - id: row.id, - title: text(row.title), - directory: text(row.directory), - time_created: count(row.time_created), - time_updated: count(row.time_updated), - model_json: text(row.model_json), - agent: text(row.agent), - tokens_input: count(row.tokens_input), - tokens_output: count(row.tokens_output), - tokens_reasoning: count(row.tokens_reasoning), - tokens_cache_read: count(row.tokens_cache_read), - cost: count(row.cost), - message_count: count(row.message_count) - } -} - -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