From 7fa695093eb86adc5f3c2f858df57b24466821c9 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 9 Aug 2026 16:56:49 -0700 Subject: [PATCH] Bound copy-edit prompt context (#528) * Bound copy-edit prompt context Whitelist and truncate staged operation context before it reaches the local agent prompt. AI assistance: Implemented and validated with OpenAI Codex under maintainer authorization. * Harden copy-edit prompt bounds Bound repair, candidate, and element context consistently and preserve absent source positions as null.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization. * Preserve bounded repair context Keep repair attempt metadata and nested diagnostics while retaining prompt limits.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization. --- skill/scripts/live-copy-edit-agent.mjs | 144 ++++++++++++++++++++++--- tests/live-copy-edit-agent.test.mjs | 118 ++++++++++++++++++++ 2 files changed, 249 insertions(+), 13 deletions(-) diff --git a/skill/scripts/live-copy-edit-agent.mjs b/skill/scripts/live-copy-edit-agent.mjs index f70f26ff4..febc8456d 100644 --- a/skill/scripts/live-copy-edit-agent.mjs +++ b/skill/scripts/live-copy-edit-agent.mjs @@ -14,10 +14,12 @@ 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() } = {}) { - const repairLines = batch?.repair ? [ + const compactBatch = compactBatchForPrompt(batch); + const repairLines = compactBatch.repair ? [ '', 'Repair mode:', '- The previous Apply attempt changed source, but validation failed.', @@ -28,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.', @@ -80,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'); } @@ -292,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, @@ -300,7 +302,71 @@ 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), + attempt: normalizeOptionalBatchNumber(repair.attempt), + attempts: normalizeOptionalBatchNumber(repair.attempts), + maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts), + reason: compactBatchString(repair.reason), + transactionId: compactBatchString(repair.transactionId), + pageUrl: compactBatchString(repair.pageUrl), + failures: compactBatchDiagnostics(repair.failures), + files: compactBatchStringList(repair.files, 20), + }; +} + +function compactBatchDiagnostics(items, depth = 0) { + if (!Array.isArray(items)) return undefined; + return items.slice(0, 12).map((item) => ({ + entryId: compactBatchString(item?.entryId || item?.id), + 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), + candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined, + failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined, + checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined, + })); +} + +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), + kind: compactBatchString(match.kind), + reason: compactBatchString(match.reason || match.kind), + status: compactBatchString(match.status), }; } @@ -311,25 +377,77 @@ 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 = 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]); + if (match[2]) column = Number(match[2]); + } + } + return { + file: compactBatchString(hint.file) || '', + loc: compactBatchString(hint.loc) || '', + line, + column, + }; +} + +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) + .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 { - 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 98d9c0c1b..522f494f0 100644 --- a/tests/live-copy-edit-agent.test.mjs +++ b/tests/live-copy-edit-agent.test.mjs @@ -51,6 +51,124 @@ 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('bounds batch repair, candidate, and element context', () => { + const huge = 'Z'.repeat(250_000); + const prompt = buildCopyEditBatchPrompt({ + pageUrl: '/', + repair: { + status: 'needs_decision', + attempt: 2, + maxAttempts: 3, + reason: 'source_verification_failed', + pageUrl: '/pricing', + transactionId: huge, + failures: [{ + entryId: 'bounded', + message: huge, + candidates: [{ file: huge, line: 12, kind: 'text' }], + failures: [{ ref: huge, reason: huge }], + checks: [{ file: huge, reason: 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', + 'attempt', + 'maxAttempts', + 'pageUrl', + 'reason', + 'status', + 'transactionId', + ].sort()); + assert.equal(compact.repair.attempt, 2); + assert.equal(compact.repair.reason, 'source_verification_failed'); + assert.ok(compact.repair.transactionId.length < 400); + assert.ok(compact.repair.failures[0].message.length < 400); + assert.equal(compact.repair.failures[0].entryId, 'bounded'); + assert.ok(compact.repair.failures[0].candidates[0].file.length < 400); + assert.ok(compact.repair.failures[0].failures[0].ref.length < 400); + assert.ok(compact.repair.failures[0].checks[0].file.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"]}'),