Files
orca/src/main/native-chat/transcript-read-cache.ts
T
star-nebula ecc3433bdf 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.
2026-10-10 17:05:02 -07:00

194 lines
8.2 KiB
TypeScript

import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode'
import type { AgentType } from '../../shared/native-chat-types'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
import { wslGatedStat } from './wsl-transcript-fs-access'
import {
isWslTranscriptFsRefusalMessage,
WslTranscriptFsError,
wslTranscriptFsRefusal
} from './wsl-transcript-fs-error'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
// host-filesystem transcript, so a single process-global cache keyed by the
// RESOLVED transcript file path maximizes the hit rate across desktop + every
// paired web/mobile client (all clients of one session resolve the same path
// against this runtime's home). Keying by connection instead would defeat the
// multi-client case this feature targets and multiply memory by the connection
// count. The key is the resolved file path, NOT `agent:sessionId`: two panes can
// share one sessionId yet resolve to DIFFERENT files (the same session resumed
// into a second worktree, which writes a new transcript file), and a
// sessionId-only key let one worktree's cached parse be served to another when
// their file mtimes momentarily coincided (#7326). The cache stores ONE
// canonical, unwindowed parse; windowing and per-surface truncation stay in the
// callers so the same parse is reused across all `limit` values and every client kind.
type CachedTranscript = {
result: ReadTranscriptResult
/** mtime of the resolved file when cached; changed mtime or size invalidates it. */
mtimeMs: number
/** On-disk byte size of the resolved file — a cheap, monotonic proxy for this
* entry's parsed memory footprint, used to bound the cache by total bytes. */
bytes: number
readOrder: number
}
const cache = new Map<string, CachedTranscript>()
const inFlightReads = new InFlightPromiseDedupe<ReadTranscriptResult>()
let cacheEpoch = 0
let nextReadOrder = 0
// Why: cap the cache so a long-lived process browsing many sessions can't grow
// it unbounded. Map preserves insertion order, so evicting the first key drops
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
const MAX_CACHE_ENTRIES = 50
// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool
// results embed whole file contents, command output, and diffs), and each cached
// entry is the full unwindowed parse. The count cap alone let 50 such entries
// retain multiple GB in the one process that now serves desktop + every paired
// web/mobile client. Bound total cached file bytes too; we always keep the most-
// recent entry (see setCached) so an active transcript is never re-parsed on
// every read, which caps the regression to extra re-parses only past this budget.
const MAX_CACHE_BYTES = 128 * 1024 * 1024
// Overridable only from tests so the byte-eviction path can be exercised without
// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES.
let maxCacheBytes = MAX_CACHE_BYTES
function setCached(key: string, value: CachedTranscript): void {
// Re-insert moves the key to the most-recent position for LRU eviction.
cache.delete(key)
cache.set(key, value)
let totalBytes = 0
for (const entry of cache.values()) {
totalBytes += entry.bytes
}
// Evict oldest until within BOTH caps, but never drop the most-recent entry
// (cache.size > 1): a single active transcript larger than the whole budget
// must stay cached or every read would re-parse the full file.
while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
totalBytes -= cache.get(oldest)?.bytes ?? 0
cache.delete(oldest)
}
}
function cacheKey(agent: AgentType, filePath: string): string {
return `${agent}:${filePath}`
}
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
try {
const stats = await wslGatedStat(filePath, 'exact')
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
} catch (err) {
// Why: swallowing a gate refusal into an unknown mtime falls through to a
// full read that burns a second deadline. Let the caller report it instead.
if (err instanceof WslTranscriptFsError) {
throw err
}
return { mtimeMs: Number.NaN, bytes: 0 }
}
}
/**
* Read the full transcript for an agent + session, returning the cached parse on
* an mtime/size hit and re-reading (and re-caching) when the file changed. Returns the
* canonical, unwindowed result; callers apply their own windowing/truncation.
*/
export async function readNativeChatTranscriptCached(
agent: AgentType,
sessionId: string,
/** Hook-reported authoritative transcript path, preferred over the id glob. */
transcriptPath?: string
): Promise<ReadTranscriptResult> {
if (resolveNativeChatTranscriptAgent(agent) === 'opencode') {
return readOpenCodeNativeChatTranscriptFull(
sessionId,
{},
undefined,
agent === 'zcode' ? 'zcode' : undefined
)
}
const epoch = cacheEpoch
let filePath: string | null
try {
filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
} catch (err) {
// Why: gate refusal is transient unavailability — report it without caching
// or `notFound` so the next call re-resolves.
return { error: wslTranscriptFsRefusal(err).message }
}
if (!filePath) {
// Not cached (see below): a not-yet-flushed transcript should be re-checked
// on the next call, not pinned as a settled miss (#8401).
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
const key = cacheKey(agent, filePath)
let cached = epoch === cacheEpoch ? cache.get(key) : undefined
let mtimeMs: number
let bytes: number
try {
;({ mtimeMs, bytes } = await fileStat(filePath))
} catch (err) {
// Why: the refusal says the distro stalled, not that this parse went stale —
// a complete cached transcript beats a retry banner. With nothing cached the
// error stands, and no `notFound`, so the next call re-stats a woken distro.
cached = epoch === cacheEpoch ? (cache.get(key) ?? cached) : undefined
if (cached) {
// Bump recency so a session read through a stall survives eviction.
setCached(key, cached)
return cached.result
}
return { error: wslTranscriptFsRefusal(err).message }
}
// Another reader can populate the cache while this caller waits for stat.
cached = epoch === cacheEpoch ? cache.get(key) : undefined
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs && cached.bytes === bytes) {
// Bump recency so a frequently-read session survives eviction.
setCached(key, cached)
return cached.result
}
if (!Number.isFinite(mtimeMs) || epoch !== cacheEpoch) {
return readNativeChatTranscript(agent, sessionId, { filePath })
}
return inFlightReads.run(stableInFlightKey([key, mtimeMs, bytes, epoch]), async () => {
const readOrder = ++nextReadOrder
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
// Late parses must not overwrite a newer completed read or repopulate a cleared cache.
if (
epoch === cacheEpoch &&
(cache.get(key)?.readOrder ?? 0) < readOrder &&
!isRetryableReadError(result)
) {
setCached(key, { result, mtimeMs, bytes, readOrder })
}
return result
})
}
function isRetryableReadError(result: ReadTranscriptResult): boolean {
return (
'error' in result && (result.notFound === true || isWslTranscriptFsRefusalMessage(result.error))
)
}
/** Test-only: drop the transcript parse cache between runs. */
export function clearNativeChatTranscriptCache(): void {
cacheEpoch += 1
inFlightReads.clear()
cache.clear()
}
/** Test-only: override the byte budget (pass no arg to restore the default). */
export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
maxCacheBytes = bytes ?? MAX_CACHE_BYTES
}