#!/usr/bin/env node 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const readline = require('node:readline/promises'); const engine = require('./site/engine.js'); const usage = `Usage (Node 20+): node cli.cjs annotate SOURCE.jsonl SIDECAR.json [e001=pinned e002=aside ...] node cli.cjs export SOURCE.jsonl SIDECAR.json BUDGET_BYTES NEW_OUTPUT_DIR Outputs: continuation.md + omissions.json. Existing files require interactive confirmation. Budget includes all Markdown framing; source transcripts are never written.`; function identity(file) { return path.resolve(file); } async function confirm(files) { if (!files.length) return; if (!process.stdin.isTTY) throw Error('Output exists; rerun in a terminal to confirm overwrite'); const rl = readline.createInterface({input:process.stdin,output:process.stdout}); try { if (await rl.question('Overwrite ' + files.join(', ') + '? Type OVERWRITE: ') !== 'OVERWRITE') throw Error('Cancelled'); } finally { rl.close(); } } function guard(file, protectedFiles) { if (protectedFiles.includes(identity(file))) throw Error('Output would overwrite an input'); if (fs.existsSync(file)) { const stat = fs.lstatSync(file); if (!stat.isFile() || stat.nlink > 1) throw Error('Output must be a regular file with no hard links'); if (protectedFiles.some(p => fs.existsSync(p) && fs.realpathSync(p) === fs.realpathSync(file))) throw Error('Output aliases an input'); } } async function main() { const [command, sourcePath, sidecarPath, ...rest] = process.argv.slice(2); if (!['annotate','export'].includes(command) || !sourcePath || !sidecarPath) throw Error(usage); const buffer = fs.readFileSync(sourcePath); if (buffer.subarray(0,3).equals(Buffer.from([239,187,191]))) throw Error('UTF-8 BOM is unsupported; normalize the export first'); const source = new TextDecoder('utf-8', {fatal:true}).decode(buffer); const exchanges = engine.parse(source); const sha256 = crypto.createHash('sha256').update(buffer).digest('hex'); if (command === 'annotate') { guard(sidecarPath, [identity(sourcePath)]); const priorities = Object.fromEntries(rest.map(value => { const parts = value.split('='); if (parts.length !== 2) throw Error('Use e001=pinned'); return parts; })); engine.select(exchanges, priorities, Number.MAX_SAFE_INTEGER); const existed = fs.existsSync(sidecarPath); await confirm(existed?[sidecarPath]:[]); fs.writeFileSync(sidecarPath, JSON.stringify({version:1,sourceSha256:sha256,priorities},null,2)+'\n', {flag:existed?'w':'wx',mode:0o600}); console.log('Sidecar saved; '+exchanges.length+' complete exchanges. Source unchanged.'); } else { if (rest.length !== 2 || !/^\d+$/.test(rest[0])) throw Error(usage); const [budget, directory] = rest; const sidecar = JSON.parse(fs.readFileSync(sidecarPath,'utf8')); if (sidecar.version !== 1 || sidecar.sourceSha256 !== sha256) throw Error('Sidecar does not match this transcript'); const result = engine.select(exchanges, sidecar.priorities, Number(budget)); const outputs = ['continuation.md','omissions.json'].map(name => path.join(directory,name)); for (const file of outputs) guard(file, [identity(sourcePath),identity(sidecarPath)]); const existing = new Set(outputs.filter(f => fs.existsSync(f))); await confirm([...existing]); // Build both artifacts in a private staging directory first. Leave it on publication failure. fs.mkdirSync(directory,{recursive:true,mode:0o700}); const stage = fs.mkdtempSync(path.join(directory,'.context-priority-')); fs.writeFileSync(path.join(stage,'continuation.md'),result.packet,{mode:0o600}); fs.writeFileSync(path.join(stage,'omissions.json'),JSON.stringify({...result.manifest,sourceSha256:sha256},null,2)+'\n',{mode:0o600}); try { for (const file of outputs) { guard(file,[identity(sourcePath),identity(sidecarPath)]); const staged = path.join(stage,path.basename(file)); if (existing.has(file)) fs.renameSync(staged,file); else { fs.linkSync(staged,file); fs.unlinkSync(staged); } } fs.rmdirSync(stage); } catch (error) { throw Error(error.message+'; recover remaining artifacts from '+stage); } console.log('Exported '+result.manifest.packetBytes+'/'+budget+' bytes to '+directory); } } main().catch(error => { console.error(error.message); process.exitCode = 1; });