Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest(
if (request.kind === 'native-page' || request.kind === 'native-signal') {
const value =
request.kind === 'native-signal'
? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId)
? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent)
: readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 })
return { id: request.id, ok: true, value }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = {
sessionId: string
limit?: number
beforeMessageRowId?: number
/** When 'zcode', reads apply ZCode's hidden-transcript filter. */
agent?: 'zcode'
}

export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker(
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
},
signal?: AbortSignal
) {
Expand All @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker(
args: {
dbPath: string
sessionId: string
agent?: 'zcode'
},
signal?: AbortSignal
) {
Expand Down
42 changes: 40 additions & 2 deletions src/main/native-chat/transcript-opencode-database.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
Expand All @@ -13,9 +14,17 @@ import {
listOpenCodeDatabasesInDirectory
} from '../opencode-usage/opencode-database-discovery'

// ZCode's CLI keeps its OpenCode-v1-shaped history in its own database, so a
// zcode transcript must never be claimed by an unrelated OpenCode install (the
// same separation the AI Vault scanner draws in session-scanner-zcode-sources).
function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] {
return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite'))
}

export async function discoverOpenCodeTranscriptDatabase(
sessionId?: string,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'zcode'
): Promise<string | null> {
signal?.throwIfAborted()
const deadline = new AbortController()
Expand Down Expand Up @@ -47,7 +56,10 @@ export async function discoverOpenCodeTranscriptDatabase(
try {
if (
await waitForPromiseWithSignal(
readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal),
readOpenCodeTranscriptSignalViaWorker(
{ dbPath, sessionId, ...(agent ? { agent } : {}) },
boundedSignal
),
boundedSignal
)
) {
Expand All @@ -61,6 +73,32 @@ export async function discoverOpenCodeTranscriptDatabase(
return null
}
try {
// A zcode transcript only ever lives in a ZCode database: probe those first
// and never fall through to OpenCode installs that cannot hold the session.
if (agent === 'zcode') {
const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)
const native = await findSession(zcodeTranscriptDatabasePaths([]))
Comment on lines +79 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Probe the native ZCode database before querying WSL homes.

If WSL-home discovery stalls, this await reaches the five-second discovery deadline before findSession checks a usable native ZCode database. The native transcript then fails to open even though it is available. Move getAiVaultWslHomeDirs() after the native probe, as the existing OpenCode branch does.

if (native) {
return native
}
const readerRoots = homes
if (readerRoots.length > 0) {
const readers = await waitForPromiseWithSignal(
prepareOpenCodeWslReaders(readerRoots),
boundedSignal
)
boundedSignal.throwIfAborted()
configureOpenCodeWslReaders(readers)
}
const wsl = await findSession(zcodeTranscriptDatabasePaths(homes))
if (wsl) {
return wsl
}
if (refusals[0]) {
throw refusals[0]
}
return null
}
const primary = await waitForPromiseWithSignal(
listOpenCodeDatabases(onRefusal, undefined, boundedSignal),
boundedSignal
Expand Down
25 changes: 21 additions & 4 deletions src/main/native-chat/transcript-opencode-sqlite-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
} from './transcript-opencode-part-blocks'
// Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2.

// ZCode stores hidden bookkeeping transcripts in the same message table; the
// same predicate the AI Vault scan applies keeps them out of chat renders.
const ZCODE_HIDDEN_MESSAGE_FILTER =
"AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'"

export type OpenCodeTranscriptItem = {
rowid: number
fingerprint: string
Expand Down Expand Up @@ -70,7 +75,8 @@ function rowsWithinBudget<T>(

export function readOpenCodeTranscriptSignal(
dbPath: string,
sessionId: string
sessionId: string,
agent?: 'zcode'
): OpenCodeTranscriptSignal | null {
const db = openOpenCodeDatabaseReadonly(dbPath)
try {
Expand All @@ -84,14 +90,22 @@ export function readOpenCodeTranscriptSignal(
// Aggregates without GROUP BY always yield exactly one row, so [0] is it.
const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>(
db.prepare(
'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?'
`SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ? ${
agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
}`
),
sessionId
)
const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>(
db.prepare(
'SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated FROM part WHERE session_id = ?'
`SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated
FROM part WHERE session_id = ? AND message_id IN (
SELECT id FROM message WHERE session_id = ? ${
agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
}
)`
),
sessionId,
sessionId
)
return {
Expand All @@ -110,6 +124,7 @@ export function readOpenCodeTranscriptPage(args: {
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
}): OpenCodeTranscriptPage | null {
const db = openOpenCodeDatabaseReadonly(args.dbPath)
try {
Expand All @@ -127,7 +142,9 @@ export function readOpenCodeTranscriptPage(args: {
const select = db.prepare(
`SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data
FROM message
WHERE session_id = ? AND rowid < ?
WHERE session_id = ? AND rowid < ? ${
args.agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
}
ORDER BY rowid DESC
LIMIT ?`
)
Expand Down
32 changes: 11 additions & 21 deletions src/main/native-chat/transcript-opencode-subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query'
import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode'
import { bindOpenCodeTranscriptDeps, type OpenCodeTranscriptDeps } from './transcript-opencode'
import {
openCodeTranscriptPageLimit,
OPENCODE_TRANSCRIPT_MAX_WINDOW
Expand All @@ -31,23 +31,13 @@ export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCach
export function subscribeOpenCodeNativeChatTranscript(
args: SubscribeNativeChatTranscriptArgs,
setupSignal?: AbortSignal,
deps: OpenCodeTranscriptDeps = {}
deps: OpenCodeTranscriptDeps = {},
// When 'zcode': discovery probes ZCode's database, reads drop hidden rows.
agent?: 'zcode'
): NativeChatTranscriptSubscription {
setupSignal?.throwIfAborted()
const controller = new AbortController()
const resolveDbPath = () =>
(deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
args.sessionId,
controller.signal
)
const readSignal = (dbPath: string, sessionId: string) =>
(deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)(
dbPath,
sessionId,
controller.signal
)
const readPage = (page: Parameters<NonNullable<OpenCodeTranscriptDeps['readPage']>>[0]) =>
(deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal)
const bound = bindOpenCodeTranscriptDeps(deps, controller.signal, agent)
const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS
const initialLimit = openCodeTranscriptPageLimit(
args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW
Expand Down Expand Up @@ -117,7 +107,7 @@ export function subscribeOpenCodeNativeChatTranscript(
return
}
try {
dbPath ??= await resolveDbPath()
dbPath ??= await bound.resolveDbPath()
if (closed) {
return
}
Expand All @@ -126,7 +116,7 @@ export function subscribeOpenCodeNativeChatTranscript(
scheduleTick()
return
}
const signal = await readSignal(dbPath, args.sessionId)
const signal = await bound.readSignal(dbPath, args.sessionId)
if (closed) {
return
}
Expand All @@ -141,7 +131,7 @@ export function subscribeOpenCodeNativeChatTranscript(
scheduleTick()
return
}
const page = await readPage({
const page = await bound.readPage({
dbPath,
sessionId: args.sessionId,
limit: firstSnapshot
Expand Down Expand Up @@ -193,7 +183,7 @@ export function subscribeOpenCodeNativeChatTranscript(
const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest)
if (displaced.length > 0) {
// Verify the displaced fringe: cap eviction and a real deletion look identical in the tail.
const older = await readPage({
const older = await bound.readPage({
dbPath,
sessionId: args.sessionId,
limit: displaced.length,
Expand Down Expand Up @@ -267,14 +257,14 @@ export function subscribeOpenCodeNativeChatTranscript(
// overlap, so the gap retries instead of dropping rows.
async function replaceWithBridgedWindow(
db: string,
page: Awaited<ReturnType<typeof readPage>>,
page: Awaited<ReturnType<typeof bound.readPage>>,
shrinking = false
): Promise<boolean> {
let limit = Math.max(windowLimit, page?.items.length ?? 0)
for (;;) {
const replacement =
page ??
(await readPage({
(await bound.readPage({
dbPath: db,
sessionId: args.sessionId,
limit
Expand Down
62 changes: 53 additions & 9 deletions src/main/native-chat/transcript-opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,24 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa
// OpenCode SQLite reads use the same bounded worker as AI Vault.

export type OpenCodeTranscriptDeps = {
resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise<string | null>
resolveDbPath?: (
sessionId?: string,
signal?: AbortSignal,
agent?: 'zcode'
) => Promise<string | null>
readSignal?: (
dbPath: string,
sessionId: string,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'zcode'
) => Promise<OpenCodeTranscriptSignal | null>
readPage?: (
args: {
dbPath: string
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
},
signal?: AbortSignal
) => Promise<OpenCodeTranscriptPage | null>
Expand All @@ -43,25 +49,60 @@ export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise<str

export const openCodeTranscriptDefaultDeps: Required<OpenCodeTranscriptDeps> = {
resolveDbPath: discoverOpenCodeTranscriptDatabase,
readSignal: (dbPath, sessionId, signal) =>
readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal),
readSignal: (dbPath, sessionId, signal, agent) =>
readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId, agent }, signal),
readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal)
}

/**
* Resolve a subscribe's read legs against its own abort signal, with the agent
* tag (when any) already applied to every leg. Kept here so the poll loop stays
* free of default-vs-override plumbing.
*/
export function bindOpenCodeTranscriptDeps(
deps: OpenCodeTranscriptDeps,
signal: AbortSignal,
agent?: 'zcode'
): {
resolveDbPath: () => Promise<string | null>
readSignal: (dbPath: string, sessionId: string) => Promise<OpenCodeTranscriptSignal | null>
readPage: (
page: Parameters<NonNullable<OpenCodeTranscriptDeps['readPage']>>[0]
) => Promise<OpenCodeTranscriptPage | null>
} {
return {
resolveDbPath: () =>
(deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(undefined, signal, agent),
readSignal: (dbPath, sessionId) =>
(deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)(
dbPath,
sessionId,
signal,
agent
),
readPage: (page) =>
(deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(
{ ...page, ...(agent ? { agent } : {}) },
signal
)
}
}

export type OpenCodeTailResult =
| { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number }
| { error: string; notFound?: true }

export async function readOpenCodeNativeChatTranscriptTail(
args: { sessionId: string; limit: number; beforeOffset?: number },
args: { sessionId: string; limit: number; beforeOffset?: number; agent?: 'zcode' },
deps: OpenCodeTranscriptDeps = {},
signal?: AbortSignal
): Promise<OpenCodeTailResult> {
const limit = openCodeTranscriptPageLimit(args.limit > 0 ? args.limit : DESKTOP_READ_WINDOW)
try {
const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
args.sessionId,
signal
signal,
args.agent
)
if (!dbPath) {
return { error: 'Transcript unavailable', notFound: true }
Expand All @@ -71,7 +112,8 @@ export async function readOpenCodeNativeChatTranscriptTail(
dbPath,
sessionId: args.sessionId,
limit,
...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {})
...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}),
...(args.agent ? { agent: args.agent } : {})
},
signal
)
Expand All @@ -91,7 +133,8 @@ export async function readOpenCodeNativeChatTranscriptTail(
export async function readOpenCodeNativeChatTranscriptFull(
sessionId: string,
deps: OpenCodeTranscriptDeps = {},
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'zcode'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass agent to full-read database discovery.

When agent is 'zcode', readOpenCodeNativeChatTranscriptFull passes the selector to page reads but not to resolveDbPath at Line 145. Discovery then searches OpenCode stores. A ZCode-only session returns “Transcript unavailable”; a matching session ID in an OpenCode store can select the wrong conversation. Pass agent as the third discovery argument. This also fixes the full reads called by src/main/native-chat/transcript-reader.ts and src/main/native-chat/transcript-read-cache.ts.

): Promise<ReadTranscriptResult> {
// Pages arrive newest-window first; reverse windows, preserving message order.
const pages: NativeChatMessage[][] = []
Expand All @@ -113,7 +156,8 @@ export async function readOpenCodeNativeChatTranscriptFull(
dbPath,
sessionId,
limit: 500,
...(cursor !== undefined ? { beforeMessageRowId: cursor } : {})
...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}),
...(agent ? { agent } : {})
},
signal
)
Expand Down
7 changes: 6 additions & 1 deletion src/main/native-chat/transcript-read-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ export async function readNativeChatTranscriptCached(
transcriptPath?: string
): Promise<ReadTranscriptResult> {
if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
return readOpenCodeNativeChatTranscriptFull(sessionId)
return readOpenCodeNativeChatTranscriptFull(
sessionId,
{},
undefined,
agent === 'zcode' ? 'zcode' : undefined
)
}
const epoch = cacheEpoch
let filePath: string | null
Expand Down
Loading
Loading