Files
notplanned/ventures/context-priority/cli.cjs
T

75 lines
4.4 KiB
JavaScript

#!/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; });