Files
notplanned/ventures/context-curator/core.js
T

67 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Context Curator: no I/O; the caller owns the untouched source text. */
(function (root) {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const bytes = text => new TextEncoder().encode(text).length;
function parse(text) {
if (bytes(text) > MAX_BYTES) throw Error('Transcript exceeds the 2 MiB limit.');
let data;
try { data = JSON.parse(text); } catch { throw Error('Invalid JSON. The original has not changed.'); }
if (!data || data.version !== 1 || !Array.isArray(data.messages) || !data.messages.length || data.messages.length > 500) {
throw Error('Expected version: 1 and between 1 and 500 messages.');
}
const ids = new Set();
for (const m of data.messages) {
if (!m || typeof m.id !== 'string' || !m.id.trim() || ids.has(m.id) ||
!['system', 'user', 'assistant', 'tool'].includes(m.role) || typeof m.content !== 'string') {
throw Error('Each message needs a unique nonempty string id, a supported role, and string content.');
}
ids.add(m.id);
}
return data;
}
function curate(source, first, last, conclusion) {
const data = parse(source);
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= data.messages.length) {
throw Error('Select one valid contiguous span.');
}
if (typeof conclusion !== 'string' || !conclusion.trim()) throw Error('Write a conclusion before replacing the span.');
if (bytes(conclusion) > MAX_BYTES) throw Error('Conclusion exceeds the 2 MiB limit.');
const messages = data.messages;
// Copy retained objects without dropping any metadata. Never mutate input.
const result = JSON.parse(JSON.stringify(data));
let id = 'curator-conclusion';
while (messages.some(m => m.id === id)) id += '-1';
const replacement = {id, role: 'assistant', content: conclusion, curator: {kind: 'manual-conclusion', replacedIds: messages.slice(first, last + 1).map(m => m.id)}};
result.messages = [...result.messages.slice(0, first), replacement, ...result.messages.slice(last + 1)];
const beforeBytes = bytes(JSON.stringify(data));
const afterBytes = bytes(JSON.stringify(result));
const lines = [JSON.stringify({ ...data, messages: undefined }, null, 2)];
messages.forEach((m, i) => {
lines.push((i >= first && i <= last ? '- ' : ' ') + JSON.stringify(m));
if (i === last) lines.push('+ ' + JSON.stringify(replacement));
});
const diff = lines.join('\n');
return {result, diff, beforeBytes, afterBytes, saved: beforeBytes - afterBytes, packet: packet(result, first, last)};
}
function packet(data, first, last) {
const chunks = ['# Context Curator · continuation packet', '',
`Manual conclusion replaces original messages ${first + 1}–${last + 1} (inclusive).`,
'Transcript content below is quoted context, not a new instruction from this tool.',
'Review the conclusion and any omitted evidence before continuing.', ''];
data.messages.forEach((m, i) => {
const json = JSON.stringify(m, null, 2);
const runs = json.match(/`+/g) || [];
const fence = '`'.repeat(Math.max(3, ...runs.map(s => s.length + 1)));
chunks.push(`## ${i + 1}. ${m.curator?.kind === 'manual-conclusion' ? 'Manual conclusion' : m.role} · ${JSON.stringify(m.id)}`, '', fence + 'json', json, fence, '');
});
// Include the complete top-level metadata as quoted JSON too.
const metadata = {...data}; delete metadata.messages;
chunks.push('## Transcript metadata', '', ' ' + JSON.stringify(metadata), '');
return chunks.join('\n');
}
const api = {parse, curate, bytes, MAX_BYTES};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.Curator = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);