Compare commits

...
Author SHA1 Message Date
Paul Bakaus 0e8eea2d91 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.
2026-08-06 09:51:32 -07:00
Paul Bakaus aa1b4e4b02 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.
2026-08-06 09:37:58 -07:00
Paul Bakaus 6e6b0227b0 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.
2026-08-06 09:22:41 -07:00
2 changed files with 249 additions and 13 deletions
+131 -13
View File
@@ -14,10 +14,12 @@ import path from 'node:path';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_TIMEOUT_MS = 60_000;
const BATCH_OP_TEXT_LIMIT = 240;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) { export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
const repairLines = batch?.repair ? [ const compactBatch = compactBatchForPrompt(batch);
const repairLines = compactBatch.repair ? [
'', '',
'Repair mode:', 'Repair mode:',
'- The previous Apply attempt changed source, but validation failed.', '- 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.', '- 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.', '- Keep failed and notes as arrays.',
'- Return the same canonical JSON shape after repair.', '- Return the same canonical JSON shape after repair.',
JSON.stringify(batch.repair, null, 2), JSON.stringify(compactBatch.repair, null, 2),
] : []; ] : [];
return [ return [
'You are the Impeccable staged copy-edit batch applier.', 'You are the Impeccable staged copy-edit batch applier.',
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
...repairLines, ...repairLines,
'', '',
'Staged copy-edit batch:', 'Staged copy-edit batch:',
JSON.stringify(compactBatchForPrompt(batch), null, 2), JSON.stringify(compactBatch, null, 2),
].join('\n'); ].join('\n');
} }
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
function compactBatchForPrompt(batch) { function compactBatchForPrompt(batch) {
return { return {
pageUrl: batch?.pageUrl || null, pageUrl: batch?.pageUrl || null,
repair: batch?.repair || undefined, repair: compactBatchRepair(batch?.repair),
entries: (batch?.entries || []).map((entry) => ({ entries: (batch?.entries || []).map((entry) => ({
id: entry.id, id: entry.id,
pageUrl: entry.pageUrl, pageUrl: entry.pageUrl,
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
element: compactContextForBatch(entry.element), element: compactContextForBatch(entry.element),
ops: (entry.ops || []).map(compactBatchOp), 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, contextRef: op.contextRef,
tag: op.tag, tag: op.tag,
elementId: op.elementId, elementId: op.elementId,
classes: op.classes, classes: compactBatchStringList(op.classes, 24),
originalText: op.originalText, originalText: op.originalText,
newText: op.newText, newText: op.newText,
deleted: op.deleted === true || undefined, deleted: op.deleted === true || undefined,
sourceHint: op.sourceHint, sourceHint: normalizeBatchSourceHint(op.sourceHint),
leaf: compactContextForBatch(op.leaf), leaf: compactContextForBatch(op.leaf),
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [], nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
container: compactContextForBatch(op.container), 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) { function compactContextForBatch(value) {
if (!value || typeof value !== 'object') return value || null; if (!value || typeof value !== 'object') return value || null;
return { return {
ref: value.ref, ref: compactBatchString(value.ref),
tagName: value.tagName, tagName: compactBatchString(value.tagName),
id: value.id, id: compactBatchString(value.id),
classes: value.classes, classes: compactBatchStringList(value.classes, 24),
textContent: truncate(value.textContent, 900), textContent: truncate(value.textContent, 900),
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800), outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
}; };
+118
View File
@@ -51,6 +51,124 @@ describe('live-copy-edit-agent', () => {
assert.match(prompt, /Return ONLY JSON/); 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', () => { it('parses partial batch results', () => {
assert.deepEqual( assert.deepEqual(
parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'), parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'),