/** * Agent module for the live-mode E2E test suite. * * Two layers: * * 1. `runAgentLoop(opts)` — the deterministic wrapper around the live-mode * poll/wrap/write/accept protocol. This is identical for fake and real * agents; only the variant-content production step differs. * * 2. `createFakeAgent()` — produces canned variants in the EXACT format * `skill/reference/live.md` describes: a colocated * `\n`; } /** * Re-author every variant of a live component session and tell the server the * publish happened, exactly as the poll loop does after a generate. The server * snapshots a fresh `r/` revision dir on the `done` reply, so the browser * imports from a path it has never seen and cannot serve a cached compile of * the previous content. * * @param {object} opts * @param {string} opts.tmp app root (where the manifest path resolves) * @param {string} opts.manifestFile manifest path relative to `tmp` * @param {{port: number, token: string}} opts.live * @param {(variantNum: number, shape: object) => string} opts.css */ export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) { const manifestPath = path.join(tmp, manifestFile); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); const componentDir = path.join(tmp, manifest.componentDir); const shape = svelteSelectionShape(manifest.originalMarkup || ''); const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1; for (let variantNum = 1; variantNum <= count; variantNum++) { const file = path.join(componentDir, `v${variantNum}.svelte`); let source; try { source = await fs.readFile(file, 'utf-8'); } catch { continue; } await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8'); } const res = await fetch(`http://127.0.0.1:${live.port}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: live.token, type: 'done', sourceEventType: 'generate', id: manifest.id, file: manifestFile, }), }); if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`); return { manifest, shape }; } export function insertTargetFromEvent(event) { const anchor = event?.insert?.anchor || {}; const classes = Array.isArray(anchor.classes) ? anchor.classes.join(' ') : (anchor.classes || ''); const text = typeof anchor.textContent === 'string' ? anchor.textContent.trim().slice(0, 80) : ''; return { position: event?.insert?.position === 'before' ? 'before' : 'after', classes: classes || undefined, tag: anchor.tagName || anchor.tag || undefined, elementId: anchor.id || anchor.elementId || undefined, text: text || undefined, }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function extractText(outerHTML) { if (!outerHTML) return null; const m = outerHTML.match(/>([^<]+)/g, '>'); } function htmlAttrEscape(str) { return htmlEscape(str).replace(/"/g, '"'); } function buildPreservedVariantAttrs(element, className) { const attrs = []; if (className) attrs.push(['class', className]); if (element.id) attrs.push(['id', element.id]); const testId = readAttrFromOuterHtml(element.outerHTML, 'data-testid'); if (testId) attrs.push(['data-testid', testId]); return attrs.map(([name, value]) => ` ${name}="${htmlAttrEscape(value)}"`).join(''); } function readAttrFromOuterHtml(outerHTML, attr) { if (!outerHTML) return null; const match = String(outerHTML).match(new RegExp("\\s" + attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "\\s*=\\s*([\"'])(.*?)\\1")); return match ? match[2] : null; } function attrEscape(str, { svelte = false } = {}) { let s = String(str).replace(/&/g, '&').replace(/'/g, '''); if (svelte) { // Svelte parses `{` in attribute values as expression starters even // inside quoted strings — see https://svelte.dev/e/expected_token . // Escape with HTML numeric entities so the literal characters land in // the rendered DOM attribute. s = s.replace(/\{/g, '{').replace(/\}/g, '}'); } return s; } /** * JSX-source counterpart to the Svelte prop contract. * * When the picked element's source content is a bare JSX expression (or text * mixed with expressions), return it verbatim so the variant markup keeps the * binding instead of the rendered snapshot. Returns null for every other * shape, including nested elements, so this stays a narrow substitution rather * than a general source-copy path. * * @param {{ wrapInfo?: object, tmp?: string }} context * @returns {Promise} */ async function readJsxExpressionInner(context = {}) { const wrapInfo = context.wrapInfo; if (!wrapInfo) return null; // Svelte previews bind through propContract downstream; leave them alone. if (wrapInfo.previewMode === 'svelte-component') return null; if (wrapInfo.commentSyntax?.open !== '{/*') return null; const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp); const inner = extractInnerSourceMarkup(original); if (inner == null) return null; const trimmed = inner.trim(); if (!trimmed || trimmed.includes('<')) return null; if (!/\{[^{}]+\}/.test(trimmed)) return null; return trimmed; } /** * Recover the picked element's source markup from the scaffold. Deferred * writes carry it in `wrapperBlock`; otherwise the wrapper is already in the * file and the same block can be read back from disk. */ async function readOriginalMarkupFromWrap(wrapInfo, tmp) { let text = wrapInfo.wrapperBlock; if (!text && tmp && wrapInfo.file) { try { text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'); } catch { return null; } } if (!text) return null; const lines = String(text).split('\n'); const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"')); if (startIdx === -1) return null; const indent = (lines[startIdx].match(/^\s*/) || [''])[0]; const closer = `${indent}`; for (let i = startIdx + 1; i < lines.length; i++) { if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n'); } return null; } /** Content between an element's opening and closing tag, or null. */ function extractInnerSourceMarkup(markup) { const src = String(markup || '').trim(); if (!src) return null; const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/); if (!open || open[2].trim().endsWith('/')) return null; const tag = open[1].toLowerCase(); const closeIdx = src.toLowerCase().lastIndexOf(`$/.test(src.slice(closeIdx))) return null; return src.slice(open[0].length, closeIdx); } /** * Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml * in HTML form; the orchestrator translates per the target file's syntax. */ export function htmlToJsx(html) { return selfCloseHtmlVoidTagsForJsx(String(html) .replace(/(^|[\s<])class=/g, '$1className=') .replace(/\sstyle=(["'])([\s\S]*?)\1/g, (_match, _quote, value) => { const entries = parseInlineStyle(value); if (entries.length === 0) return ''; return ' style={{ ' + entries.map(({ prop, value }) => `${formatJsxStyleKey(prop)}: ${JSON.stringify(value)}`).join(', ') + ' }}'; })); } const JSX_VOID_TAGS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]); function selfCloseHtmlVoidTagsForJsx(html) { let result = ''; let index = 0; while (index < html.length) { const lt = html.indexOf('<', index); if (lt === -1) { result += html.slice(index); break; } result += html.slice(index, lt); const tagMatch = html.slice(lt + 1).match(/^([A-Za-z][\w:-]*)/); if (!tagMatch) { result += '<'; index = lt + 1; continue; } const tagName = tagMatch[1]; let end = lt + 1 + tagName.length; let quote = null; while (end < html.length) { const ch = html[end]; if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") { quote = ch; } else if (ch === '>') { break; } end++; } if (end >= html.length) { result += html.slice(lt); break; } const tag = html.slice(lt, end + 1); if (!JSX_VOID_TAGS.has(tagName.toLowerCase()) || /\/\s*>$/.test(tag)) { result += tag; } else { result += html.slice(lt, end).replace(/\s+$/, '') + ' />'; } index = end + 1; } return result; } function parseInlineStyle(style) { return splitInlineStyleDeclarations(String(style)) .map((decl) => decl.trim()) .filter(Boolean) .map(parseInlineStyleDeclaration) .filter(Boolean); } function splitInlineStyleDeclarations(style) { const declarations = []; let quote = null; let escaped = false; let parenDepth = 0; let start = 0; for (let i = 0; i < style.length; i++) { const ch = style[i]; if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (quote) { if (ch === quote) quote = null; continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (ch === '(') { parenDepth++; continue; } if (ch === ')' && parenDepth > 0) { parenDepth--; continue; } if (ch === ';' && parenDepth === 0) { declarations.push(style.slice(start, i)); start = i + 1; } } declarations.push(style.slice(start)); return declarations; } function parseInlineStyleDeclaration(decl) { const colon = decl.indexOf(':'); if (colon <= 0) return null; const prop = decl.slice(0, colon).trim(); const value = decl.slice(colon + 1).trim(); if (!prop || !value) return null; return { prop, value }; } function formatJsxStyleKey(prop) { if (prop.startsWith('--')) return JSON.stringify(prop); const reactKey = cssPropertyToReactKey(prop); return /^[A-Za-z_$][\w$]*$/.test(reactKey) ? reactKey : JSON.stringify(prop); } function cssPropertyToReactKey(prop) { const lower = prop.toLowerCase(); if (lower.startsWith('-webkit-')) return 'Webkit' + capitalize(camelCaseCssProperty(lower.slice(8))); if (lower.startsWith('-moz-')) return 'Moz' + capitalize(camelCaseCssProperty(lower.slice(5))); if (lower.startsWith('-o-')) return 'O' + capitalize(camelCaseCssProperty(lower.slice(3))); if (lower.startsWith('-ms-')) return 'ms' + camelCaseCssProperty(lower.slice(4)); if (lower === 'float') return 'cssFloat'; return camelCaseCssProperty(prop); } function camelCaseCssProperty(prop) { return prop.replace(/-([a-z])/gi, (_match, ch) => ch.toUpperCase()); } function capitalize(str) { return str ? str[0].toUpperCase() + str.slice(1) : str; } export const HOIST_ATTR = 'data-impeccable-hoist-id'; export function normalizeVariantOutput(output, wrapInfo = {}) { const scopedCssInput = output.scopedCss || ''; const normalizedInputCss = normalizeVariantSelectorQuotes(scopedCssInput); const extraCss = []; const variants = output.variants.map((variant, i) => { const { innerHtml, groups } = stripInlineStylesPerElement(String(variant.innerHtml)); for (const { hoistId, declarations } of groups) { extraCss.push(renderHoistedInlineStyleRule({ variantId: i + 1, hoistId, declarations, styleMode: wrapInfo.styleMode, })); } return { ...variant, innerHtml }; }); const baseCss = renderMissingBaseVariantRules({ scopedCss: normalizedInputCss, count: output.variants.length, styleMode: wrapInfo.styleMode, }); if (extraCss.length === 0 && baseCss.length === 0 && normalizedInputCss === scopedCssInput) return output; const scopedCss = [normalizedInputCss, ...extraCss, ...baseCss] .map((chunk) => String(chunk).trim()) .filter(Boolean) .join('\n'); return { ...output, scopedCss, variants }; } function normalizeVariantSelectorQuotes(css) { return String(css).replace( /\[data-impeccable-variant=(['"])(\d+)\1\]/g, (_match, _quote, id) => `[data-impeccable-variant="${id}"]`, ); } export async function applyManualEditBatchToSource(batch, { tmp, sourceEdits, repair = null } = {}) { if (!tmp) throw new Error('manual edit apply requires tmp project root'); const fileCache = new Map(); const filesTouched = new Set(); const appliedEntryIds = []; const failed = []; const sourceEditQueue = Array.isArray(sourceEdits) ? [...sourceEdits] : null; const allowAlreadyApplied = !!repair; const readRelativeFile = async (relativeFile) => { if (fileCache.has(relativeFile)) return fileCache.get(relativeFile); const full = safeProjectPath(tmp, relativeFile); const body = await fs.readFile(full, 'utf-8'); fileCache.set(relativeFile, body); return body; }; for (const entry of batch?.entries || []) { const beforeEntry = new Map(fileCache); const beforeTouched = new Set(filesTouched); const keyRenames = sourceKeyRenamesForEntry(entry); const entrySourceEdits = sourceEditQueue ? sourceEditQueue.filter((edit) => edit.entryId === entry.id) : null; let entryFailed = null; if (sourceEditQueue) { if (entrySourceEdits.length === 0) { entryFailed = { reason: 'no source edits returned', candidates: candidatesForEntry(batch, entry.id) }; } for (const edit of entrySourceEdits) { if (entryFailed) break; const relativeFile = normalizeRelativeSourceFile(edit.file); if (!relativeFile) { entryFailed = { reason: 'invalid source edit file', candidates: candidatesForEntry(batch, entry.id) }; break; } try { const body = await readRelativeFile(relativeFile); const replaced = replaceTextInSource(body, { originalText: edit.originalText, newText: edit.newText, line: edit.line, contextHints: contextHintsForEntry(entry), }); if (!replaced.ok) { if (allowAlreadyApplied && sourceAlreadyShowsAppliedOp(body, { file: relativeFile, line: edit.line, }, edit)) { filesTouched.add(relativeFile); continue; } entryFailed = { reason: replaced.reason, candidates: candidatesForEntry(batch, entry.id) }; break; } fileCache.set(relativeFile, replaced.body); filesTouched.add(relativeFile); } catch (err) { entryFailed = { reason: err.message, candidates: candidatesForEntry(batch, entry.id) }; break; } } } else { for (const op of entry.ops || []) { const attempts = candidateAttemptsForOp(batch, entry, op); let opApplied = false; let lastReason = 'originalText not found'; for (const attempt of attempts) { try { const body = await readRelativeFile(attempt.file); const replaced = replaceTextInSource(body, { originalText: op.originalText, newText: op.newText, line: attempt.line, contextHints: contextHintsForEntry(entry), keyRenames, }); if (!replaced.ok) { if (allowAlreadyApplied && sourceAlreadyShowsAppliedOp(body, attempt, op)) { filesTouched.add(attempt.file); opApplied = true; break; } lastReason = replaced.reason; continue; } fileCache.set(attempt.file, replaced.body); filesTouched.add(attempt.file); opApplied = true; break; } catch (err) { lastReason = err.message; } } if (!opApplied) { entryFailed = { reason: lastReason, candidates: candidatesForEntry(batch, entry.id) }; break; } } } if (entryFailed) { fileCache.clear(); for (const [file, body] of beforeEntry) fileCache.set(file, body); filesTouched.clear(); for (const file of beforeTouched) filesTouched.add(file); failed.push({ entryId: entry.id, ...entryFailed }); } else { await applyCoupledSourceKeyRenamesForEntry({ batch, entry, keyRenames, readRelativeFile, fileCache, filesTouched, }); appliedEntryIds.push(entry.id); } } for (const file of filesTouched) { await fs.writeFile(safeProjectPath(tmp, file), fileCache.get(file), 'utf-8'); } const status = failed.length === 0 ? 'done' : (appliedEntryIds.length > 0 ? 'partial' : 'error'); return { status, appliedEntryIds, failed, files: [...filesTouched], notes: [], }; } export async function loadManualEditEventBatch(event, { tmp } = {}) { if (!event?.evidencePath) return event?.batch; const evidencePath = await resolveManualEditEvidencePath(event.evidencePath, tmp); const body = await fs.readFile(evidencePath, 'utf-8'); const batch = JSON.parse(body); return batch && typeof batch === 'object' && Array.isArray(batch.entries) ? batch : event.batch; } async function resolveManualEditEvidencePath(evidencePath, root) { if (!evidencePath || typeof evidencePath !== 'string') throw new Error('invalid manual edit evidence path'); const base = root ? path.resolve(root) : process.cwd(); const full = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(base, evidencePath); const [realBase, realFull] = await Promise.all([ fs.realpath(base).catch(() => base), fs.realpath(full).catch(() => full), ]); const rel = path.relative(realBase, realFull); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error('manual edit evidence path outside fixture project'); } return full; } function safeProjectPath(root, relativeFile) { const normalized = normalizeRelativeSourceFile(relativeFile); if (!normalized) throw new Error('invalid source file path'); const full = path.resolve(root, normalized); const rel = path.relative(path.resolve(root), full); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error('source file outside fixture project'); } return full; } function normalizeRelativeSourceFile(file) { if (!file || typeof file !== 'string') return null; if (path.isAbsolute(file)) return null; const normalized = path.normalize(file).replace(/\\/g, '/'); if (!normalized || normalized === '.' || normalized.startsWith('../')) return null; return normalized; } function candidateAttemptsForOp(batch, entry, op) { const attempts = []; const seen = new Set(); const numericDisplayEdit = /^-?\d+(?:\.\d+)?$/.test(String(op?.originalText || '').trim()) && !/^-?\d+(?:\.\d+)?$/.test(String(op?.newText || '').trim()); const add = (file, line, kind) => { const relativeFile = normalizeRelativeSourceFile(file); if (!relativeFile) return; const key = `${relativeFile}:${line || ''}:${kind}`; if (seen.has(key)) return; seen.add(key); attempts.push({ file: relativeFile, line, kind }); }; const opCandidates = (batch?.candidates || []) .filter((candidate) => candidate.entryId === entry.id && (!candidate.ref || candidate.ref === op.ref)); const relatedSiblingRefs = relatedSiblingRefsForOp(entry, op); const siblingCandidates = (batch?.candidates || []) .filter((candidate) => candidate.entryId === entry.id && candidate.ref && relatedSiblingRefs.has(candidate.ref)); if (numericDisplayEdit) { for (const candidate of [...opCandidates, ...siblingCandidates]) { for (const match of candidate.objectKeyMatches || []) add(match.file, match.line, 'object_key_match'); } } add(op?.sourceHint?.file, op?.sourceHint?.line, 'source_hint'); for (const candidate of opCandidates) { const sourceHint = candidate.sourceHint; if (sourceHint?.status === 'ok') add(sourceHint.relativeFile || sourceHint.file, sourceHint.line, 'candidate_source_hint'); for (const match of candidate.locatorMatches || []) add(match.file, match.line, 'locator_match'); for (const match of candidate.textMatches || []) add(match.file, match.line, 'text_match'); for (const match of candidate.objectKeyMatches || []) add(match.file, match.line, 'object_key_match'); for (const match of candidate.contextTextMatches || []) add(match.file, match.line, 'context_text_match'); } return attempts; } function replaceTextInSource(body, { originalText, newText, line, contextHints = [], keyRenames = [] }) { const original = String(originalText || ''); if (!original) return { ok: false, reason: 'missing originalText' }; const contextKeyValueMatch = replaceNumericValueForContextKey(body, { originalText: original, newText, contextHints, }); if (contextKeyValueMatch.ok) return contextKeyValueMatch; if (Number.isFinite(Number(line)) && Number(line) > 0) { const typedDisplayMatch = replaceTypedNumericDisplayExpression(body, { originalText: original, newText, line: Number(line), }); if (typedDisplayMatch.ok) return typedDisplayMatch; const lineMatch = replaceNearLine(body, original, String(newText), Number(line), keyRenames); if (lineMatch.ok) return lineMatch; } const numericDisplayEdit = isNumericDisplayEdit(original, newText); const matches = allIndexesOf(body, original) .filter((index) => !numericDisplayEdit || numericReplacementAllowedAt(body, index, original)); if (matches.length === 0) return { ok: false, reason: 'originalText not found' }; if (matches.length === 1) { return replaceAtIndexWithSourceRules(body, matches[0], original, String(newText)); } const scored = matches.map((index) => ({ index, score: scoreManualEditMatch(body, index, contextHints), })).sort((a, b) => b.score - a.score); if (scored[0].score > 0 && scored[0].score > scored[1].score) { return replaceAtIndexWithSourceRules(body, scored[0].index, original, String(newText)); } return { ok: false, reason: 'originalText ambiguous' }; } function replaceNumericValueForContextKey(body, { originalText, newText, contextHints = [] }) { const original = String(originalText || '').trim(); const next = String(newText || '').trim(); if (!/^-?\d+(?:\.\d+)?$/.test(original) || !next || /^-?\d+(?:\.\d+)?$/.test(next)) { return { ok: false, reason: 'not a context-key numeric display edit' }; } const context = contextHints.join(' '); if (!context) return { ok: false, reason: 'missing context for keyed numeric edit' }; const lines = String(body || '').split('\n'); const valuePattern = new RegExp(`(['"])([^'"]{2,160})\\1\\s*:\\s*${escapeRegExp(original)}(?=\\s*[,}])`); for (let index = 0; index < lines.length; index += 1) { const match = lines[index].match(valuePattern); if (!match || !context.includes(match[2])) continue; lines[index] = lines[index].slice(0, match.index) + match[0].replace(new RegExp(`${escapeRegExp(original)}$`), JSON.stringify(next)) + lines[index].slice(match.index + match[0].length); return { ok: true, body: lines.join('\n') }; } return { ok: false, reason: 'no related keyed numeric value found' }; } function replaceTypedNumericDisplayExpression(body, { originalText, newText, line }) { const original = String(originalText || '').trim(); const displayText = String(newText || ''); const displayTrimmed = displayText.trim(); if (!/^-?\d+(?:\.\d+)?$/.test(original)) return { ok: false, reason: 'not a numeric display edit' }; if (!displayTrimmed || /^-?\d+(?:\.\d+)?$/.test(displayTrimmed)) { return { ok: false, reason: 'not a typed display expansion' }; } const lines = body.split('\n'); const lineIndex = Math.max(0, Math.min(lines.length - 1, Number(line) - 1)); const candidateIndexes = [lineIndex]; for (let i = 0; i < lines.length; i++) { if (i !== lineIndex && /String\([^)]+\)/.test(lines[i])) candidateIndexes.push(i); } for (const index of candidateIndexes) { const lineText = lines[index] || ''; const stringCall = lineText.match(/String\(([^)\n]+)\)/); if (stringCall) { const replacement = JSON.stringify(displayTrimmed); lines[index] = lineText.slice(0, stringCall.index) + replacement + lineText.slice(stringCall.index + stringCall[0].length); return { ok: true, body: lines.join('\n') }; } const bareExpression = lineText.match(/\{[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[['"][^'"]+['"]\])*\}/); if (bareExpression) { const replacement = `{${JSON.stringify(displayTrimmed)}}`; lines[index] = lineText.slice(0, bareExpression.index) + replacement + lineText.slice(bareExpression.index + bareExpression[0].length); return { ok: true, body: lines.join('\n') }; } } return { ok: false, reason: 'no typed display expression near sourceHint' }; } function replaceNearLine(body, originalText, newText, line, keyRenames = []) { const lines = body.split('\n'); const lineIndex = Math.max(0, Math.min(lines.length - 1, Number(line) - 1)); const indexes = []; for (let distance = 0; distance <= 3; distance += 1) { for (const i of distance === 0 ? [lineIndex] : [lineIndex - distance, lineIndex + distance]) { if (i >= 0 && i < lines.length && !indexes.includes(i)) indexes.push(i); } } for (const i of indexes) { const idx = lines[i].indexOf(originalText); if (idx === -1) continue; if (isNumericDisplayEdit(originalText, newText) && !numericReplacementAllowedOnLine(lines[i], idx, originalText)) continue; const replacement = sourceReplacementForLine(lines[i], originalText, newText); lines[i] = lines[i].slice(0, idx) + replacement + lines[i].slice(idx + originalText.length); lines[i] = applyCoupledSourceKeyRenames(lines[i], keyRenames); return { ok: true, body: lines.join('\n') }; } return { ok: false, reason: 'originalText not found near sourceHint' }; } function sourceAlreadyShowsAppliedOp(body, attempt, op) { const newText = typeof op?.newText === 'string' ? op.newText : ''; const originalText = typeof op?.originalText === 'string' ? op.originalText : ''; const lines = String(body || '').split('\n'); const lineNumber = Number(attempt?.line); if (Number.isFinite(lineNumber) && lineNumber > 0) { const lineIndex = Math.max(0, Math.min(lines.length - 1, lineNumber - 1)); const start = Math.max(0, lineIndex - 3); const end = Math.min(lines.length, lineIndex + 4); const windowLines = lines.slice(start, end); if (newText) return windowLines.some((line) => line.includes(newText)); if (originalText) return windowLines.every((line) => !line.includes(originalText)); } if (newText) return String(body || '').includes(newText); if (originalText) return !String(body || '').includes(originalText); return false; } function sourceKeyRenamesForEntry(entry) { return (entry?.ops || []) .filter((op) => typeof op.originalText === 'string' && typeof op.newText === 'string' && op.originalText.trim() && op.newText.trim() && op.originalText !== op.newText && op.originalText.length <= 120 && op.newText.length <= 120 && !/^-?\d+(?:\.\d+)?$/.test(op.originalText.trim()) ) .map((op) => ({ from: op.originalText, to: op.newText })); } function isNumericDisplayEdit(originalText, newText) { const original = String(originalText || '').trim(); const next = String(newText || '').trim(); return /^-?\d+(?:\.\d+)?$/.test(original) && !!next && !/^-?\d+(?:\.\d+)?$/.test(next); } function numericReplacementAllowedAt(body, index, originalText) { const source = String(body || ''); const lineStart = source.lastIndexOf('\n', index) + 1; const lineEndIndex = source.indexOf('\n', index); const lineEnd = lineEndIndex === -1 ? source.length : lineEndIndex; return numericReplacementAllowedOnLine( source.slice(lineStart, lineEnd), index - lineStart, originalText, ); } function numericReplacementAllowedOnLine(line, index, originalText) { if (isInsideQuotedString(line, index)) return false; const before = index > 0 ? line[index - 1] : ''; const after = line[index + String(originalText || '').length] || ''; if (/[A-Za-z0-9_$.-]/.test(before) || /[A-Za-z0-9_$.-]/.test(after)) return false; return true; } function isInsideQuotedString(line, index) { let quote = null; let escaped = false; for (let i = 0; i < index; i += 1) { const ch = line[i]; if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (quote) { if (ch === quote) quote = null; continue; } if (ch === '"' || ch === "'" || ch === '`') quote = ch; } return !!quote; } function sourceReplacementForLine(line, originalText, newText) { const original = escapeRegExp(String(originalText || '')); const isPlainNumber = /^-?\d+(?:\.\d+)?$/.test(String(originalText || '').trim()); const next = String(newText || ''); const nextIsPlainNumber = /^-?\d+(?:\.\d+)?$/.test(next.trim()); if (isPlainNumber && !nextIsPlainNumber) { const valuePattern = new RegExp(`(['\"][^'\"]+['\"]\\s*:\\s*)${original}(\\s*[,}])`); if (valuePattern.test(line)) return JSON.stringify(next); } return next; } function applyCoupledSourceKeyRenames(line, keyRenames) { let out = line; for (const { from, to } of keyRenames || []) { const escaped = escapeRegExp(from); out = out.replace(new RegExp(`'${escaped}'(?=\\s*:)`), `'${to.replace(/'/g, "\\'")}'`); out = out.replace(new RegExp(`"${escaped}"(?=\\s*:)`), `"${to.replace(/"/g, '\\"')}"`); } return out; } async function applyCoupledSourceKeyRenamesForEntry({ batch, entry, keyRenames, readRelativeFile, fileCache, filesTouched, }) { if (!Array.isArray(keyRenames) || keyRenames.length === 0) return; const files = new Set(filesTouched); for (const candidate of batch?.candidates || []) { if (candidate.entryId !== entry.id) continue; for (const match of candidate.objectKeyMatches || []) { const relativeFile = normalizeRelativeSourceFile(match.file); if (relativeFile) files.add(relativeFile); } } for (const file of files) { let body; try { body = await readRelativeFile(file); } catch { continue; } const lines = body.split('\n'); let changed = false; for (let index = 0; index < lines.length; index += 1) { const next = applyCoupledSourceKeyRenames(lines[index], keyRenames); if (next === lines[index]) continue; lines[index] = next; changed = true; } if (!changed) continue; fileCache.set(file, lines.join('\n')); filesTouched.add(file); } } function replaceAtIndex(body, index, originalText, newText) { return { ok: true, body: body.slice(0, index) + newText + body.slice(index + originalText.length), }; } function replaceAtIndexWithSourceRules(body, index, originalText, newText) { const source = String(body || ''); const lineStart = source.lastIndexOf('\n', index) + 1; const lineEndIndex = source.indexOf('\n', index); const lineEnd = lineEndIndex === -1 ? source.length : lineEndIndex; const line = source.slice(lineStart, lineEnd); const replacement = sourceReplacementForLine(line, originalText, newText); return replaceAtIndex(source, index, originalText, replacement); } function allIndexesOf(body, needle) { const out = []; let index = 0; while (true) { index = body.indexOf(needle, index); if (index === -1) return out; out.push(index); index += Math.max(1, needle.length); } } function scoreManualEditMatch(body, index, contextHints) { const windowText = body.slice(Math.max(0, index - 600), index + 600); let score = 0; for (const hint of contextHints) { if (hint && windowText.includes(hint)) score++; } return score; } function contextHintsForEntry(entry) { const hints = []; const add = (value) => { const text = String(value || '').replace(/\s+/g, ' ').trim(); if (text.length >= 2 && text.length <= 180) hints.push(text); }; for (const op of entry?.ops || []) { for (const nearby of op.nearbyEditableTexts || []) add(typeof nearby === 'string' ? nearby : nearby?.text); add(op.container?.textContent); add(op.leaf?.textContent); } add(entry?.element?.textContent); return [...new Set(hints)]; } function relatedSiblingRefsForOp(entry, op) { const context = contextHintsForSingleOp(op).join(' '); if (!context) return new Set(); return new Set((entry?.ops || []) .filter((sibling) => sibling.ref !== op.ref) .filter((sibling) => { const original = String(sibling.originalText || '').trim(); const next = String(sibling.newText || '').trim(); return (original && context.includes(original)) || (next && context.includes(next)); }) .map((sibling) => sibling.ref) .filter(Boolean)); } function contextHintsForSingleOp(op) { const hints = []; const add = (value) => { const text = String(value || '').replace(/\s+/g, ' ').trim(); if (text.length >= 2 && text.length <= 240) hints.push(text); }; for (const nearby of op?.nearbyEditableTexts || []) add(typeof nearby === 'string' ? nearby : nearby?.text); add(op?.container?.textContent); add(op?.leaf?.textContent); return [...new Set(hints)]; } function candidatesForEntry(batch, entryId) { const out = []; for (const candidate of batch?.candidates || []) { if (candidate.entryId !== entryId) continue; if (candidate.sourceHint?.relativeFile) { out.push({ file: candidate.sourceHint.relativeFile, line: candidate.sourceHint.line, kind: 'candidate_source_hint' }); } for (const key of ['textMatches', 'objectKeyMatches', 'locatorMatches', 'contextTextMatches']) { for (const match of candidate[key] || []) { out.push({ file: match.file, line: match.line, kind: match.kind || key }); } } } return out.slice(0, 20); } function renderMissingBaseVariantRules({ scopedCss, count, styleMode }) { const rules = []; for (let i = 1; i <= count; i++) { if (!hasBaseVariantRule(scopedCss, i, styleMode)) { rules.push(renderBaseVariantRule(i, styleMode)); } } return rules; } function hasBaseVariantRule(scopedCss, variantId, styleMode) { const q = String.raw`["']${variantId}["']`; if (styleMode === 'astro-global-prefixed') { return new RegExp(String.raw`\[data-impeccable-variant=${q}\](?:\s|>|\.|#|\[${HOIST_ATTR}=)`).test(scopedCss); } return new RegExp(String.raw`@scope\s*\(\s*\[data-impeccable-variant=${q}\]\s*\)`).test(scopedCss); } function renderBaseVariantRule(variantId, styleMode) { if (styleMode === 'astro-global-prefixed') { return [ `[data-impeccable-variant="${variantId}"] > * {`, ' --impeccable-variant-ready: 1;', '}', ].join('\n'); } return [ `@scope ([data-impeccable-variant="${variantId}"]) {`, ' :scope > * { --impeccable-variant-ready: 1; }', '}', ].join('\n'); } // Walk each opening tag char-by-char (respecting quotes so a literal `>` // inside an attribute value doesn't terminate the tag early), strip any // `style="..."`, and tag the element with `data-impeccable-hoist-id="N"`. // The downstream rule selects on that attribute so it targets the exact // element that was styled — never sibling tags of the same name. function stripInlineStylesPerElement(innerHtml) { const groups = []; const styleRe = /\sstyle=(["'])([\s\S]*?)\1/; let counter = 0; let result = ''; let i = 0; while (i < innerHtml.length) { const lt = innerHtml.indexOf('<', i); if (lt === -1) { result += innerHtml.slice(i); break; } result += innerHtml.slice(i, lt); const tagMatch = innerHtml.slice(lt + 1).match(/^([A-Za-z][\w:-]*)/); if (!tagMatch) { // , comments, text content — copy `<` and continue. result += '<'; i = lt + 1; continue; } const tagName = tagMatch[1]; let j = lt + 1 + tagName.length; let quote = null; while (j < innerHtml.length) { const ch = innerHtml[j]; if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") { quote = ch; } else if (ch === '>') { break; } j++; } if (j >= innerHtml.length) { // Unterminated tag (malformed input): copy verbatim and stop. result += innerHtml.slice(lt); break; } const attrs = innerHtml.slice(lt + 1 + tagName.length, j); const styleMatch = attrs.match(styleRe); if (!styleMatch) { result += innerHtml.slice(lt, j + 1); i = j + 1; continue; } const entries = parseInlineStyle(styleMatch[2]); const strippedAttrs = attrs.replace(styleRe, ''); if (entries.length === 0) { result += `<${tagName}${strippedAttrs}>`; i = j + 1; continue; } counter++; const hoistId = String(counter); groups.push({ hoistId, declarations: entries }); result += `<${tagName} ${HOIST_ATTR}="${hoistId}"${strippedAttrs}>`; i = j + 1; } return { innerHtml: result, groups }; } function renderHoistedInlineStyleRule({ variantId, hoistId, declarations, styleMode }) { // Select on the per-element hoist attribute, not the tag name, so two // s in the same variant where only one had an inline style cannot // both pick up the hoisted declarations. const lines = declarations.map(({ prop, value }) => ` ${prop}: ${value};`); const target = `[${HOIST_ATTR}="${hoistId}"]`; if (styleMode === 'astro-global-prefixed') { return [ `[data-impeccable-variant="${variantId}"] ${target} {`, ...lines.map((line) => line.slice(2)), '}', ].join('\n'); } return [ `@scope ([data-impeccable-variant="${variantId}"]) {`, ` :scope ${target} {`, ...lines, ' }', '}', ].join('\n'); } /** * Render the variants block in either HTML or JSX, depending on commentSyntax. * In JSX: * - comments use {/​* ... *​/} (already what commentSyntax.open is) * - wraps CSS in a template literal so JSX * doesn't choke on the {} in CSS * - non-default visible variants use style={{display: 'none'}} * - inner element class= becomes className=, style="..." becomes JSX style={{ ... }} * - data-impeccable-params stays a single-quoted JSON string (JSX-legal) */ function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, styleMode }) { const isJsx = commentSyntax.open === '{/*'; const isSvelte = !!file && file.endsWith('.svelte'); const isAstroGlobalCss = styleMode === 'astro-global-prefixed'; const styleLines = isJsx ? [ indent + ' ', ] : [ indent + ' ', ...output.scopedCss.split('\n').map((l) => indent + ' ' + l), indent + ' ', ]; const variantBlocks = output.variants.map((v, i) => { const idx = i + 1; const paramsAttr = v.params && v.params.length ? " data-impeccable-params='" + attrEscape(JSON.stringify(v.params), { svelte: isSvelte }) + "'" : ''; let styleAttr = ''; if (i !== 0) styleAttr = isJsx ? " style={{display: 'none'}}" : ' style="display: none"'; const inner = isJsx ? htmlToJsx(v.innerHtml) : v.innerHtml; return [ indent + ' ' + commentSyntax.open + ' Variant ' + idx + ' ' + commentSyntax.close, indent + '
', indent + ' ' + inner, indent + '
', ].join('\n'); }); return [...styleLines, ...variantBlocks].join('\n'); } /** * Splice the rendered variants block into an array of wrapper lines at the * "insert below this line" marker. Pure: returns the new lines array. Used * both against a whole file (wrapper already in source) and against a * standalone wrapper block (deferred source write, agent writes it now). */ function spliceVariantsIntoLines(lines, { sessionId, output, commentSyntax, file, styleMode }) { // Find the "Variants: insert below this line" comment line — definitive // marker, robust to any indentation off-by-one. Matches in any comment // style (HTML / JSX / Astro). const markerIdx = lines.findIndex((l) => l.includes('Variants: insert below this line'), ); if (markerIdx === -1) { throw new Error('insert marker not found in ' + file); } const indent = (lines[markerIdx].match(/^\s*/) || [''])[0]; // Indent INSIDE the wrapper is one level shallower (the marker is indented // 2 spaces relative to the wrapper opening). Remove the 2-space comment // indent to get the wrapper indent. const wrapperIndent = indent.replace(/ $/, ''); const block = renderVariantsBlock({ sessionId, indent: wrapperIndent, output, commentSyntax, file, styleMode, }); const endMarkerIdx = lines.findIndex((line, index) => index > markerIdx && line.includes('impeccable-variants-end ' + sessionId), ); if (endMarkerIdx === -1) { throw new Error('end marker not found in ' + file); } const tailIdx = commentSyntax.open === '{/*' ? endMarkerIdx : endMarkerIdx - 1; return [ ...lines.slice(0, markerIdx + 1), block, ...lines.slice(tailIdx), ]; } /** * Read the wrapped file, find the "insert below this line" marker, splice in * the rendered variants block, write back. Used when the wrapper is already * present in source (agent's own wrap fallback, no preflight). */ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) { const filePath = path.join(tmp, wrapInfo.file); const src = await fs.readFile(filePath, 'utf-8'); const lines = src.split('\n'); const next = spliceVariantsIntoLines(lines, { sessionId, output, commentSyntax: wrapInfo.commentSyntax, file: wrapInfo.file, styleMode: wrapInfo.styleMode, }); await fs.writeFile(filePath, next.join('\n'), 'utf-8'); } /** * Deferred source write (preflight computed the scaffold but left source * untouched). Splice the variants into the scaffold's `wrapperBlock`, then * replace the picked element's source range with the result in ONE write — * the 3.5 atomic single-edit semantics. `replaceEndLine < replaceStartLine` * expresses a pure insertion (insert mode). */ async function writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId, output }) { const filePath = path.join(tmp, wrapInfo.file); const src = await fs.readFile(filePath, 'utf-8'); const lines = src.split('\n'); const wrapperLines = String(wrapInfo.wrapperBlock).split('\n'); const splicedWrapper = spliceVariantsIntoLines(wrapperLines, { sessionId, output, commentSyntax: wrapInfo.commentSyntax, file: wrapInfo.file, styleMode: wrapInfo.styleMode, }); const startIdx = wrapInfo.replaceStartLine - 1; const endIdx = wrapInfo.replaceEndLine - 1; // may be startIdx-1 for insertion const next = [ ...lines.slice(0, startIdx), ...splicedWrapper, ...lines.slice(endIdx + 1), ]; await fs.writeFile(filePath, next.join('\n'), 'utf-8'); } async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) { const manifestPath = path.join(tmp, wrapInfo.file); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); const componentDir = path.join(tmp, manifest.componentDir); const isInsert = manifest.mode === 'insert'; const contract = Array.isArray(manifest.propContract) ? manifest.propContract : []; const propNames = contract.map((entry) => entry.prop); const baseMarkup = isInsert ? '' : substituteSvelteExprsWithProps(manifest.originalMarkup || '', contract).trim(); const textValues = isInsert ? [] : extractTextPieces(event.element?.outerHTML || event.element?.textContent || ''); const paramsByVariant = {}; for (let i = 0; i < output.variants.length; i++) { const variantId = i + 1; const variant = output.variants[i]; // Contract-v2 path: keep the scaffolded stub (control flow + prop // references) and swap only its ', '', ].join('\n'); await fs.writeFile(path.join(componentDir, `v${variantId}.svelte`), component, 'utf-8'); paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : []; } if (writeParams) { await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8'); } manifest.arrivedVariants = output.variants.length; await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); } function variantMarkupHasVisibleContent(markup) { const text = String(markup || '') .replace(//gi, '') .replace(//gi, '') .replace(//g, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); if (text.length > 0) return true; return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); } function buildSveltePropsScript(contract) { if (!contract.length) return ''; return ``; } function substituteSvelteExprsWithProps(markup, contract) { let out = String(markup || ''); for (const entry of contract) { out = out.split(`{${entry.expr}}`).join(`{${entry.prop}}`); } return out; } function substituteLiveTextWithProps(markup, contract, textValues) { let out = String(markup || ''); for (let i = 0; i < contract.length; i++) { const value = textValues[i]; if (!value) continue; out = out.split(htmlEscape(value)).join(`{${contract[i].prop}}`); out = out.split(value).join(`{${contract[i].prop}}`); } return out; } function extractTextPieces(html) { return String(html || '') .replace(//gi, '') .replace(//gi, '') .split(/<[^>]+>/) .map((text) => text.replace(/\s+/g, ' ').trim()) .filter(Boolean); } function firstTagName(markup) { const match = String(markup || '').match(/<([A-Za-z][\w:-]*)\b/); return match ? match[1].toLowerCase() : null; } function mergeTopLevelAttrs(baseMarkup, variantMarkup) { const base = String(baseMarkup || ''); const variant = String(variantMarkup || ''); const baseOpen = base.match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*)(>)/); const variantOpen = variant.match(/^\s*<([A-Za-z][\w:-]*)([^>]*)(>)/); if (!baseOpen || !variantOpen || baseOpen[2].toLowerCase() !== variantOpen[1].toLowerCase()) return base; return base.replace(baseOpen[0], `${baseOpen[1]}${baseOpen[2]}${variantOpen[2]}${baseOpen[4]}`); } function svelteCssForVariant(scopedCss, variantId, tag) { const css = String(scopedCss || ''); const chunks = extractVariantCssChunks(css, variantId); const rewritten = chunks .join('\n') .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\]\\s*>\\s*`, 'g'), '') .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\][^{]*>\\s*`, 'g'), '') .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') .replace(/:scope(?:\[[^\]]+\])?/g, tag) .split('\n') .map((line) => line.trimEnd()) .filter((line) => line.trim()) .join('\n') .trim(); return rewritten || `${tag} {}`; } function extractVariantCssChunks(css, variantId) { const lines = String(css || '').split('\n'); const chunks = []; let collecting = false; let depth = 0; for (const line of lines) { if (line.includes(`[data-impeccable-variant="${variantId}"]`) || line.includes(`[data-impeccable-variant='${variantId}']`)) { collecting = true; depth = 0; if (!line.trim().startsWith('@scope')) chunks.push(line); depth += braceDelta(line); if (depth <= 0) collecting = false; continue; } if (!collecting) continue; const before = depth; depth += braceDelta(line); if (before === 1 && depth === 0 && line.trim() === '}') { collecting = false; continue; } chunks.push(line); if (depth <= 0) collecting = false; } return chunks; } function braceDelta(line) { return (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; } // --------------------------------------------------------------------------- // Poll loop — the "agent" runs this until aborted // --------------------------------------------------------------------------- /** * @param {object} opts * @param {string} opts.tmp Project tmp dir (cwd for live-* scripts). * @param {string} opts.scriptsDir Path to the impeccable scripts dir. * @param {number} opts.port live-server port. * @param {string} opts.token live-server token. * @param {LiveAgent} opts.agent * @param {AbortSignal} opts.signal * @param {(msg: string) => void} [opts.log] * @param {object} [opts.steerSourceFile] Optional relative source path for steer edits. * @param {object} [opts.steerTarget] Optional { classes, tag } for steer target discovery. */ export async function runAgentLoop({ tmp, scriptsDir, port, token, agent, signal, log = () => {}, trace = () => {}, atomicDelayMs = 0, wrapTarget = { classes: 'hero-title', tag: 'h1' }, steerSourceFile, steerTarget, }) { const base = `http://127.0.0.1:${port}`; // Everything the agent needs to republish a component session without // re-running generate: the scaffold info and the variant set it authored. const publishedComponentSessions = new Map(); // A republish that fails to mount for the same reason would loop forever; // repair each (session, variant) at most once and let the user's Retry (or // the mount-error card) own anything beyond that. const repairedMounts = new Set(); while (!signal.aborted) { let event; try { const res = await fetch(`${base}/poll?token=${token}&timeout=5000`, { signal }); event = await res.json(); } catch (err) { if (signal.aborted) return; log('poll error: ' + err.message); await new Promise((r) => setTimeout(r, 200)); continue; } if (event.type === 'timeout') continue; if (event.type === 'exit') return; if (event.type === 'prefetch') continue; if (event.type === 'connected') continue; trace('agent.event.received', { id: event.id, type: event.type, clientSentAt: event.clientSentAt ?? null }); if (event.type === 'steer') { log(`steer id=${event.id} message=${JSON.stringify(event.message)}`); try { const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget; const steerCtxTarget = steerTarget || target; const steerContext = buildSteerContext({ tmp, event, wrapTarget: steerCtxTarget, sourceFile: steerSourceFile, }); let toast = 'Hero marked'; if (typeof agent.handleSteer === 'function') { const result = await agent.handleSteer(event, steerContext); toast = result?.message || toast; } else { await handleSteerDeterministic(steerContext); } await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'steer_done', id: event.id, message: toast, file: steerContext.targetFile, }), signal, }); } catch (err) { if (signal.aborted) return; log('steer failed: ' + err.message); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }), signal, }).catch(() => {}); } continue; } if (event.type === 'generate') { const isInsert = event.mode === 'insert'; log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`); try { let wrapInfo; if (event.scaffold) { wrapInfo = event.scaffold; trace('agent.scaffold.reused', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source', durationMs: event.scaffoldDurationMs ?? null, }); } else if (isInsert) { trace('agent.scaffold.start', { id: event.id, mode: 'insert' }); const insertTarget = insertTargetFromEvent(event); wrapInfo = await runInsert({ tmp, scriptsDir, id: event.id, count: event.count, ...insertTarget, }); trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' }); } else { trace('agent.scaffold.start', { id: event.id, mode: 'replace' }); // 1. Wrap the original element in the variant scaffold (deterministic CLI) // wrapTarget can be a static {classes, tag, elementId} (test fixtures // know what they pick) or a function (event) => target (real-use // sessions: the agent must derive the selector from the picked // element on the fly). const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget; // Pull textContent from the picker event so wrap can disambiguate // when sibling elements share classes/tag (issue #114). Fixtures can // still override by including `text` in their wrapTarget. const text = target.text ?? (event.element?.textContent || '').trim(); wrapInfo = await runWrap({ tmp, scriptsDir, id: event.id, count: event.count, ...target, text, }); trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' }); } log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`); // 2. Agent generates variant content (LLM-pluggable seam). // Providers may expose a true split path so variant 1 is written before // the request for the remaining variants completes. trace('agent.generate.start', { id: event.id, count: event.count }); let output = normalizeVariantOutput( await agent.generateVariants(event, { wrapTarget, wrapInfo, tmp }), wrapInfo, ); if (atomicDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, atomicDelayMs)); } trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 }); trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 }); if (output.variants.length !== event.count) { log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`); } // 3. Write the complete set into the deterministic preview target. trace('agent.write.start', { id: event.id, file: wrapInfo.file }); if (wrapInfo.previewMode === 'svelte-component') { await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true }); publishedComponentSessions.set(event.id, { wrapInfo, event, output }); } else if (wrapInfo.sourceWritten === false) { await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output }); } else { await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output }); } trace('agent.write.end', { id: event.id, file: wrapInfo.file }); if (process.env.IMPECCABLE_E2E_DEBUG) { const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'); log(`--- post-splice (variants written) ---\n${post}`); } // 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING) trace('agent.reply.start', { id: event.id }); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'done', sourceEventType: 'generate', id: event.id, file: wrapInfo.file }), signal, }); trace('agent.reply.end', { id: event.id }); } catch (err) { if (signal.aborted) return; if (isExpectedGenerationCancellation(err)) { trace('agent.generate.canceled', { id: event.id, reason: 'stale_generation_epoch' }); log('generate canceled after Accept/Discard: ' + err.message); continue; } trace('agent.generate.error', { id: event.id, message: err.message }); log('generate failed: ' + err.message); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'error', sourceEventType: 'generate', id: event.id, message: err.message }), signal, }).catch(() => {}); } continue; } // The browser could not render something the agent published. Only the // agent can fix that, which is why the server queues it as a first-class // event instead of leaving it in the journal. Rewriting the variant files // and replying `done` makes the server snapshot a fresh revision dir and // rebroadcast, which is what drives the browser's remount. if (event.type === 'variant_mount_failed') { const key = `${event.id}|${event.variant}`; const published = publishedComponentSessions.get(event.id); log(`variant_mount_failed id=${event.id} variant=${event.variant} error=${JSON.stringify(event.error)}`); if (agent.autoRepairMountFailures === false) { log('auto-repair disabled; leaving the mount-error card for the user to retry'); continue; } if (!published) { log('no published component session to repair; ignoring'); continue; } if (repairedMounts.has(key)) { log('already republished this variant once; not looping'); continue; } repairedMounts.add(key); try { await writeSvelteComponentVariants({ tmp, wrapInfo: published.wrapInfo, event: published.event, output: published.output, writeParams: true, }); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'done', // No explicit sourceEventType: the server's inferSourceEventType // maps this done onto the pending variant_mount_failed event, so // the failure is acknowledged and leaves the poll queue instead // of being redelivered forever. id: event.id, file: published.wrapInfo.file, }), signal, }); log(`republished component variants for ${event.id}`); } catch (err) { if (signal.aborted) return; log('variant_mount_failed repair failed: ' + err.message); } continue; } if (event.type === 'manual_edit_apply') { const entryCount = event.batch?.entries?.length || 0; const opCount = (event.batch?.entries || []).reduce((sum, entry) => sum + (entry.ops?.length || 0), 0) || entryCount; const chunkLabel = event.chunk ? ` (chunk ${event.chunk.index}/${event.chunk.total})` : ''; const applyFiles = formatManualApplyFiles(event.batch); log(`Applying ${opCount} staged copy edit(s)${chunkLabel} across ${applyFiles}.`); try { if (typeof agent.applyManualEdits !== 'function') { throw new Error('agent does not implement applyManualEdits'); } log("Using source hints first; I'll only touch the hinted copy."); const result = await agent.applyManualEdits(event, { tmp, scriptsDir }); if (process.env.IMPECCABLE_E2E_DEBUG) { log(`manual_edit_apply result: ${JSON.stringify(result)}`); } await runPollReply({ tmp, scriptsDir, id: event.id, status: 'done', data: result, }); const appliedCount = result.appliedEntryIds?.length || 0; const failedCount = result.failed?.length || Math.max(0, entryCount - appliedCount); if (failedCount > 0) { log(`Applied ${appliedCount}/${entryCount} edit(s); ${failedCount} stayed staged because ${result.failed?.[0]?.reason || 'one or more entries failed'}.`); } else if (event.chunk) { const finalChunk = event.chunk.index === event.chunk.total; log(`Applied ${appliedCount}/${entryCount} entry(s) for chunk ${event.chunk.index}/${event.chunk.total}; ${finalChunk ? 'waiting for server verification.' : 'polling for the next Apply chunk.'}`); } else { log(`Applied ${appliedCount}/${entryCount} edit(s) and cleared the Apply stash.`); } } catch (err) { if (signal.aborted) return; log('manual_edit_apply failed: ' + err.message); const failedEntries = (event.batch?.entries || []).map((entry) => ({ entryId: entry.id, reason: err.message || 'manual_edit_apply_failed', candidates: [], })).filter((item) => item.entryId); await runPollReply({ tmp, scriptsDir, id: event.id, status: 'done', data: { status: 'error', appliedEntryIds: [], failed: failedEntries, files: [], notes: [], message: err.message, }, }).catch(() => {}); } continue; } if (event.type === 'accept') { log(`accept id=${event.id} variantId=${event.variantId}`); try { const acceptResult = await runAccept({ tmp, scriptsDir, id: event.id, variant: event.variantId, paramValues: event.paramValues, pageUrl: event.pageUrl, }); // Carbonize cleanup — required after accept per the live skill spec. // For the fake agent, we perform a faithful but minimal cleanup: // delete the carbonize block (markers + dead variants + inline