diff --git a/skill/scripts/live-copy-edit-agent.mjs b/skill/scripts/live-copy-edit-agent.mjs index 95dd86f0b..2dbb5a162 100644 --- a/skill/scripts/live-copy-edit-agent.mjs +++ b/skill/scripts/live-copy-edit-agent.mjs @@ -18,7 +18,8 @@ const BATCH_OP_TEXT_LIMIT = 240; const require = createRequire(import.meta.url); export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) { - const repairLines = batch?.repair ? [ + const compactBatch = compactBatchForPrompt(batch); + const repairLines = compactBatch.repair ? [ '', 'Repair mode:', '- The previous Apply attempt changed source, but validation failed.', @@ -29,7 +30,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) { '- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.', '- Keep failed and notes as arrays.', '- Return the same canonical JSON shape after repair.', - JSON.stringify(batch.repair, null, 2), + JSON.stringify(compactBatch.repair, null, 2), ] : []; return [ 'You are the Impeccable staged copy-edit batch applier.', @@ -81,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) { ...repairLines, '', 'Staged copy-edit batch:', - JSON.stringify(compactBatchForPrompt(batch), null, 2), + JSON.stringify(compactBatch, null, 2), ].join('\n'); } @@ -293,7 +294,7 @@ function readManualEditValidationScript(cwd) { function compactBatchForPrompt(batch) { return { pageUrl: batch?.pageUrl || null, - repair: batch?.repair || undefined, + repair: compactBatchRepair(batch?.repair), entries: (batch?.entries || []).map((entry) => ({ id: entry.id, pageUrl: entry.pageUrl, @@ -301,7 +302,63 @@ function compactBatchForPrompt(batch) { element: compactContextForBatch(entry.element), ops: (entry.ops || []).map(compactBatchOp), })), - candidates: batch?.candidates || [], + candidates: compactBatchCandidates(batch?.candidates), + }; +} + +function compactBatchRepair(repair) { + if (!repair || typeof repair !== 'object') return undefined; + return { + status: compactBatchString(repair.status), + attempts: normalizeOptionalBatchNumber(repair.attempts), + maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts), + transactionId: compactBatchString(repair.transactionId), + failures: compactBatchDiagnostics(repair.failures), + files: compactBatchStringList(repair.files, 20), + }; +} + +function compactBatchDiagnostics(items) { + if (!Array.isArray(items)) return undefined; + return items.slice(0, 12).map((item) => ({ + reason: compactBatchString(item?.reason || item?.kind), + detail: compactBatchString(item?.detail), + message: compactBatchString(item?.message), + file: compactBatchString(item?.file || item?.relativeFile), + line: normalizeOptionalBatchNumber(item?.line), + ref: compactBatchString(item?.ref), + marker: compactBatchString(item?.marker), + files: compactBatchStringList(item?.files, 8), + })); +} + +function compactBatchCandidates(candidates) { + return (Array.isArray(candidates) ? candidates : []) + .slice(0, 24) + .map((candidate) => ({ + entryId: compactBatchString(candidate?.entryId), + ref: compactBatchString(candidate?.ref), + sourceHint: compactBatchSourceMatch(candidate?.sourceHint), + textMatches: compactBatchSourceMatches(candidate?.textMatches, 8), + objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8), + contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8), + locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6), + })); +} + +function compactBatchSourceMatches(matches, limit) { + if (!Array.isArray(matches)) return undefined; + return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean); +} + +function compactBatchSourceMatch(match) { + if (!match || typeof match !== 'object') return null; + return { + file: compactBatchString(match.relativeFile || match.file), + line: normalizeBatchNumber(match.line), + column: normalizeBatchNumber(match.column), + reason: compactBatchString(match.reason || match.kind), + status: compactBatchString(match.status), }; } @@ -326,9 +383,9 @@ function compactBatchOp(op) { 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') { + let line = normalizeBatchNumber(hint.line); + let column = normalizeBatchNumber(hint.column); + if ((line === null || column === null) && typeof hint.loc === 'string') { const match = hint.loc.match(/^(\d+)(?::(\d+))?/); if (match) { line = Number(match[1]); @@ -343,6 +400,17 @@ function normalizeBatchSourceHint(hint) { }; } +function normalizeBatchNumber(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function normalizeOptionalBatchNumber(value) { + const number = normalizeBatchNumber(value); + return number === null ? undefined : number; +} + function compactNearbyBatchTexts(items) { return (Array.isArray(items) ? items : []) .slice(0, 8) @@ -368,10 +436,10 @@ function compactBatchString(value) { function compactContextForBatch(value) { if (!value || typeof value !== 'object') return value || null; return { - ref: value.ref, - tagName: value.tagName, - id: value.id, - classes: value.classes, + ref: compactBatchString(value.ref), + tagName: compactBatchString(value.tagName), + id: compactBatchString(value.id), + classes: compactBatchStringList(value.classes, 24), textContent: truncate(value.textContent, 900), outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800), }; diff --git a/tests/live-copy-edit-agent.test.mjs b/tests/live-copy-edit-agent.test.mjs index df328656d..1d5b53558 100644 --- a/tests/live-copy-edit-agent.test.mjs +++ b/tests/live-copy-edit-agent.test.mjs @@ -94,6 +94,60 @@ describe('live-copy-edit-agent', () => { assert.ok(op.contextHints[0].length < 400); }); + it('bounds batch repair, candidate, and element context', () => { + const huge = 'Z'.repeat(250_000); + const prompt = buildCopyEditBatchPrompt({ + pageUrl: '/', + repair: { + status: 'needs_decision', + transactionId: huge, + failures: [{ message: huge, extra: huge }], + files: [huge], + extra: huge, + }, + entries: [{ + id: 'bounded', + element: { ref: huge, tagName: huge, id: huge, classes: [huge], textContent: huge }, + ops: [{ + originalText: 'Old', + newText: 'New', + sourceHint: { file: 'src/App.jsx', line: null, column: null }, + }], + }], + candidates: [{ + entryId: 'bounded', + ref: huge, + sourceHint: { file: huge, line: null, extra: huge }, + textMatches: [{ file: huge, reason: huge, extra: huge }], + extra: huge, + }], + }); + + const serializedBatch = prompt.split('Staged copy-edit batch:\n').pop(); + const compact = JSON.parse(serializedBatch); + assert.ok(prompt.length < 25_000, `expected compact prompt, got ${prompt.length} characters`); + assert.deepEqual(Object.keys(compact.repair).sort(), [ + 'failures', + 'files', + 'status', + 'transactionId', + ]); + assert.ok(compact.repair.transactionId.length < 400); + assert.ok(compact.repair.failures[0].message.length < 400); + assert.deepEqual(Object.keys(compact.candidates[0]).sort(), [ + 'entryId', + 'ref', + 'sourceHint', + 'textMatches', + ]); + assert.ok(compact.candidates[0].ref.length < 400); + assert.ok(compact.candidates[0].sourceHint.file.length < 400); + assert.ok(compact.entries[0].element.ref.length < 400); + assert.ok(compact.entries[0].element.classes[0].length < 400); + assert.equal(compact.entries[0].ops[0].sourceHint.line, null); + assert.equal(compact.entries[0].ops[0].sourceHint.column, null); + }); + it('parses partial batch results', () => { assert.deepEqual( parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'),