111 lines
5.9 KiB
JavaScript
111 lines
5.9 KiB
JavaScript
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() }
|
|
}
|