Files
orca/src/main/native-chat/transcript-tail-reader.ts
T

311 lines
10 KiB
TypeScript

import { readOpenCodeNativeChatTranscriptTail } from './transcript-opencode'
import { isENOENT } from '../ipc/filesystem/access/filesystem-path-containment'
import type { AgentType } from '../../shared/agent-status/contracts/agent-status-types'
import type {
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat/native-chat-agent-support'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine,
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
import { inTranscriptFileOrder } from './transcript-file-order'
import { extendTranscriptBoundary } from './transcript-file-version'
import {
nativeChatTurnLifecycleDecoderForAgent,
type NativeChatTurnLifecycleDecoder
} from './transcript-turn-lifecycle'
import {
findLastCompleteLineEnd,
readTranscriptByteAt,
TAIL_CHUNK_BYTES
} from './transcript-tail-boundary'
import {
closeTranscriptHandle,
openTranscriptFile,
readTranscriptFile,
transcriptFileStat
} from './wsl-transcript-fs-access'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null
export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
if (transcriptAgent === 'claude') {
return decodeClaudeTranscriptLine
}
if (transcriptAgent === 'codex') {
return decodeCodexTranscriptLine
}
if (transcriptAgent === 'grok') {
return decodeGrokTranscriptLine
}
if (transcriptAgent === 'omp') {
return decodeOmpTranscriptLine
}
return null
}
export async function readNativeChatTranscriptTailFile(
filePath: string,
limit: number,
decode: NativeChatLineDecoder,
includeTrailingLine = false,
endOffset?: number,
decodeLifecycle?: NativeChatTurnLifecycleDecoder | null,
signal?: AbortSignal,
onConsumedBoundary?: (boundary: Buffer) => void,
sourceOrder = false
): Promise<{
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
consumedTo: number
hasMore: boolean
beforeOffset: number
latestTimestamp?: number
malformedRecordCount?: number
oversizedRecordCount?: number
}> {
signal?.throwIfAborted()
const end = Math.min(
(await transcriptFileStat(filePath, 'exact', signal)).size,
endOffset ?? Number.MAX_SAFE_INTEGER
)
signal?.throwIfAborted()
if (end === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const handle = await openTranscriptFile(filePath, 'exact', signal)
const lineParts: Buffer[] = []
let lineBytes = 0
let lineOversized = false
let lifecycle: NativeChatTurnLifecycle | undefined
let malformedRecordCount = 0
let oversizedRecordCount = 0
let ignoreNextMalformedRecord = false
try {
signal?.throwIfAborted()
const consumedTo = includeTrailingLine
? end
: await findLastCompleteLineEnd(handle, filePath, end, signal)
if (consumedTo === 0) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
const newestFirst: { message: NativeChatMessage; offset: number }[] = []
const finalByte = await readTranscriptByteAt(handle, filePath, consumedTo - 1, signal)
if (finalByte === null) {
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
}
ignoreNextMalformedRecord = finalByte !== 0x0a
let cursor = consumedTo - (finalByte === 0x0a ? 1 : 0)
const finalBoundaryByte = finalByte === 0x0a ? Buffer.from([finalByte]) : Buffer.alloc(0)
let boundaryCaptured = false
if (cursor === 0) {
onConsumedBoundary?.(finalBoundaryByte)
}
while (cursor > 0 && newestFirst.length <= limit) {
signal?.throwIfAborted()
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
const buffer = Buffer.allocUnsafe(cursor - start)
const { bytesRead } = await readTranscriptFile(
handle,
filePath,
buffer,
0,
buffer.length,
start,
'exact',
signal
)
signal?.throwIfAborted()
// Short reads mean shrink; never stitch non-adjacent bytes into a record.
if (bytesRead < buffer.length) {
break
}
if (!boundaryCaptured && onConsumedBoundary) {
onConsumedBoundary(extendTranscriptBoundary(buffer, finalBoundaryByte))
boundaryCaptured = true
}
let segmentEnd = bytesRead
for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) {
if (buffer[index] !== 0x0a) {
continue
}
retainPart(buffer.subarray(index + 1, segmentEnd))
if (!lineOversized) {
decodeLine(start + index + 1, newestFirst)
}
resetLine()
segmentEnd = index
}
if (segmentEnd > 0) {
retainPart(buffer.subarray(0, segmentEnd))
}
cursor = start
}
if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) {
decodeLine(0, newestFirst)
}
let latest = Number.NEGATIVE_INFINITY
const chronological = newestFirst.toReversed().map((entry) => {
const ordered = inTranscriptFileOrder(
entry.message,
latest,
sourceOrder ? entry.offset : undefined
)
latest = ordered.latest
return { message: ordered.message, offset: entry.offset }
})
// slice(-0) returns everything; non-positive limits must explicitly select nothing.
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
return {
messages: selected.map((entry) => entry.message),
...(lifecycle ? { lifecycle } : {}),
consumedTo,
hasMore: limit > 0 && chronological.length > limit,
beforeOffset: selected[0]?.offset ?? end,
...(Number.isFinite(latest) ? { latestTimestamp: latest } : {}),
...(malformedRecordCount > 0 ? { malformedRecordCount } : {}),
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
}
} finally {
await closeTranscriptHandle(handle, filePath)
}
function retainPart(part: Buffer): void {
if (lineOversized) {
return
}
lineBytes += part.length
if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) {
lineParts.length = 0
lineOversized = true
oversizedRecordCount++
return
}
lineParts.push(part)
}
function resetLine(): void {
lineParts.length = 0
lineBytes = 0
lineOversized = false
}
function decodeLine(
lineOffset: number,
messages: { message: NativeChatMessage; offset: number }[]
): void {
// Positional reads own their buffers; only split records require a reverse-order join.
const bytes = lineParts.length === 1 ? lineParts[0] : Buffer.concat(lineParts.toReversed())
let line = bytes.toString('utf8')
if (line.endsWith('\r')) {
line = line.slice(0, -1)
}
if (!line) {
return
}
try {
JSON.parse(line)
} catch {
if (ignoreNextMalformedRecord) {
ignoreNextMalformedRecord = false
return
}
malformedRecordCount++
return
}
ignoreNextMalformedRecord = false
const fallbackId = transcriptFallbackId(filePath, lineOffset)
// Use provider lifecycle records from the same window; visible assistant text cannot prove completion.
lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined
const message = decode(line, fallbackId)
if (message) {
messages.push({ message, offset: lineOffset })
}
}
}
export async function readNativeChatTranscriptTail(
args: ResolveSessionFileOptions & {
agent: AgentType
sessionId: string
transcriptPath?: string
filePath?: string
limit: number
beforeOffset?: number
sourceOrder?: boolean
},
signal?: AbortSignal
): Promise<
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
hasMore: boolean
beforeOffset: number
}
| { error: string; notFound?: true }
> {
if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
return readOpenCodeNativeChatTranscriptTail(
{ ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined },
{},
signal
)
}
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
if (!decode) {
return { error: 'Transcript unavailable' }
}
let filePath: string | null
try {
filePath =
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
} catch (error) {
signal?.throwIfAborted()
// Gate refusal is unavailable, never proof of a missing transcript.
return { error: wslTranscriptFsRefusal(error).message }
}
signal?.throwIfAborted()
// Session IDs can arrive before the first flush; missing paths stay retryable.
if (!filePath) {
return { error: 'Transcript unavailable', notFound: true }
}
try {
const result = await readNativeChatTranscriptTailFile(
filePath,
args.limit,
decode,
true,
args.beforeOffset,
decodeLifecycle,
signal,
undefined,
args.sourceOrder
)
signal?.throwIfAborted()
return {
messages: result.messages,
// Historical pages cannot rewind live lifecycle; only the current tail describes present turn state.
...(args.beforeOffset === undefined && result.lifecycle
? { lifecycle: result.lifecycle }
: {}),
hasMore: result.hasMore,
beforeOffset: result.beforeOffset
}
} catch (error) {
signal?.throwIfAborted()
const message = error instanceof Error ? error.message : String(error)
return isENOENT(error) ? { error: message, notFound: true } : { error: message }
}
}