import { createReadStream } from 'fs' import { createInterface } from 'readline' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } export type ReadTranscriptOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery (used by tests). */ filePath?: string } /** * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies * NO message cap. Unknown record types are skipped rather than throwing, so a * single malformed/unrecognized line cannot fail the whole read. The per-line * record→message mapping is shared with the live tailer (transcript-watch.ts) * via transcript-line-decoders.ts. */ export async function readNativeChatTranscript( agent: AgentType, sessionId: string, options: ReadTranscriptOptions = {} ): Promise { const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) if (!filePath) { return { error: `No transcript found for ${agent} session ${sessionId}` } } try { if (agent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } if (agent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { return { error: errorMessage(err) } } } async function readTranscript( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null ): Promise { const reader = createInterface({ input: createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity }) const messages: NativeChatMessage[] = [] let index = 0 for await (const line of reader) { // Why: fallback id embeds start offset 0 so it matches the live tailer's id // for the same record (the tailer's first drain reads from offset 0 too). // Records that re-emit then collapse by id in the assembler — no dup, no drop. const message = decode(line, `${filePath}:0:${index}`) if (message) { messages.push(message) } index++ } return messages }