feat(native-chat): read ZCode session transcripts through the OpenCode SQLite reader

ZCode's CLI persists an OpenCode-v1-shaped history in its own SQLite store
(~/.zcode/cli/db/db.sqlite). The Agent Session History scan already lists
those sessions, but opening one in the native chat found nothing: the chat
transcript path had no ZCode awareness.

Resolve a zcode transcript against the ZCode database (native home, then WSL
homes) before any OpenCode install, tag every read (signal, page, subscribe)
with the agent, and apply the same hidden-transcript filter the scan uses so
ZCode's hidden bookkeeping rows stay out of chat renders.
This commit is contained in:
star-nebula
2026-10-10 17:05:02 -07:00
committed by Neil
parent 7d81ce26c4
commit ecc3433bdf
13 changed files with 166 additions and 42 deletions
@@ -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 }
}
@@ -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
@@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker(
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
},
signal?: AbortSignal
) {
@@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker(
args: {
dbPath: string
sessionId: string
agent?: 'zcode'
},
signal?: AbortSignal
) {
@@ -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'
@@ -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()
@@ -47,7 +56,10 @@ export async function discoverOpenCodeTranscriptDatabase(
try {
if (
await waitForPromiseWithSignal(
readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal),
readOpenCodeTranscriptSignalViaWorker(
{ dbPath, sessionId, ...(agent ? { agent } : {}) },
boundedSignal
),
boundedSignal
)
) {
@@ -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([]))
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
@@ -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
@@ -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 {
@@ -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 {
@@ -110,6 +124,7 @@ export function readOpenCodeTranscriptPage(args: {
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
}): OpenCodeTranscriptPage | null {
const db = openOpenCodeDatabaseReadonly(args.dbPath)
try {
@@ -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 ?`
)
@@ -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
@@ -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
@@ -117,7 +107,7 @@ export function subscribeOpenCodeNativeChatTranscript(
return
}
try {
dbPath ??= await resolveDbPath()
dbPath ??= await bound.resolveDbPath()
if (closed) {
return
}
@@ -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
}
@@ -141,7 +131,7 @@ export function subscribeOpenCodeNativeChatTranscript(
scheduleTick()
return
}
const page = await readPage({
const page = await bound.readPage({
dbPath,
sessionId: args.sessionId,
limit: firstSnapshot
@@ -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,
@@ -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
+53 -9
View File
@@ -20,11 +20,16 @@ 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: {
@@ -32,6 +37,7 @@ export type OpenCodeTranscriptDeps = {
sessionId: string
limit: number
beforeMessageRowId?: number
agent?: 'zcode'
},
signal?: AbortSignal
) => Promise<OpenCodeTranscriptPage | null>
@@ -43,17 +49,51 @@ 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> {
@@ -61,7 +101,8 @@ export async function readOpenCodeNativeChatTranscriptTail(
try {
const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
args.sessionId,
signal
signal,
args.agent
)
if (!dbPath) {
return { error: 'Transcript unavailable', notFound: true }
@@ -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
)
@@ -91,7 +133,8 @@ export async function readOpenCodeNativeChatTranscriptTail(
export async function readOpenCodeNativeChatTranscriptFull(
sessionId: string,
deps: OpenCodeTranscriptDeps = {},
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'zcode'
): Promise<ReadTranscriptResult> {
// Pages arrive newest-window first; reverse windows, preserving message order.
const pages: NativeChatMessage[][] = []
@@ -113,7 +156,8 @@ export async function readOpenCodeNativeChatTranscriptFull(
dbPath,
sessionId,
limit: 500,
...(cursor !== undefined ? { beforeMessageRowId: cursor } : {})
...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}),
...(agent ? { agent } : {})
},
signal
)
@@ -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
+6 -1
View File
@@ -45,7 +45,12 @@ export async function readNativeChatTranscript(
options: ReadTranscriptOptions = {}
): Promise<ReadTranscriptResult> {
if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
return readOpenCodeNativeChatTranscriptFull(sessionId)
return readOpenCodeNativeChatTranscriptFull(
sessionId,
{},
undefined,
agent === 'zcode' ? 'zcode' : undefined
)
}
let filePath: string | null
try {
@@ -246,7 +246,11 @@ export async function readNativeChatTranscriptTail(
| { error: string; notFound?: true }
> {
if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
return readOpenCodeNativeChatTranscriptTail(args, {}, signal)
return readOpenCodeNativeChatTranscriptTail(
{ ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined },
{},
signal
)
}
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
+6 -1
View File
@@ -267,7 +267,12 @@ export async function subscribeNativeChatTranscript(
): Promise<NativeChatTranscriptSubscription> {
setupSignal?.throwIfAborted()
if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
return subscribeOpenCodeNativeChatTranscript(args, setupSignal)
return subscribeOpenCodeNativeChatTranscript(
args,
setupSignal,
{},
args.agent === 'zcode' ? 'zcode' : undefined
)
}
const decode = nativeChatLineDecoderForAgent(args.agent)
if (!decode) {
@@ -15,6 +15,9 @@ describe('resolveNativeChatTranscriptAgent', () => {
it('passes codex, grok and omp through and rejects everything else', () => {
expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode')
expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode')
// ZCode keeps an OpenCode-v1-shaped history in its own SQLite DB, so its
// transcripts read through the OpenCode SQLite reader.
expect(resolveNativeChatTranscriptAgent('zcode')).toBe('opencode')
expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex')
expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok')
expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp')
@@ -28,6 +31,7 @@ describe('isNativeChatSupportedAgent', () => {
it('recognizes the parseable agents and rejects unknown / nullish input', () => {
expect(isNativeChatSupportedAgent('opencode')).toBe(true)
expect(isNativeChatSupportedAgent('opencode2')).toBe(true)
expect(isNativeChatSupportedAgent('zcode')).toBe(true)
expect(isNativeChatSupportedAgent('claude')).toBe(true)
expect(isNativeChatSupportedAgent('openclaude')).toBe(true)
expect(isNativeChatSupportedAgent('omp')).toBe(true)
@@ -43,6 +47,7 @@ describe('nativeChatRequiresLocalTranscript', () => {
// native chat has to find their file on a disk this process can read.
expect(nativeChatRequiresLocalTranscript('opencode')).toBe(true)
expect(nativeChatRequiresLocalTranscript('opencode2')).toBe(true)
expect(nativeChatRequiresLocalTranscript('zcode')).toBe(true)
expect(nativeChatRequiresLocalTranscript('grok')).toBe(true)
expect(nativeChatRequiresLocalTranscript('omp')).toBe(true)
expect(nativeChatRequiresLocalTranscript('claude')).toBe(false)
+8 -1
View File
@@ -11,7 +11,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [
'grok',
'omp',
'opencode',
'opencode2'
'opencode2',
'zcode'
]
export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet<string> = new Set(
@@ -48,6 +49,12 @@ export function resolveNativeChatTranscriptAgent(
if (agent === 'opencode' || agent === 'opencode2') {
return 'opencode'
}
// Why: ZCode's CLI stores an OpenCode-v1-shaped history in its own SQLite DB
// (~/.zcode/cli/db/db.sqlite), so its transcripts read through the same
// OpenCode SQLite reader as OpenCode 1.x — the agent identity stays zcode.
if (agent === 'zcode') {
return 'opencode'
}
if (agent === 'codex' || agent === 'grok' || agent === 'omp') {
return agent
}