Files
ycode/scripts/rea/patches/SelectedConversationText.js

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() }
}