diff --git a/skill/scripts/live-copy-edit-agent.mjs b/skill/scripts/live-copy-edit-agent.mjs index 313ed7f10..95dd86f0b 100644 --- a/skill/scripts/live-copy-edit-agent.mjs +++ b/skill/scripts/live-copy-edit-agent.mjs @@ -14,6 +14,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; const DEFAULT_TIMEOUT_MS = 60_000; +const BATCH_OP_TEXT_LIMIT = 240; const require = createRequire(import.meta.url); export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) { @@ -311,18 +312,59 @@ function compactBatchOp(op) { contextRef: op.contextRef, tag: op.tag, elementId: op.elementId, - classes: op.classes, + classes: compactBatchStringList(op.classes, 24), originalText: op.originalText, newText: op.newText, deleted: op.deleted === true || undefined, - sourceHint: op.sourceHint, + sourceHint: normalizeBatchSourceHint(op.sourceHint), leaf: compactContextForBatch(op.leaf), - nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [], + nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts), container: compactContextForBatch(op.container), - contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [], + contextHints: compactBatchStringList(op.contextHints, 12), }; } +function normalizeBatchSourceHint(hint) { + if (!hint || typeof hint !== 'object') return null; + let line = Number.isFinite(Number(hint.line)) ? Number(hint.line) : null; + let column = Number.isFinite(Number(hint.column)) ? Number(hint.column) : null; + if ((!line || !column) && typeof hint.loc === 'string') { + const match = hint.loc.match(/^(\d+)(?::(\d+))?/); + if (match) { + line = Number(match[1]); + if (match[2]) column = Number(match[2]); + } + } + return { + file: compactBatchString(hint.file) || '', + loc: compactBatchString(hint.loc) || '', + line, + column, + }; +} + +function compactNearbyBatchTexts(items) { + return (Array.isArray(items) ? items : []) + .slice(0, 8) + .map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : { + ref: compactBatchString(item?.ref), + tag: compactBatchString(item?.tag), + classes: compactBatchStringList(item?.classes, 24), + text: compactBatchString(item?.text), + }); +} + +function compactBatchStringList(items, limit) { + return (Array.isArray(items) ? items : []) + .slice(0, limit) + .filter((item) => typeof item === 'string') + .map((item) => truncate(item, BATCH_OP_TEXT_LIMIT)); +} + +function compactBatchString(value) { + return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined; +} + function compactContextForBatch(value) { if (!value || typeof value !== 'object') return value || null; return { diff --git a/tests/live-copy-edit-agent.test.mjs b/tests/live-copy-edit-agent.test.mjs index 6993e5028..df328656d 100644 --- a/tests/live-copy-edit-agent.test.mjs +++ b/tests/live-copy-edit-agent.test.mjs @@ -51,6 +51,49 @@ describe('live-copy-edit-agent', () => { assert.match(prompt, /Return ONLY JSON/); }); + it('bounds and whitelists operation context in batch prompts', () => { + const huge = 'Z'.repeat(50_000); + const prompt = buildCopyEditBatchPrompt({ + pageUrl: '/', + entries: [{ + id: 'bounded', + pageUrl: '/', + ops: [{ + classes: [huge], + originalText: 'Old', + newText: 'New', + sourceHint: { + file: 'src/App.jsx', + loc: '12:3', + nested: { payload: huge }, + }, + nearbyEditableTexts: [{ + ref: 'body>main>span', + tag: 'span', + classes: ['label'], + text: huge, + extra: huge, + }], + contextHints: [huge], + }], + }], + }); + + const serializedBatch = prompt.split('Staged copy-edit batch:\n').pop(); + const op = JSON.parse(serializedBatch).entries[0].ops[0]; + assert.ok(prompt.length < 20_000, `expected compact prompt, got ${prompt.length} characters`); + assert.ok(op.classes[0].length < 400); + assert.deepEqual(op.sourceHint, { + file: 'src/App.jsx', + loc: '12:3', + line: 12, + column: 3, + }); + assert.deepEqual(Object.keys(op.nearbyEditableTexts[0]).sort(), ['classes', 'ref', 'tag', 'text']); + assert.ok(op.nearbyEditableTexts[0].text.length < 400); + assert.ok(op.contextHints[0].length < 400); + }); + it('parses partial batch results', () => { assert.deepEqual( parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'),