// Import an inert conversation from REA Evidence. Never reads WorkBuddy storage directly. import fs from 'node:fs/promises' import path from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' import { execFileSync } from 'node:child_process' const args = process.argv.slice(2) const evidencePath = args[0] const destination = args[1] const title = args[2] || '开始新会话' if (!evidencePath || !destination || !path.isAbsolute(destination)) { throw new Error('Usage: node scripts/import-workbuddy-session.mjs [title] [--apply]') } const evidence = JSON.parse((await fs.readFile(evidencePath, 'utf8')).replace(/^\uFEFF/, '')) if (evidence.operation !== 'export_selected_conversation_text' || evidence.provider?.id !== 'rea-selected-conversation-text' || !/^ev_[a-f0-9]{64}$/.test(evidence.evidence_id)) throw new Error('Expected a REA selected transcript Evidence') const source = evidence.normalized_result.file const sha = data => createHash('sha256').update(data).digest('hex') if (sha(Buffer.from(source.text)) !== source.sha256 || Buffer.byteLength(source.text) !== source.bytes) { throw new Error('REA transcript digest/byte count does not match') } await build({ entryPoints: ['src/shared/workbuddy-import.ts'], bundle: true, platform: 'node', format: 'esm', outfile: '.tmp/workbuddy-import-converter.mjs', logLevel: 'silent' }) const { convertWorkBuddyJsonl } = await import(pathToFileURL(path.resolve('.tmp/workbuddy-import-converter.mjs')).href) const originalBytes = await fs.readFile(destination) const existing = JSON.parse(originalBytes.toString('utf8').replace(/^\uFEFF/, '')) if (!Array.isArray(existing.sessions)) throw new Error('Invalid YCode session store') if (existing.sessions.some(session => session.messages?.some(message => message.status === 'streaming'))) { throw new Error('YCode has unfinished persisted messages; finish the active run before offline import') } const sourceId = evidence.normalized_result.session_id const duplicate = existing.sessions.find(session => session.importProvenance?.sourceSessionId === sourceId) if (duplicate) { console.log(JSON.stringify({ status: 'already-imported', sessionId: duplicate.id, title: duplicate.title })) process.exit(0) } const importedAt = Date.now() const result = convertWorkBuddyJsonl(source.text, { sessionId: randomUUID(), title, sourceSessionId: sourceId, evidenceId: evidence.evidence_id, sourceSha256: source.sha256, importedAt, branchMode: 'visible' }) const exportedImages = new Map((evidence.normalized_result.attachments ?? []).map(image => { const bytes = Buffer.from(image.data_base64, 'base64') if (sha(bytes) !== image.sha256 || image.sha256 !== image.blob_id || bytes.length !== image.bytes || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(image.mime)) throw new Error('Invalid REA image artifact') return [image.blob_id, image] })) let copiedImages = 0 for (const reference of result.provenance.referencedAttachments ?? []) { const attachment = result.messages.find(message => message.id === reference.messageId)?.attachments?.find(item => item.id === reference.attachmentId) const image = exportedImages.get(reference.sourceBlobId) if (!attachment || !image) throw new Error('A referenced image is unavailable in the REA export; no import performed') attachment.previewUrl = `data:${image.mime};base64,${image.data_base64}` copiedImages++ } if (copiedImages === result.provenance.referencedAttachments.length) { result.warnings = result.warnings.filter(warning => !warning.startsWith('Image attachment references were retained')) } const session = { ...result.meta, updatedAt: importedAt, messages: result.messages, importProvenance: result.provenance } await fs.writeFile('.tmp/workbuddy-copy-session.json', JSON.stringify(session, null, 2)) const report = { applied: false, title, sessionId: session.id, sourceSessionId: sourceId, evidenceId: evidence.evidence_id, sourceSha256: source.sha256, stats: { ...result.stats, copiedImages }, warnings: result.warnings, existingSessionCount: existing.sessions.length, destination } if (args.includes('--apply')) { if (process.platform === 'win32') { const executable = path.resolve('node_modules/electron/dist/electron.exe').replaceAll("'", "''") const processIds = execFileSync('powershell.exe', ['-NoProfile', '-Command', `Get-CimInstance Win32_Process -Filter "Name='electron.exe'" | Where-Object { $_.ExecutablePath -eq '${executable}' -and $_.CommandLine -notmatch '--type=' -and ($_.CommandLine -match '\\s\\.\\s*$' -or $_.CommandLine -match 'out[\\\\/]main') } | Select-Object -ExpandProperty ProcessId`], { encoding: 'utf8', windowsHide: true }).trim() if (processIds) throw new Error('Close the YCode app before applying an offline import so its main-process cache cannot overwrite the copy') } const currentBytes = await fs.readFile(destination) if (sha(currentBytes) !== sha(originalBytes)) throw new Error('YCode store changed during import; no writes performed') const backup = `${destination}.before-workbuddy-${importedAt}.bak` await fs.copyFile(destination, backup, 1) const expected = { ...existing, sessions: [...existing.sessions, session] } const temp = `${destination}.import-${randomUUID()}.tmp` await fs.writeFile(temp, JSON.stringify(expected, null, 2)) await fs.rename(temp, destination) const restored = JSON.parse(await fs.readFile(destination, 'utf8')) if (JSON.stringify(restored) !== JSON.stringify(expected)) throw new Error(`Import readback failed; backup: ${backup}`) report.applied = true report.backup = backup } await fs.writeFile('.tmp/workbuddy-copy-report.json', JSON.stringify(report, null, 2)) console.log(JSON.stringify(report))