import { createHash } from 'node:crypto' import path from 'node:path' import { DirectoryArtifactReader } from './DirectoryArtifactReader.js' import { createEvidence } from '../domain/evidence.js' const MAX_BYTES = 32 * 1024 * 1024 const MAX_ENTRIES = 20000 export async function exportSelectedConversationText(projectsRoot, sessionId, includeImages = false) { if (!path.isAbsolute(projectsRoot) || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(sessionId)) { throw new TypeError('An absolute projects root and exact UUID session ID are required') } const reader = new DirectoryArtifactReader(projectsRoot) const matches = [] let examined = 0 try { for await (const entry of reader.entries()) { if (++examined > MAX_ENTRIES) throw new RangeError('Conversation inventory entry budget exceeded') if (entry.path.split('/').at(-1) === `${sessionId}.jsonl`) matches.push(entry) } if (matches.length !== 1) throw new Error(`Expected one exact session transcript, found ${matches.length}`) const entry = matches[0] if (entry.kind !== 'file' || !Number.isSafeInteger(entry.declaredSize) || entry.declaredSize > MAX_BYTES) { throw new RangeError('Selected transcript is not a bounded regular file') } const chunks = [] let bytes = 0 for await (const chunk of await reader.open(entry)) { const data = Buffer.from(chunk) bytes += data.length if (bytes > MAX_BYTES) throw new RangeError('Transcript grew beyond the byte budget') chunks.push(data) } const data = Buffer.concat(chunks) if (data.length !== entry.declaredSize) throw new Error('Transcript size changed during read') const text = new TextDecoder('utf-8', { fatal: true }).decode(data) const sha256 = createHash('sha256').update(data).digest('hex') let records = 0 const imageRefs = new Map() for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue const record = JSON.parse(line) if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('JSONL record is not an object') if (record.sessionId && record.sessionId !== sessionId) throw new Error('Transcript contains another session ID') if (includeImages && record.type === 'message' && Array.isArray(record.content)) { for (const block of record.content) { const image = block?.type === 'input_image' ? block.image : undefined if (image?.type !== 'image_blob_ref') continue if (!/^[a-f0-9]{64}$/.test(image.blob_id) || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(image.mime)) { throw new Error('Unsupported or invalid transcript image reference') } imageRefs.set(image.blob_id, image) } } records++ } const attachments = includeImages ? await readImages(projectsRoot, [...imageRefs.values()]) : [] return createEvidence({ path: path.join(projectsRoot, entry.path), sha256, format: 'file' }, { id: 'rea-selected-conversation-text', name: 'REA selected local conversation transcript', version: '1+local.1' }, { predicateType: 'rea.conversation.selected-text', operation: 'export_selected_conversation_text', parameters: { projects_root: projectsRoot, session_id: sessionId, include_images: includeImages, max_bytes: MAX_BYTES, max_entries: MAX_ENTRIES }, result: { session_id: sessionId, file: { path: entry.path, sha256, bytes, records, text }, attachments, scope: 'One exact session JSONL and optionally its referenced image blobs; other file contents are not read' }, confidence: 'observed', authority: 'historical-reference', limitations: ['Stable directory-reader identity checks do not establish an atomic snapshot of an actively written conversation.', 'Exports stored history only; non-image external files and live kernel state are not copied.'], locations: [{ kind: 'artifact-path', path: entry.path }] }) } finally { await reader.close() } } async function readImages(projectsRoot, references) { if (!references.length) return [] if (references.length > 32) throw new RangeError('Too many image references') const root = path.resolve(projectsRoot, '../blobs') const selected = new Map() for (const ref of references) { const target = path.resolve(ref.blob_path) const relative = path.relative(root, target).split(path.sep).join('/') if (!new RegExp(`^${ref.blob_id.slice(0, 2)}/${ref.blob_id}\\.(png|jpg|jpeg|webp|gif)$`).test(relative)) { throw new Error('Image reference is outside the selected content-addressed blob root') } selected.set(relative, ref) } const reader = new DirectoryArtifactReader(root) const files = [] let total = 0 try { for await (const entry of reader.entries(undefined, { descendInto: directory => [...selected.keys()].some(file => file.startsWith(directory + '/')) })) { const ref = selected.get(entry.path) if (!ref) continue if (entry.kind !== 'file' || !Number.isSafeInteger(entry.declaredSize) || entry.declaredSize > 8 * 1024 * 1024) { throw new Error('Referenced image is not a bounded regular file') } const chunks = [] for await (const chunk of await reader.open(entry)) { total += chunk.length if (total > 16 * 1024 * 1024) throw new RangeError('Image export byte budget exceeded') chunks.push(Buffer.from(chunk)) } const data = Buffer.concat(chunks) const sha256 = createHash('sha256').update(data).digest('hex') if (sha256 !== ref.blob_id || data.length !== ref.size || data.length !== entry.declaredSize) { throw new Error('Image blob digest/size does not match the transcript reference') } files.push({ blob_id: ref.blob_id, mime: ref.mime, bytes: data.length, sha256, data_base64: data.toString('base64') }) } if (files.length !== references.length) throw new Error('A referenced image blob is absent') return files } finally { await reader.close() } }