mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
311 lines
9.9 KiB
TypeScript
311 lines
9.9 KiB
TypeScript
import { readOpenCodeNativeChatTranscriptTail } from './transcript-opencode'
|
|
import { isENOENT } from '../ipc/filesystem/filesystem-path-containment'
|
|
import type {
|
|
AgentType,
|
|
NativeChatMessage,
|
|
NativeChatTurnLifecycle
|
|
} from '../../shared/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 }
|
|
}
|
|
}
|