Files
OpenQode/bin/goose-ultra-final/src/services/automationService.ts

1983 lines
68 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GooseUltraComputerDriver, GooseUltraBrowserDriver, GooseUltraServerDriver, Project } from '../types';
import { runVibeGuard, loadCurrentState, saveCurrentState, recordInteraction, ExecutionMode, saveSnapshot } from './ContextEngine';
import { SafeGenStreamer } from './StreamHandler';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Global model accessor - reads from localStorage to support Ollama integration
// This is updated by the React orchestrator when the model changes
export const getActiveModel = (): string => {
try {
const stored = localStorage.getItem('goose-active-model');
return stored || 'qwen-coder-plus';
} catch {
return 'qwen-coder-plus';
}
};
export const setActiveModel = (model: string): void => {
try {
localStorage.setItem('goose-active-model', model);
} catch {
// Ignore storage errors
}
};
export const CANVAS_ISOLATION_ARCHITECTURE = `
### CANVAS ISOLATION ARCHITECTURE
1. **Base Canvas**: Existing code is immutable. Avoid touching unrelated blocks.
2. **Isolation Chamber**: Extract ONLY specific sections mentioned.
3. **Precision Merge**: Merge back using surgical anchors. 100% preservation.
4. **Boundary Mapping**: Identify exact coordinates. No cascading changes.
`;
// --- GEMINI 3 PRO / VIBE CODING TEMPLATE ---
export const FRAMEWORK_TEMPLATE_PROMPT = `
You are an expert Frontend Engineer.
Your task is to implement the User's Plan into a SINGLE, HIGH-FIDELITY HTML FILE using the REQUESTED FRAMEWORK.
### TECHNICAL REQUIREMENTS:
1. ** Single File **: Everything(HTML, CSS, JS) must be in one file.
2. ** CDNs Only **: Use reputable CDNs(cdnjs, unpkg, esm.sh) for libraries.
3. ** React **: If React is requested, use React 18 + ReactDOM 18 + Babel Standalone(for JSX).
- <script src="https://unpkg.com/react@18/umd/react.development.js" > </script>
- <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" > </script>
- <script src="https://unpkg.com/@babel/standalone/babel.min.js" > </script>
- <script type="text/babel" > ... your code ... </script>
4. ** Vue **: If Vue is requested, use Vue 3 global build.
- <script src="https://unpkg.com/vue@3/dist/vue.global.js" > </script>
5. ** Aesthetics **: Unless the user specified a specific CSS framework(like Bootstrap), DEFAULT to ** TailwindCSS ** for styling to maintain "Vibe" quality.
- <script src="https://cdn.tailwindcss.com" > </script>
### CRITICAL: CLIENT - SIDE ONLY
- ** NO SERVER - SIDE TEMPLATES **: DO NOT use Jinja2, Liquid, PHP, etc.
- ** NO NODE.JS SPECIFIC **: No 'require', no 'process.env', no 'npm install'.
- ** Mock Data **: Create dummy data arrays inside the script.
### OUTPUT FORMAT:
- RETURN ** ONLY ** THE RAW HTML CODE.
- DO NOT USE MARKDOWN BLOCK.
- DO NOT include or display the plan / instructions in the UI.The plan is input - only.
`;
export const MODERN_TEMPLATE_PROMPT = `
You are an elite Frontend Architect simulating the reasoning and coding quality of Google Gemini 1.5 Pro.
### DESIGN & TECH STACK(NON - NEGOTIABLE)
1. ** Framework **: Vanilla HTML5 + JavaScript(ES6 +).NO React / Vue / Angular unless explicitly requested.
2. ** Styling **: Tailwind CSS(via CDN).Use modern utility classes(flex, grid, glassmorphism, gradients).Use slate / zinc / neutral for structural greys and vibrant indigo / violet / blue for accents.
3. ** Icons **: FontAwesome(via CDN).
4. ** Font **: 'Inter' or 'JetBrains Mono' via Google Fonts.
### ATOMIC OUTPUT PROTOCOL
1. You are strictly forbidden from 'chatting' your code.
2. You must output the code inside a standard XML block: <artifact_payload>.
3. Do not output internal tool logs(like << goose) inside this block.
Example Output:
<artifact_payload file="index.html" >
<!DOCTYPE html >
<html lang="en" >
...
</html>
</artifact_payload>
`;
export const MockComputerDriver: GooseUltraComputerDriver = {
checkArmed: () => true, // In real app, check env var or system flag
runAction: async (action, params) => {
console.log(`[Desktop] ${action} `, params);
await sleep(800);
return { status: 'success', screenshot: 'https://picsum.photos/800/600' };
}
};
export const MockBrowserDriver: GooseUltraBrowserDriver = {
navigate: async (url) => {
console.log(`[Browser] Navigate: ${url} `);
await sleep(1000);
},
assert: async (selector) => {
console.log(`[Browser] Assert: ${selector} `);
await sleep(500);
return true; // Always pass in mock
}
};
export const MockServerDriver: GooseUltraServerDriver = {
connect: async (host) => {
console.log(`[Server] Connecting to ${host}...`);
await sleep(1500);
return true;
},
runCommand: async (cmd, dryRun) => {
if (dryRun) return `[DryRun] Would execute: ${cmd} `;
console.log(`[Server] Executing: ${cmd} `);
await sleep(1000);
return `Success: ${cmd} executed.\nLogs: [System OK]`;
}
};
// P0-2: Task Match Gate
export interface TaskMatchResult {
matchesRequest: boolean;
why: string;
detectedAppType: string;
requestType: string;
}
const runTaskMatchCheck = async (
plan: string,
userRequest: string
): Promise<TaskMatchResult> => {
const electron = (window as any).electron;
if (!electron) return { matchesRequest: true, why: "Offline", detectedAppType: "unknown", requestType: "unknown" }; // Skip in offline mode
const prompt = `You are a strict QA Gatekeeper.
User Request: "${userRequest}"
Proposed Plan: "${plan.substring(0, 500)}..."
Analyze if the Plan matches the User Request category.
- If User asked for a Game and Plan is a Dashboard -> FAIL.
- If User asked for a Portfolio and Plan is a Chatbot -> FAIL.
- If they vaguely match(e.g., "App" and "Dashboard"), PASS.
OUTPUT ONLY JSON:
{
"matchesRequest": boolean,
"why": "string reason",
"detectedAppType": "string",
"requestType": "string"
} `;
return new Promise((resolve) => {
let buf = '';
const onChunk = (c: string) => buf += c;
const onComplete = (c: string) => {
electron.removeChatListeners();
const final = (c && c.length > buf.length ? c : buf).replace(/```json/gi, '').replace(/```/g, '').trim();
try {
const res = JSON.parse(final);
resolve(res);
} catch (e) {
resolve({ matchesRequest: true, why: "JSON Parse Error", detectedAppType: "unknown", requestType: "unknown" });
}
};
electron.onChatChunk(onChunk);
electron.onChatComplete(onComplete);
electron.startChat([
{ role: 'system', content: prompt }
], getActiveModel());
});
};
export const compilePlanToCode = async (
plan: string,
onChunk?: (code: string) => void,
projectId?: string,
preferredFramework?: string | null,
persona?: { name: string, prompt: string } | null,
skills?: string[] | null
): Promise<string> => {
const electron = (window as any).electron;
if (!electron) return "<!-- Electron Not Available --><h1 style='color:red'>Offline Mode</h1>";
// M1: Load Project Memory
let memoryContext = "";
if (projectId) {
const memories = await loadProjectMemories(projectId);
// M3: Retrieve Top-K
const relevant = retrieveRelevantMemories(memories, plan, 5); // Top-5 relevant to the plan
memoryContext = formatMemoriesForPrompt(relevant);
}
const MAX_RETRIES = 3;
let attempt = 0;
let lastQaReport: QualityReport | null = null;
// P0-2: Run Task Match Check before generating code (Pass 1)
// We infer the original request from the plan title or first few lines if not explicitly passed.
// Ideally, we passed the request explicitly, but for now we extract it from the plan header.
if (onChunk) onChunk("// Verifying Plan Alignment with User Request..."); // VISUAL FEEDBACK
const userRequestInferred = plan.split('\n')[0].replace('#', '').trim() || "App Build";
// Add Timeout to prevent stuck state
const matchResultPromise = runTaskMatchCheck(plan, userRequestInferred);
const timeoutPromise = new Promise<TaskMatchResult>(resolve =>
setTimeout(() => resolve({ matchesRequest: true, why: "Timeout (Fail Open)", detectedAppType: "unknown", requestType: "unknown" }), 5000)
);
const matchResult = await Promise.race([matchResultPromise, timeoutPromise]);
if (!matchResult.matchesRequest) {
console.error("[AutoService] BLOCKED: Plan matches " + matchResult.detectedAppType + " but User asked for " + matchResult.requestType);
// We could throw here, but the contract says "Auto-retry once with stronger instructions".
// We will inject this failure into the prompt.
}
while (attempt < MAX_RETRIES) {
attempt++;
console.log(`[AutoService] Compiling Plan to Code... Attempt ${attempt}/${MAX_RETRIES}`);
// VISUAL FEEDBACK
if (onChunk) onChunk(`// Initializing Build Engine (Attempt ${attempt})...`);
try {
const result = await new Promise<string>((resolve, reject) => {
let fullResponse = '';
// Ensure clean state BEFORE attaching new listeners
electron.removeChatListeners();
let lastSafeCode = '';
const streamer = new SafeGenStreamer();
// ... (handlers definition)
const onChunkHandler = (c: string) => {
const safeUpdate = streamer.processChunk(c);
if (safeUpdate) {
if (!safeUpdate.startsWith("<!-- Generating")) {
lastSafeCode = safeUpdate;
}
if (onChunk) onChunk(safeUpdate);
}
};
const onCompleteHandler = (c: string) => {
if (c && c.length > 5) {
const safeUpdate = streamer.processChunk(c);
if (safeUpdate && !safeUpdate.startsWith("<!-- Generating")) {
lastSafeCode = safeUpdate;
}
}
cleanup();
if (lastSafeCode) {
resolve(lastSafeCode);
} else {
console.warn("[SafeGen] Output was incomplete or invalid.");
resolve("<!-- Error: SafeGen failed to validate artifact. -->");
}
};
const onErrorHandler = (e: any) => {
cleanup();
reject(e);
};
const cleanup = () => {
electron.removeChatListeners();
};
electron.onChatChunk(onChunkHandler);
electron.onChatComplete(onCompleteHandler);
electron.onChatError(onErrorHandler);
// ... (Prompt Construction)
// Construct Prompt (F5: XML Artifact Bundle)
let systemPrompt = `You are a strict Build Engine.
${CANVAS_ISOLATION_ARCHITECTURE}
1. FIRST, output a <thinking> block. Discuss strategy/design (e.g. "Using dark theme...").
2. THEN, output the files using <goose_file> tags.
SCHEMA:
<thinking>
...
</thinking>
<goose_file path="index.html">
<!DOCTYPE html>
...
</goose_file>
<goose_file path="style.css">
body { ... }
</goose_file>
RULES:
1. Do NOT escape content (no need for \\n or \\").
2. Do NOT use markdown code blocks (\`\`\`) inside the tags.
3. index.html MUST be a complete file.
4. Output MUST start with <thinking>.
5. DO NOT generate a "Plan" or "Documentation" page. Generate the ACTUAL APP requested.
`;
let userContent = "IMPLEMENTATION PLAN (input-only):\n" + plan;
if (memoryContext) {
systemPrompt += "\n" + memoryContext;
}
if (preferredFramework) {
systemPrompt += `\n\n[USER PREFERENCE]: Use "${preferredFramework}".`;
}
// --- INJECT PERSONA & SKILLS ---
if (persona) {
systemPrompt += `\n\n[ACTIVE PERSONA]: You are acting as "${persona.name}".\n${persona.prompt}`;
}
if (skills && skills.length > 0) {
systemPrompt += `\n\n[AVAILABLE SKILLS]: You have the following capabilities/skills available: ${skills.join(', ')}. Ensure you leverage them if relevant to the implementation.`;
}
// P0-2: Task Mismatch Handling (Inject into Prompt)
if (!matchResult.matchesRequest && attempt === 1) {
systemPrompt += `\n\n⚠ CRITICAL WARNING: Previous plan drifted. User asked for ${matchResult.requestType}. BUILD THAT.`;
}
// Retry Logic with Repair Prompt
if (attempt > 1 && lastQaReport) {
// We just reuse strict mode instructions
systemPrompt += `\n\n[REPAIR MODE] Fix these errors:\n${lastQaReport.gates.filter(g => !g.passed).map(g => g.errors.join(', ')).join('\n')}`;
}
// Just start chat - listeners are already attached (and cleaned before that)
electron.startChat([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
], getActiveModel());
});
// F5: Parse XML Bundle for QA
let filesForQa: Record<string, string> = {};
try {
// Strip thinking block for cleaner processing (optional, regex handles it but good for debug)
const regex = /<goose_file\s+path=["']([^"']+)["']\s*>([\s\S]*?)<\/goose_file>/g;
let m;
while ((m = regex.exec(result)) !== null) {
if (m[1] && m[2]) {
filesForQa[m[1]] = m[2].trim();
}
}
// Fallback: If no tags found, try legacy detection (plain HTML)
if (Object.keys(filesForQa).length === 0) {
if (result.includes('<!DOCTYPE') || result.includes('<html')) {
filesForQa['index.html'] = result;
}
}
} catch (e) {
// Fallback or fail
if (result.includes('<!DOCTYPE') || result.includes('<html')) {
filesForQa['index.html'] = result;
}
}
const qaReport = runQualityGates(filesForQa);
if (qaReport.overallPass) {
console.log("[AutoService] ✓ Quality gates passed.");
// Return the RAW RESULT (JSON or HTML) - generateMockFiles will handle parsing again
return result;
} else {
console.warn(`[AutoService] Attempt ${attempt} failed QA:`, qaReport.gates.filter(g => !g.passed).map(g => g.gate));
lastQaReport = qaReport;
if (attempt >= MAX_RETRIES) {
console.error("[AutoService] QA failed after max retries. Returning best effort.");
return result;
}
await sleep(1000);
}
} catch (e) {
if (attempt >= MAX_RETRIES) throw e;
console.error(`[AutoService] Attempt ${attempt} error:`, e);
await sleep(1000);
}
}
throw new Error("Code generation failed unexpectedly.");
};
const PLAN_TAG_RE = /\[+\s*plan\s*\]+/gi;
const stripPlanTag = (text: string) => text.replace(PLAN_TAG_RE, '').replace(/^\s*plan\s*:\s*/i, '').trim();
// --- Fingerprint Helpers for Redesign Detection ---
const computeDomSignature = (html: string): string => {
// Extract tag sequence from body content
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const body = bodyMatch ? bodyMatch[1] : html;
const tags = body.match(/<(\w+)[^>]*>/g) || [];
const tagNames = tags.slice(0, 50).map(t => t.match(/<(\w+)/)?.[1] || '').join(',');
// Simple hash
let hash = 0;
for (let i = 0; i < tagNames.length; i++) {
hash = ((hash << 5) - hash) + tagNames.charCodeAt(i);
hash |= 0;
}
return hash.toString(16);
};
const computeCssSignature = (html: string): string => {
// Extract color tokens and font-family
const colors = html.match(/(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|hsl\([^)]+\))/g) || [];
const fonts = html.match(/font-family:\s*([^;]+)/g) || [];
const signature = [...colors.slice(0, 10), ...fonts.slice(0, 3)].join('|');
let hash = 0;
for (let i = 0; i < signature.length; i++) {
hash = ((hash << 5) - hash) + signature.charCodeAt(i);
hash |= 0;
}
return hash.toString(16);
};
const computeLayoutSignature = (html: string): string => {
// Count major structural elements
const hasNav = /<nav|class="[^"]*nav/i.test(html);
const hasHero = /<section[^>]*hero|class="[^"]*hero/i.test(html);
const hasFooter = /<footer/i.test(html);
const sectionCount = (html.match(/<section/gi) || []).length;
const divCount = (html.match(/<div/gi) || []).length;
return `nav:${hasNav},hero:${hasHero},footer:${hasFooter},sections:${sectionCount},divs:${Math.floor(divCount / 5) * 5}`;
};
const compareSignatures = (before: { dom: string; css: string; layout: string }, after: { dom: string; css: string; layout: string }) => {
const domChanged = before.dom !== after.dom;
const cssChanged = before.css !== after.css;
const layoutChanged = before.layout !== after.layout;
// Layout change is most severe indicator of redesign
const isLikelyRedesign = layoutChanged || (domChanged && cssChanged);
return { domChanged, cssChanged, layoutChanged, isLikelyRedesign };
};
// --- Change Budget Calculator ---
const computeChangeBudget = (before: string, after: string): { addedLines: number; removedLines: number; changedLines: number; totalChanges: number } => {
const beforeLines = before.split('\n');
const afterLines = after.split('\n');
// Simple line-based diff approximation
const beforeSet = new Set(beforeLines.map(l => l.trim()).filter(l => l.length > 0));
const afterSet = new Set(afterLines.map(l => l.trim()).filter(l => l.length > 0));
let addedLines = 0;
let removedLines = 0;
for (const line of beforeSet) {
if (!afterSet.has(line)) removedLines++;
}
for (const line of afterSet) {
if (!beforeSet.has(line)) addedLines++;
}
return {
addedLines,
removedLines,
changedLines: Math.min(addedLines, removedLines),
totalChanges: addedLines + removedLines
};
};
// --- AI Self-Check Gate ---
interface SelfCheckVerdict {
preservedDesign: boolean;
onlyRequestedChanges: boolean;
forbiddenChangesDetected: string[];
summaryOfChanges: string;
}
const runAISelfCheck = async (
beforeHtml: string,
afterHtml: string,
userRequest: string
): Promise<SelfCheckVerdict> => {
const electron = (window as any).electron;
if (!electron) {
return { preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Offline mode - skipped' };
}
const selfCheckPrompt = `You are a strict QA validator.
${CANVAS_ISOLATION_ARCHITECTURE}
Compare BEFORE and AFTER HTML based on the USER REQUEST: "${userRequest}"
GOAL: Verify that ONLY the requested changes were made and the overall design remains identical.
Return ONLY valid JSON (no markdown):
{
"preservedDesign": true/false (DID LAYOUT, COLORS, FONTS, AND BASIC STRUCTURE REMAIN UNCHANGED?),
"onlyRequestedChanges": true/false (DID THE AI AVOID ADDING UNREQUESTED FEATURES OR CHANGING UNTOUCHED BLOCKS?),
"forbiddenChangesDetected": ["list exactly what changed that wasn't asked for"],
"summaryOfChanges": "one sentence summary"
}`;
const userContent = `BEFORE (first 500 chars):\n${beforeHtml.substring(0, 500)}\n\nAFTER (first 500 chars):\n${afterHtml.substring(0, 500)}`;
return new Promise((resolve) => {
let buffer = '';
electron.removeChatListeners();
electron.onChatChunk((c: string) => { buffer += c; });
electron.onChatComplete((response: string) => {
electron.removeChatListeners();
let json = (response || buffer).trim();
json = json.replace(/```json/gi, '').replace(/```/g, '').trim();
const first = json.indexOf('{');
const last = json.lastIndexOf('}');
if (first !== -1 && last > first) {
json = json.substring(first, last + 1);
}
try {
const parsed = JSON.parse(json);
resolve({
preservedDesign: parsed.preservedDesign ?? true,
onlyRequestedChanges: parsed.onlyRequestedChanges ?? true,
forbiddenChangesDetected: parsed.forbiddenChangesDetected ?? [],
summaryOfChanges: parsed.summaryOfChanges ?? ''
});
} catch {
console.warn('[SelfCheck] Parse failed, assuming pass');
resolve({ preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Parse error - assumed pass' });
}
});
electron.onChatError(() => {
electron.removeChatListeners();
resolve({ preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Error - assumed pass' });
});
electron.startChat([
{ role: 'system', content: selfCheckPrompt },
{ role: 'user', content: userContent }
], getActiveModel());
});
};
// --- Constants for Change Budget ---
const CHANGE_BUDGET = {
maxHtmlLineEdits: 80,
maxCssLineEdits: 120, // Not used since CSS is inline
maxJsLineEdits: 160 // Not used since JS is inline
};
// --- Patch Engine Types ---
export interface Patch {
file: string;
op: 'INSERT_AFTER' | 'INSERT_BEFORE' | 'REPLACE_BLOCK' | 'DELETE_BLOCK';
anchor: {
type: 'STRING' | 'REGEX';
value: string;
};
content?: string;
reason?: string;
}
export interface PatchPlan {
mode: 'MODIFY_EXISTING';
projectId: string;
request: string;
redesignRequested: boolean;
files: string[];
changeBudget: {
maxEdits: number;
maxNewLines: number;
};
patches: Patch[];
}
// --- Local Patch Applier ---
const applyPatches = (html: string, patches: Patch[]): string => {
let currentHtml = html;
for (const patch of patches) {
if (patch.file !== 'index.html' && patch.file !== 'file') continue; // Single file mode for now
// Locate anchor
let anchorIndex = -1;
let matchLength = 0;
if (patch.anchor.type === 'REGEX') {
try {
const re = new RegExp(patch.anchor.value);
const match = currentHtml.match(re);
if (match && match.index !== undefined) {
anchorIndex = match.index;
matchLength = match[0].length;
}
} catch (e) {
console.warn(`[Patch] Invalid Regex: ${patch.anchor.value}`);
}
} else {
anchorIndex = currentHtml.indexOf(patch.anchor.value);
matchLength = patch.anchor.value.length;
}
if (anchorIndex === -1) {
console.warn(`[Patch] Anchor not found: ${patch.anchor.value.substring(0, 50)}...`);
continue; // Skip failed patch
}
// Apply Op
const content = patch.content || '';
if (patch.op === 'REPLACE_BLOCK') {
currentHtml = currentHtml.substring(0, anchorIndex) + content + currentHtml.substring(anchorIndex + matchLength);
} else if (patch.op === 'DELETE_BLOCK') {
currentHtml = currentHtml.substring(0, anchorIndex) + currentHtml.substring(anchorIndex + matchLength);
} else if (patch.op === 'INSERT_AFTER') {
currentHtml = currentHtml.substring(0, anchorIndex + matchLength) + content + currentHtml.substring(anchorIndex + matchLength);
} else if (patch.op === 'INSERT_BEFORE') {
currentHtml = currentHtml.substring(0, anchorIndex) + content + currentHtml.substring(anchorIndex);
}
}
return currentHtml;
};
export const applyPlanToExistingHtml = async (
plan: string,
currentHtml: string,
onChunk?: (code: string) => void,
retryCount: number = 0,
projectId?: string,
persona?: { name: string, prompt: string } | null,
skills?: string[] | null
): Promise<string> => {
const electron = (window as any).electron;
if (!electron) return "<!-- Electron Not Available --><h1 style='color:red'>Offline Mode</h1>";
// M1: Injest Memory
let memoryContext = "";
if (projectId) {
const memories = await loadProjectMemories(projectId);
const relevant = retrieveRelevantMemories(memories, plan, 5);
memoryContext = formatMemoriesForPrompt(relevant);
}
// --- INJECT PERSONA & SKILLS ---
if (persona) {
memoryContext += `\n\n[ACTIVE PERSONA]: You are acting as "${persona.name}".\n${persona.prompt}`;
}
if (skills && skills.length > 0) {
memoryContext += `\n\n[AVAILABLE SKILLS]: You have the following capabilities/skills available: ${skills.join(', ')}. Ensure you leverage them if relevant to the implementation.`;
}
// PATCH PROMPT
const PATCH_PROMPT = `
You are an expert Frontend Engineer.
The User wants to MODIFY the existing application.
DO NOT REWRITE THE FULL FILE. Provide a JSON Patch Plan.
${CANVAS_ISOLATION_ARCHITECTURE}
### FILES
We are working on a single file 'index.html' which contains HTML, CSS, and JS.
### MEMORY & CONSTRAINTS
${memoryContext}
### INSTRUCTIONS
1. Analyze the CURRENT_HTML and the REQUEST.
2. Produce a JSON Patch Plan (Strict JSON only).
3. Use 'REPLACE_BLOCK' to modify existing code.
4. Use 'INSERT_AFTER' / 'INSERT_BEFORE' to add new features.
5. 'anchor' must be a UNIQUE string or regex in the file to locate where to apply the change.
6. Keep 'content' minimal. Do NOT include unchanged surrounding code in 'content'.
### JSON FORMAT
{
"mode": "MODIFY_EXISTING",
"projectId": "${projectId || 'unknown'}",
"request": "summary of request",
"redesignRequested": false,
"files": ["index.html"],
"changeBudget": { "maxEdits": 50, "maxNewLines": 200 },
"patches": [
{
"file": "index.html",
"op": "REPLACE_BLOCK",
"anchor": { "type": "STRING", "value": "<h1 class=\"title\">Old Title</h1>" },
"content": "<h1 class=\"title\">New Title</h1>"
}
]
}
If the request requires a FULL REDESIGN (changing >50% of layout or colors), set "redesignRequested": true.
`;
return new Promise((resolve, reject) => {
let fullResponse = '';
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Patch Generation Timeout (90s). The model took too long to respond."));
}, 90000);
const onChunkHandler = (c: string) => {
fullResponse += c;
// Throttled status updates
if (onChunk && fullResponse.length % 50 === 0) {
onChunk(JSON.stringify({ status: "Generating Patch...", size: fullResponse.length }));
}
};
const onCompleteHandler = async (c: string) => {
clearTimeout(timeout);
cleanup();
let rawJson = (c.length > fullResponse.length ? c : fullResponse).trim();
// Robust extraction: find first { and last }
const firstBrace = rawJson.indexOf('{');
const lastBrace = rawJson.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
rawJson = rawJson.substring(firstBrace, lastBrace + 1);
}
// Parse JSON
let patchPlan: PatchPlan | null = null;
try {
const jsonStr = rawJson.replace(/```json/gi, '').replace(/```/g, '').trim();
patchPlan = JSON.parse(jsonStr);
} catch (e) {
console.error("Failed to parse patch JSON", e, rawJson.substring(0, 300));
if (retryCount < 1) {
return resolve(applyPlanToExistingHtml(plan, currentHtml, onChunk, retryCount + 1, projectId));
}
reject(new Error("Failed to parse patch plan: " + (e as Error).message));
return;
}
if (patchPlan?.redesignRequested) {
// Fallback to full rewrite if redesign requested
// We would ideally ask user confirmation here, but for now we throw/reject or handle upstream?
// The contract says: "Require explicit REDESIGN_OK confirmation".
// Since we can't show a modal easily from here without UI callback, we'll Reject with a special error.
reject(new Error("REDESIGN_REQUESTED: The model requesting a full redesign. Please confirm."));
return;
}
// CLIE: Save Snapshot (Before applying patches)
if (projectId && patchPlan) {
// We save the state *before* modifications are applied
const description = patchPlan.request ? `Before: ${patchPlan.request}` : 'Before update';
// For now, we assume index.html is the main file.
// In a real multi-file system we'd snapshot all files, but currentHtml implies single context here.
saveSnapshot(projectId, description, { 'index.html': currentHtml }).catch(e => console.error('[CLIE] Snapshot failed:', e));
}
// Apply Patches
let newHtml = currentHtml;
if (patchPlan && patchPlan.patches) {
newHtml = applyPatches(currentHtml, patchPlan.patches);
}
// CLIE: VIBE GUARD Check
// Only run if we have a projectId (so we can load context)
if (projectId && patchPlan && !patchPlan.redesignRequested) {
try {
const currentState = await loadCurrentState(projectId);
if (currentState) {
// Assume REPAIR_MODE if we are here (patching existing) without redesign
// Ideally we'd pass the mode from intent analysis, but let's default to strict safety
const mode: ExecutionMode = 'REPAIR_MODE';
const vibeCheck = runVibeGuard(mode, currentState, newHtml);
await recordInteraction(
projectId,
patchPlan.request || 'Unknown Patch',
mode,
`Applied ${patchPlan.patches?.length || 0} patches`,
vibeCheck.approved,
vibeCheck.domDrift
);
if (!vibeCheck.approved) {
console.error(`[CLIE] Vibe Guard Blocked Update: ${vibeCheck.reason}`);
reject(new Error(`VIBE GUARD BLOCKED: ${vibeCheck.reason}. The AI attempted a destructive change (` + vibeCheck.domDrift + '% structural change) during a repair task. Please explicitly request a "Redesign" if you want to change the layout.'));
return;
}
}
} catch (e) {
console.error('[CLIE] Vibe Guard Error (Allowing update):', e);
}
}
// Validate
const qReport = runQualityGates(newHtml);
if (!qReport.overallPass) {
console.warn("Patched HTML failed QA", qReport);
// Auto-repair? Or just return original?
// We can try to repair the RESULTING html using the standard repair loop
// But maybe simpler to just return it and let the standard flow handle it?
// The contract says "on_validation_failure: Do not write files... Auto-retry".
if (retryCount < 1) {
return resolve(applyPlanToExistingHtml(plan, currentHtml, onChunk, retryCount + 1, projectId));
}
}
// CLIE: Update State
if (projectId) {
// We only save the structural state - CSS handling is simplified here (assuming inline or separate)
// If we had the CSS we would pass it. For now, passing empty string for CSS update, causing re-extraction from HTML if present.
saveCurrentState(projectId, newHtml, "").catch(e => console.error(e));
}
resolve(newHtml);
};
const onErrorHandler = (e: any) => {
cleanup();
reject(e);
};
const cleanup = () => {
electron.removeChatListeners();
};
electron.onChatChunk(onChunkHandler);
electron.onChatComplete(onCompleteHandler);
electron.onChatError(onErrorHandler);
electron.startChat(
[
{ role: 'system', content: PATCH_PROMPT },
{ role: 'user', content: `CURRENT_HTML (snippet):\n${currentHtml.substring(0, 5000) + "..."}\n\nREQUEST:\n${plan}` }
],
getActiveModel()
);
});
};
export const generateMockPlan = () => `
# Project Plan: Vibe Coder
## Objectives
- Create a slick React interface
- Implement state machine
- ensure darkness
## Files
- /src/App.tsx
- /src/components/Header.tsx
`;
// Helper to get electron API
const getElectron = () => (window as any).electron;
export const getUserDataRoot = async (): Promise<string | null> => {
const electron = getElectron();
if (!electron?.getAppPath) return null;
try {
return await electron.getAppPath();
} catch {
return null;
}
};
const safeJsonParse = <T,>(raw: string, fallback: T): T => {
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
};
export const ensureProjectOnDisk = async (project: Project): Promise<void> => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
const base = `${userData}/projects/${project.id}`;
const metadataPath = `${base}/project.json`;
await electron.fs.write(metadataPath, JSON.stringify(project, null, 2));
};
export const writeLastActiveProjectId = async (projectId: string): Promise<void> => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
await electron.fs.write(`${userData}/projects/.lastActive.json`, JSON.stringify({ projectId }, null, 2));
};
export const readLastActiveProjectId = async (): Promise<string | null> => {
const electron = getElectron();
if (!electron?.fs) return null;
const userData = await getUserDataRoot();
if (!userData) return null;
try {
const raw = await electron.fs.read(`${userData}/projects/.lastActive.json`);
const parsed = safeJsonParse<{ projectId?: string }>(raw, {});
return parsed.projectId || null;
} catch {
return null;
}
};
// --- Project Context Memory (for modification retention) ---
export interface ProjectContext {
projectId: string;
createdAt: string;
updatedAt: string;
designLockEnabled: boolean;
designIntent: string;
uiTokens: {
colors: string[];
fonts: string[];
borderRadiusHints: string[];
};
structureSummary: string;
goldenSnapshot: {
indexHtmlSha256: string;
styleCssSha256: string | null;
scriptJsSha256: string | null;
createdAt: string;
};
fingerprints: {
domSignature: string;
cssSignature: string;
layoutSignature: string;
};
modificationBudgetDefaults: {
maxHtmlLineEdits: number;
maxCssLineEdits: number;
maxJsLineEdits: number;
};
}
export const saveProjectContext = async (projectId: string, context: ProjectContext): Promise<void> => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
const path = `${userData}/projects/${projectId}/goose-context.json`;
await electron.fs.write(path, JSON.stringify(context, null, 2));
};
export const loadProjectContext = async (projectId: string): Promise<ProjectContext | null> => {
const electron = getElectron();
if (!electron?.fs) return null;
const userData = await getUserDataRoot();
if (!userData) return null;
try {
const raw = await electron.fs.read(`${userData}/projects/${projectId}/goose-context.json`);
return safeJsonParse<ProjectContext>(raw, null as any);
} catch {
return null;
}
};
export const extractProjectContext = (html: string, projectId: string): ProjectContext => {
// Extract colors (hex codes)
const colors = (html.match(/#[0-9a-fA-F]{3,8}/g) || []).slice(0, 10);
// Extract fonts
const fonts = (html.match(/font-family:\s*([^;]+)/gi) || []).map(f => f.replace(/font-family:\s*/i, '')).slice(0, 5);
// Extract structure summary
const hasNav = /<nav/i.test(html);
const hasHero = /hero|jumbotron/i.test(html);
const hasFeatures = /features|benefits/i.test(html);
const hasFooter = /<footer/i.test(html);
const sectionCount = (html.match(/<section/gi) || []).length;
const structureSummary = [
hasNav ? 'nav' : '',
hasHero ? 'hero' : '',
hasFeatures ? 'features' : '',
`${sectionCount} sections`,
hasFooter ? 'footer' : ''
].filter(Boolean).join(', ');
// Compute fingerprints
const domSignature = computeDomSignature(html);
const cssSignature = computeCssSignature(html);
const layoutSignature = computeLayoutSignature(html);
// Extract border radius hints
const borderRadiusHints = (html.match(/border-radius:\s*([^;]+)/gi) || [])
.map(r => r.replace(/border-radius:\s*/i, ''))
.slice(0, 5);
// Simple hash for snapshot
const simpleHash = (s: string): string => {
let hash = 0;
for (let i = 0; i < Math.min(s.length, 1000); i++) {
hash = ((hash << 5) - hash) + s.charCodeAt(i);
hash |= 0;
}
return hash.toString(16);
};
const now = new Date().toISOString();
return {
projectId,
createdAt: now,
updatedAt: now,
designLockEnabled: true, // Default ON as per contract
designIntent: 'Modern dark-mode web app with Tailwind styling',
uiTokens: { colors, fonts, borderRadiusHints },
structureSummary,
goldenSnapshot: {
indexHtmlSha256: simpleHash(html),
styleCssSha256: null,
scriptJsSha256: null,
createdAt: now
},
fingerprints: { domSignature, cssSignature, layoutSignature },
modificationBudgetDefaults: {
maxHtmlLineEdits: 80,
maxCssLineEdits: 120,
maxJsLineEdits: 160
}
};
};
// =========================================
// mem0-STYLE PROJECT MEMORY SYSTEM
// =========================================
export interface MemoryRecord {
memoryId: string;
scope: 'project' | 'session' | 'global';
type: 'fact' | 'constraint' | 'decision' | 'preference' | 'glossary';
key: string;
value: string;
confidence: number; // 0..1
createdAt: string;
updatedAt: string;
source: string; // messageId or buildSessionId
supersedes?: string; // ID of memory this replaces
isActive: boolean;
}
// --- Memory Store CRUD ---
export const loadProjectMemories = async (projectId: string): Promise<MemoryRecord[]> => {
const electron = getElectron();
if (!electron?.fs) return [];
const userData = await getUserDataRoot();
if (!userData) return [];
try {
const raw = await electron.fs.read(`${userData}/projects/${projectId}/memory/memory.jsonl`);
const lines = raw.split('\n').filter(l => l.trim());
return lines.map(l => {
try { return JSON.parse(l) as MemoryRecord; }
catch { return null; }
}).filter(Boolean) as MemoryRecord[];
} catch {
return [];
}
};
export const saveProjectMemories = async (projectId: string, memories: MemoryRecord[]): Promise<void> => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
const jsonl = memories.map(m => JSON.stringify(m)).join('\n');
await electron.fs.write(`${userData}/projects/${projectId}/memory/memory.jsonl`, jsonl);
};
export const addMemory = async (projectId: string, memory: Omit<MemoryRecord, 'memoryId' | 'createdAt' | 'updatedAt' | 'isActive'>): Promise<MemoryRecord> => {
const existing = await loadProjectMemories(projectId);
const newMemory: MemoryRecord = {
...memory,
memoryId: `mem_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isActive: true
};
// M4: Dedup - check for existing similar memory
const duplicate = existing.find(m =>
m.isActive &&
m.key.toLowerCase() === newMemory.key.toLowerCase() &&
m.type === newMemory.type
);
if (duplicate) {
// Update existing instead of adding new
duplicate.value = newMemory.value;
duplicate.updatedAt = newMemory.updatedAt;
duplicate.confidence = Math.max(duplicate.confidence, newMemory.confidence);
await saveProjectMemories(projectId, existing);
return duplicate;
}
existing.push(newMemory);
await saveProjectMemories(projectId, existing);
return newMemory;
};
export const deleteMemory = async (projectId: string, memoryId: string): Promise<void> => {
const existing = await loadProjectMemories(projectId);
const memory = existing.find(m => m.memoryId === memoryId);
if (memory) {
memory.isActive = false;
memory.updatedAt = new Date().toISOString();
}
await saveProjectMemories(projectId, existing);
};
export const updateMemory = async (projectId: string, memoryId: string, updates: Partial<Pick<MemoryRecord, 'value' | 'confidence'>>): Promise<void> => {
const existing = await loadProjectMemories(projectId);
const memory = existing.find(m => m.memoryId === memoryId);
if (memory) {
if (updates.value !== undefined) memory.value = updates.value;
if (updates.confidence !== undefined) memory.confidence = updates.confidence;
memory.updatedAt = new Date().toISOString();
}
await saveProjectMemories(projectId, existing);
};
// --- M3: Top-K Retrieval with Keyword + Recency Scoring ---
export const retrieveRelevantMemories = (
memories: MemoryRecord[],
query: string,
topK: number = 5,
maxChars: number = 1500
): MemoryRecord[] => {
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
const now = Date.now();
// Active memories only
const active = memories.filter(m => m.isActive);
// Score each memory
const scored = active.map(m => {
let score = 0;
const keyLower = m.key.toLowerCase();
const valueLower = m.value.toLowerCase();
// Keyword matching
for (const word of queryWords) {
if (keyLower.includes(word)) score += 3;
if (valueLower.includes(word)) score += 1;
}
// Boost by type priority
const typePriority: Record<string, number> = {
constraint: 5,
decision: 4,
preference: 3,
fact: 2,
glossary: 1
};
score += typePriority[m.type] || 0;
// Recency boost (memories from last 24h get +2, last week +1)
const age = now - new Date(m.updatedAt).getTime();
if (age < 24 * 60 * 60 * 1000) score += 2;
else if (age < 7 * 24 * 60 * 60 * 1000) score += 1;
// Confidence boost
score += m.confidence;
return { memory: m, score };
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
// Take top-K within char budget
const result: MemoryRecord[] = [];
let totalChars = 0;
for (const { memory } of scored) {
const charCount = memory.key.length + memory.value.length + 20;
if (totalChars + charCount > maxChars) break;
result.push(memory);
totalChars += charCount;
if (result.length >= topK) break;
}
return result;
};
// --- M2: Memory Extraction from Chat/Builds ---
export const extractMemoriesFromText = async (
projectId: string,
text: string,
source: string
): Promise<MemoryRecord[]> => {
const electron = (window as any).electron;
if (!electron) return [];
const extractionPrompt = `You are a memory extraction agent. Extract durable facts, constraints, and decisions from the text.
Return ONLY valid JSON array (no markdown):
[
{"type": "constraint|decision|preference|fact|glossary", "key": "short_key", "value": "description", "confidence": 0.8}
]
Rules:
- Max 5 items
- Constraint = something that MUST be preserved (e.g., "dark mode", "use Tailwind")
- Decision = architectural choice (e.g., "use React 18", "localStorage for state")
- Preference = user style preference (e.g., "prefers dark glass UI")
- Fact = factual statement about the project
- Glossary = domain term definition
Only extract if clearly stated. Do not invent.`;
return new Promise((resolve) => {
let buffer = '';
electron.removeChatListeners();
electron.onChatChunk((c: string) => { buffer += c; });
electron.onChatComplete((response: string) => {
electron.removeChatListeners();
let json = (response || buffer).trim();
json = json.replace(/```json/gi, '').replace(/```/g, '').trim();
// Extract JSON array
const first = json.indexOf('[');
const last = json.lastIndexOf(']');
if (first !== -1 && last > first) {
json = json.substring(first, last + 1);
}
try {
const parsed = JSON.parse(json) as Array<{ type: string; key: string; value: string; confidence: number }>;
const newMemories: MemoryRecord[] = [];
for (const item of parsed.slice(0, 5)) {
if (item.key && item.value) {
const mem: MemoryRecord = {
memoryId: `mem_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
scope: 'project',
type: (item.type as MemoryRecord['type']) || 'fact',
key: item.key,
value: item.value,
confidence: item.confidence || 0.7,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
source,
isActive: true
};
newMemories.push(mem);
}
}
resolve(newMemories);
} catch {
console.warn('[MemoryExtract] Parse failed');
resolve([]);
}
});
electron.onChatError(() => {
electron.removeChatListeners();
resolve([]);
});
electron.startChat([
{ role: 'system', content: extractionPrompt },
{ role: 'user', content: `Extract memories from:\n${text.substring(0, 2000)}` }
], getActiveModel());
});
};
// --- Format memories for prompt injection ---
export const formatMemoriesForPrompt = (memories: MemoryRecord[]): string => {
if (memories.length === 0) return '';
const lines = memories.map(m => `• [${m.type.toUpperCase()}] ${m.key}: ${m.value}`);
return `\n[PROJECT MEMORY - MUST RESPECT]\n${lines.join('\n')}\n`;
};
export const listProjectsFromDisk = async (): Promise<Project[]> => {
const electron = getElectron();
if (!electron?.fs) return [];
const userData = await getUserDataRoot();
if (!userData) return [];
const root = `${userData}/projects`;
let entries: Array<{ name: string; isDirectory: boolean; path: string }> = [];
try {
entries = await electron.fs.list(root);
} catch {
return [];
}
const projects: Project[] = [];
for (const entry of entries) {
if (!entry.isDirectory) continue;
const id = entry.name;
try {
const raw = await electron.fs.read(`${root}/${id}/project.json`);
const parsed = safeJsonParse<Project>(raw, null as any);
if (parsed?.id) {
projects.push(parsed);
continue;
}
} catch {
// ignore
}
projects.push({ id, name: `Project ${id}`, slug: id, createdAt: 0 });
}
projects.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
return projects;
};
export const loadProjectFilesFromDisk = async (projectId: string): Promise<Record<string, string>> => {
const electron = getElectron();
if (!electron?.fs) return {};
const userData = await getUserDataRoot();
if (!userData) return {};
const base = `${userData}/projects/${projectId}`;
const result: Record<string, string> = {};
const tryRead = async (rel: string) => {
try {
return await electron.fs.read(`${base}/${rel}`);
} catch {
return null;
}
};
const html = await tryRead('index.html');
const css = await tryRead('style.css');
const js = await tryRead('script.js');
if (html != null) result['index.html'] = html;
if (css != null) result['style.css'] = css;
if (js != null) result['script.js'] = js;
return result;
};
export const deleteProjectFromDisk = async (projectId: string): Promise<void> => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
await electron.fs.delete(`${userData}/projects/${projectId}`);
try {
const raw = await electron.fs.read(`${userData}/projects/.lastActive.json`);
const parsed = safeJsonParse<{ projectId?: string }>(raw, {});
if (parsed.projectId === projectId) {
await electron.fs.write(`${userData}/projects/.lastActive.json`, JSON.stringify({ projectId: null }, null, 2));
}
} catch {
// ignore
}
};
// Helper to extract code blocks
const extractCode = (markdown: string, lang: string) => {
// Try precise language match
let regex = new RegExp(`\`\`\`${lang}([\\s\\S]*?)\`\`\``, 'i');
let match = markdown.match(regex);
if (match) return match[1].trim();
// Fallback: If looking for HTML, try to find generic blocks containing specific tags
if (lang === 'html') {
const genericRegex = /```([\s\S]*?)```/g;
let m;
while ((m = genericRegex.exec(markdown)) !== null) {
const content = m[1];
if (content.includes('<!DOCTYPE html>') || content.includes('<html')) {
return content.trim();
}
}
}
return null;
};
// =========================================
// UI QUALITY GATES SYSTEM
// =========================================
export interface QualityGateResult {
passed: boolean;
gate: string;
errors: string[];
warnings: string[];
}
export interface QualityReport {
overallPass: boolean;
artifactType: 'HTML_APP' | 'PLAN_TEXT' | 'UNKNOWN';
gates: QualityGateResult[];
repairHints: string[];
}
// Gate 1: Detect artifact type and block plan/prose
const gate1_artifactType = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const errors: string[] = [];
const warnings: string[] = [];
// Check for plan markers
// Check for plan markers (Aggressive Update)
if (/\[PLAN\]/i.test(content) ||
content.includes('<title>Implementation Plan</title>') ||
content.includes('<h1>Architecture Overview</h1>') ||
(content.includes('<h2>Core Features</h2>') && content.includes('<ul>')) ||
// New Specific Checks for the user's case
content.match(/<div class=["']phase["']>/i) ||
content.match(/<h3>Phase \d+:/i) ||
content.includes('Timeline Summary') ||
content.includes('Implementation Plan') // Catch-all for title variations
) {
errors.push('Output appears to be a formatted HTML Plan, not the App itself [PLAN]');
}
// Check for markdown headings without HTML
if (/^#{1,3}\s+/m.test(content) && !/<html/i.test(content)) {
errors.push('Contains markdown headings but no <html> tag');
}
// Check for bullet lists at start
if (/^[\s]*[-*]\s+/m.test(content.substring(0, 500)) && !/<html/i.test(content)) {
errors.push('Starts with bullet list (prose/plan) but no <html> tag');
}
// Must have HTML tags
if (!/<html/i.test(content) && !/<body/i.test(content) && !/<div/i.test(content)) {
errors.push('No HTML tags detected - appears to be plain text');
}
return {
passed: errors.length === 0,
gate: 'Gate 1: Artifact Type',
errors,
warnings
};
};
// Gate 2: HTML validity
const gate2_htmlValidity = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const errors: string[] = [];
const warnings: string[] = [];
// Must have doctype
if (!/<!DOCTYPE html>/i.test(content)) {
errors.push('Missing <!DOCTYPE html>');
}
// Must have <html> tag
if (!/<html/i.test(content)) {
errors.push('Missing <html> tag');
}
// Must have <head>
if (!/<head/i.test(content)) {
warnings.push('Missing <head> section');
}
// Must have <body>
if (!/<body/i.test(content)) {
errors.push('Missing <body> tag');
}
// Must not have escaped HTML entities as content (sign of encoding issue)
if (/&lt;html|&lt;body|&lt;div/i.test(content)) {
errors.push('Contains escaped HTML entities (&lt;) - encoding issue');
}
// Check for matching closing tags
if (/<html/i.test(content) && !/<\/html>/i.test(content)) {
errors.push('Missing closing </html> tag');
}
return {
passed: errors.length === 0,
gate: 'Gate 2: HTML Validity',
errors,
warnings
};
};
// Gate 3: Styling presence
const gate3_stylingPresence = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const cssContent = files['style.css'] || '';
const errors: string[] = [];
const warnings: string[] = [];
const hasInlineStyle = /<style[\s\S]*?>[\s\S]*?<\/style>/i.test(content);
const hasStyleAttribute = /style\s*=\s*["'][^"']+["']/i.test(content);
const hasTailwindCDN = /cdn\.tailwindcss\.com/i.test(content);
const hasExternalCSS = /<link[^>]+stylesheet/i.test(content);
const hasBootstrapCDN = /bootstrap/i.test(content);
const hasLocalCSS = cssContent.length > 50; // Arbitrary significant size
// Count CSS rules if inline style exists
let cssRuleCount = 0;
if (hasInlineStyle) {
const styleMatch = content.match(/<style[\s\S]*?>([\s\S]*?)<\/style>/i);
if (styleMatch) {
cssRuleCount = (styleMatch[1].match(/[{]/g) || []).length;
}
}
// Count local CSS rules
if (hasLocalCSS) {
cssRuleCount += (cssContent.match(/[{]/g) || []).length;
}
// Determine if styled
const isVanillaHighQuality = (hasInlineStyle || hasLocalCSS) && cssRuleCount >= 5;
const isTailwindUsed = hasTailwindCDN && (content.match(/class="[^"]*"/g) || []).length > 3;
const isBootstrapUsed = hasBootstrapCDN;
const isExternalUsed = hasExternalCSS;
const isInlineHeavy = hasStyleAttribute && (content.match(/style\s*=/gi) || []).length >= 5;
const isStyled = isVanillaHighQuality || isTailwindUsed || isBootstrapUsed || isExternalUsed || isInlineHeavy;
if (!isStyled) {
warnings.push('Low styling detected (Gate 3 Warning).');
warnings.push('Expected significant CSS, Tailwind, or inline styles.');
if (hasInlineStyle) warnings.push(`Found <style> with ${cssRuleCount} rules.`);
}
// Check for potential CDN issues
if (hasTailwindCDN && !hasInlineStyle && !hasStyleAttribute && !hasLocalCSS) {
warnings.push('Relies solely on Tailwind CDN - ensure network access');
}
return {
passed: errors.length === 0,
gate: 'Gate 3: Styling Presence',
errors,
warnings
};
};
// Gate 4: Runtime sanity (basic checks)
const gate4_runtimeSanity = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const jsContent = files['script.js'] || '';
const errors: string[] = [];
const warnings: string[] = [];
// Combine JS
let combinedJS = jsContent;
const scriptMatches = content.match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
for (const script of scriptMatches) {
combinedJS += '\n' + script.replace(/<\/?script[^>]*>/gi, '');
}
// Check for unclosed strings or brackets (very basic)
const openBraces = (combinedJS.match(/{/g) || []).length;
const closeBraces = (combinedJS.match(/}/g) || []).length;
if (Math.abs(openBraces - closeBraces) > 2) {
warnings.push('Possible JS syntax error: mismatched braces');
}
// Check body isn't just text dump
const bodyMatch = content.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) {
const bodyContent = bodyMatch[1].replace(/<[^>]+>/g, '').trim();
// If body is mostly text with very few tags, might be a dump
const tagCount = (bodyMatch[1].match(/<[a-z]/gi) || []).length;
if (bodyContent.length > 1000 && tagCount < 5) {
warnings.push('Body contains large text block with few HTML tags - possible prose dump');
}
}
// Check for undefined references (basic)
if (/undefined|null\s*\./.test(combinedJS)) {
warnings.push('Potential runtime error: undefined/null reference');
}
return {
passed: errors.length === 0,
gate: 'Gate 4: Runtime Sanity',
errors,
warnings
};
};
// Gate 5: Accessibility minimum
const gate5_accessibilityMinimum = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const errors: string[] = [];
const warnings: string[] = [];
// Check for viewport meta
if (!/<meta[^>]+viewport/i.test(content)) {
warnings.push('Missing viewport meta tag for responsive design');
}
// Check for at least one interactive element
const hasButton = /<button/i.test(content);
const hasLink = /<a[^>]+href/i.test(content);
const hasInput = /<input/i.test(content);
if (!hasButton && !hasLink && !hasInput) {
warnings.push('No interactive elements (button, link, input) detected');
}
// Check for lang attribute
if (!/<html[^>]+lang\s*=/i.test(content)) {
warnings.push('Missing lang attribute on <html>');
}
return {
passed: true, // Accessibility is warning-only, doesn't block
gate: 'Gate 5: Accessibility',
errors,
warnings
};
};
// =========================================
// LAYER 4: TASK MATCH GATE
// =========================================
// Detect when AI generates wrong type of app (e.g., asked for game, got dashboard)
interface TaskMatchContext {
originalPrompt: string;
projectType?: string;
}
// Store context for task matching (set before generation)
let taskMatchContext: TaskMatchContext | null = null;
export const setTaskMatchContext = (context: TaskMatchContext) => {
taskMatchContext = context;
};
export const clearTaskMatchContext = () => {
taskMatchContext = null;
};
// Extract key intent keywords from prompt
const extractIntentKeywords = (prompt: string): string[] => {
const keywords: string[] = [];
const lower = prompt.toLowerCase();
// App types
if (lower.includes('game') || lower.includes('play') || lower.includes('score')) keywords.push('game');
if (lower.includes('dashboard') || lower.includes('analytics') || lower.includes('chart')) keywords.push('dashboard');
if (lower.includes('form') || lower.includes('survey') || lower.includes('input')) keywords.push('form');
if (lower.includes('portfolio') || lower.includes('resume') || lower.includes('cv')) keywords.push('portfolio');
if (lower.includes('blog') || lower.includes('article') || lower.includes('post')) keywords.push('blog');
if (lower.includes('shop') || lower.includes('store') || lower.includes('cart') || lower.includes('product')) keywords.push('ecommerce');
if (lower.includes('cbt') || lower.includes('therapy') || lower.includes('mental') || lower.includes('stress')) keywords.push('wellness');
if (lower.includes('todo') || lower.includes('task') || lower.includes('checklist')) keywords.push('productivity');
if (lower.includes('chat') || lower.includes('message') || lower.includes('conversation')) keywords.push('messaging');
if (lower.includes('landing') || lower.includes('hero') || lower.includes('call to action')) keywords.push('landing');
return keywords;
};
// Check if output matches expected intent
const gate6_taskMatch = (files: Record<string, string>): QualityGateResult => {
const content = files['index.html'] || '';
const errors: string[] = [];
const warnings: string[] = [];
if (!taskMatchContext?.originalPrompt) {
// No context set, skip this gate
return { passed: true, gate: 'Gate 6: Task Match', errors, warnings };
}
const requestedKeywords = extractIntentKeywords(taskMatchContext.originalPrompt);
const outputKeywords = extractIntentKeywords(content);
// Also check the <title> and headings
const titleMatch = content.match(/<title>([^<]+)<\/title>/i);
const h1Match = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
const titleText = (titleMatch?.[1] || '') + ' ' + (h1Match?.[1] || '');
const titleKeywords = extractIntentKeywords(titleText);
// Merge detected keywords
const allOutputKeywords = [...new Set([...outputKeywords, ...titleKeywords])];
// Check for mismatch
if (requestedKeywords.length > 0) {
const hasMatch = requestedKeywords.some(k => allOutputKeywords.includes(k));
if (!hasMatch && allOutputKeywords.length > 0) {
// Definite mismatch
errors.push(`Task mismatch: Requested "${requestedKeywords.join(', ')}" but output appears to be "${allOutputKeywords.join(', ')}"`);
} else if (!hasMatch) {
// Can't determine output type, just warn
warnings.push(`Could not verify output matches requested "${requestedKeywords.join(', ')}"`);
}
}
return {
passed: errors.length === 0,
gate: 'Gate 6: Task Match',
errors,
warnings
};
};
// Run all quality gates
export const runQualityGates = (files: Record<string, string> | string): QualityReport => {
// Normalize input to files object to support legacy calls if any
let normalizedFiles: Record<string, string> = {};
if (typeof files === 'string') {
normalizedFiles = { 'index.html': files };
} else {
normalizedFiles = files;
}
const gates = [
gate1_artifactType(normalizedFiles),
gate2_htmlValidity(normalizedFiles),
gate3_stylingPresence(normalizedFiles),
gate4_runtimeSanity(normalizedFiles),
gate5_accessibilityMinimum(normalizedFiles),
gate6_taskMatch(normalizedFiles) // LAYER 4: Task Match Gate
];
// Gates 1-4 and 6 are blocking; Gate 5 (accessibility) is warning-only
const blockingGates = [gates[0], gates[1], gates[2], gates[3], gates[5]];
const overallPass = blockingGates.every(g => g.passed);
// Determine artifact type
let artifactType: 'HTML_APP' | 'PLAN_TEXT' | 'UNKNOWN' = 'UNKNOWN';
if (gates[0].passed && gates[1].passed) {
artifactType = 'HTML_APP';
} else if (gates[0].errors.some(e => e.includes('[PLAN]') || e.includes('markdown'))) {
artifactType = 'PLAN_TEXT';
}
// Generate repair hints
const repairHints: string[] = [];
for (const gate of gates) {
for (const error of gate.errors) {
if (error.includes('plan text') || error.includes('[PLAN]')) {
repairHints.push('Return a full valid HTML document, not plan text.');
}
if (error.includes('Missing <!DOCTYPE')) {
repairHints.push('Start with <!DOCTYPE html><html>...');
}
if (error.includes('No significant styling')) {
repairHints.push('Add a <style> block with CSS rules, or use inline styles.');
}
if (error.includes('escaped HTML entities')) {
repairHints.push('Output raw HTML, not escaped entities.');
}
}
}
return {
overallPass,
artifactType,
gates,
repairHints: [...new Set(repairHints)] // Dedupe
};
};
// Auto-repair prompt generator
export const generateRepairPrompt = (originalPrompt: string, qaReport: QualityReport, attemptNumber: number): string => {
const strictMode = attemptNumber >= 2;
let repairPrompt = `⚠️ QA GATE FAILURE - ATTEMPT ${attemptNumber}/2
Your previous output FAILED quality checks:
${qaReport.gates.filter(g => !g.passed).map(g => `${g.gate}: ${g.errors.join(', ')}`).join('\n')}
REPAIR INSTRUCTIONS:
${qaReport.repairHints.map(h => `${h}`).join('\n')}
${strictMode ? `
⚡ STRICT MODE (Final Attempt):
- Use ONLY embedded <style> block, NO external CDNs
- Keep JavaScript minimal
- Output a complete, self-contained HTML file
- DO NOT include any text explanations
` : ''}
ORIGINAL REQUEST:
${originalPrompt}
OUTPUT: Return ONLY the corrected HTML file. No markdown. No explanations.`;
return repairPrompt;
};
export const generateMockFiles = async (projectPath: string = 'projects/latest', planContent: string = '') => {
const electron = getElectron();
const buildId = Date.now().toString();
// =========================================
// SAFE-GEN PIPELINE INTEGRATION
// =========================================
let files: Record<string, string> = {};
// F5: XML Parsing Logic (v2 Protocol)
const regex = /<goose_file\s+path=["']([^"']+)["']\s*>([\s\S]*?)<\/goose_file>/g;
let hasXmlMatch = false;
let m;
while ((m = regex.exec(planContent)) !== null) {
if (m[1] && m[2]) {
files[m[1]] = m[2].trim();
hasXmlMatch = true;
}
}
if (hasXmlMatch) {
console.log('[MockFiles] Successfully parsed XML Artifact Bundle');
}
// Direct HTML assignment (primary path fallback)
if (Object.keys(files).length === 0) {
// Only accept if strictly HTML to avoid JSON-dump bug
const trimmed = planContent.trim();
if (trimmed.startsWith('<!DOCTYPE html') || trimmed.startsWith('<html')) {
files['index.html'] = trimmed;
} else {
// FALLBACK: Legacy regex extraction
let html = extractCode(planContent, 'html') || extractCode(planContent, 'xml') || '';
let css = extractCode(planContent, 'css');
let js = extractCode(planContent, 'javascript') || extractCode(planContent, 'js');
// Fallback: Check for raw HTML if extraction failed
if (!html && planContent.trim().includes('<!DOCTYPE html>')) {
const match = planContent.match(/<!DOCTYPE html>[\s\S]*<\/html>/i);
html = match ? match[0] : null;
}
// Last resort: Check for <html> tag without DOCTYPE
if (!html && planContent.includes('<html')) {
const match = planContent.match(/<html[\s\S]*<\/html>/i);
if (match) {
html = '<!DOCTYPE html>\n' + match[0];
}
}
if (html) {
files['index.html'] = html;
if (css) files['style.css'] = css;
if (js) files['script.js'] = js;
}
}
}
// If no files extracted at all, create simple stub so QA fails properly
if (!files['index.html']) {
files['index.html'] = `<!DOCTYPE html><html><body><h1>Code Generation Failed</h1></body></html>`;
}
// =========================================
// RUN QUALITY GATES (Non-Destructive)
// =========================================
const qaReport = runQualityGates(files);
console.log('[QA Gates] Report:', qaReport);
const home = electron && electron.fs ? await electron.getAppPath() : '';
const fullBasePath = `${home}/${projectPath}`;
const buildsPath = `${fullBasePath}/.builds/${buildId}`;
// STAGING: Always save raw artifacts to history (F1)
if (electron && electron.fs) {
try {
// Create directory if needed (assuming fs handles it or we rely on write to create)
await electron.fs.write(`${buildsPath}/raw/index.html`, files['index.html'] || '');
if (files['style.css']) await electron.fs.write(`${buildsPath}/raw/style.css`, files['style.css']);
if (files['script.js']) await electron.fs.write(`${buildsPath}/raw/script.js`, files['script.js']);
await electron.fs.write(`${buildsPath}/qa/report.json`, JSON.stringify(qaReport, null, 2));
} catch (e) {
console.error('[QA Gates] Failed to write history:', e);
}
}
if (!qaReport.overallPass) {
console.error('[QA Gates] FAILED! Errors:', qaReport.gates.filter(g => !g.passed).map(g => ({ gate: g.gate, errors: g.errors })));
// F1: NON-DESTRUCTIVE - Do NOT write to project root.
// Instead, return the failure overlay content for the UI/Preview to show, but DO NOT save it as index.html
const failureHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>QA Check Failed</title><style>
body { background: linear-gradient(135deg, #1a1a1a 0%, #2d1a1a 100%); color: #e8e8e8; padding: 40px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.container { max-width: 700px; text-align: center; }
h1 { color: #ff5555; font-size: 28px; margin-bottom: 10px; }
.subtitle { color: #888; margin-bottom: 30px; font-size: 14px; }
.error-box { background: rgba(255,85,85,0.1); border: 1px solid rgba(255,85,85,0.3); border-radius: 12px; padding: 20px; margin: 20px 0; text-align: left; }
.error-item { margin: 12px 0; font-size: 14px; color: #ffaaaa; }
.error-item strong { color: #ff8888; }
.rebuild-btn { display: inline-flex; align-items: center; gap: 8px; margin-top: 30px; padding: 14px 28px; background: linear-gradient(135deg, #34d399 0%, #10b981 100%); color: #000; font-weight: bold; font-size: 14px; border: none; border-radius: 12px; cursor: pointer; box-shadow: 0 0 30px rgba(52,211,153,0.3); transition: all 0.3s ease; }
.rebuild-btn:hover { transform: scale(1.05); box-shadow: 0 0 40px rgba(52,211,153,0.5); }
</style></head><body>
<div class="container">
<h1>⚠️ QA Check Failed</h1>
<p class="subtitle">The generated app did not meet quality standards. (Build ID: ${buildId})</p>
<div class="error-box">
<p style="margin: 0 0 12px 0; font-size: 12px; color: #ff6666; text-transform: uppercase;">Issues Detected:</p>
${qaReport.gates.filter(g => !g.passed).map(g => `
<div class="error-item"><strong>Gate ${g.gate.split(':')[0]}:</strong> ${g.errors.join(', ')}</div>
`).join('')}
</div>
<button class="rebuild-btn" onclick="window.parent.postMessage({type:'REBUILD_REQUEST'},'*')">
🔄 Retry / Repair
</button>
</div>
<script>
document.querySelector('.rebuild-btn').addEventListener('click', function() {
try { window.parent.dispatchEvent(new CustomEvent('goose-rebuild-request')); } catch(e) {}
});
</script>
</body></html>`;
// Return files object with _qaFailed flag but also the 'virtual' index.html for the iframe to render
return {
'index.html': failureHtml,
'style.css': '',
'script.js': '',
_qaFailed: true,
_qaReport: qaReport,
_buildId: buildId
};
}
// Log any warnings (non-blocking)
const warnings = qaReport.gates.flatMap(g => g.warnings);
if (warnings.length > 0) {
console.warn('[QA Gates] Warnings:', warnings);
}
// Write all extracted files to disk (ATOMIC SWAP: Success only)
if (electron && electron.fs) {
console.log(`[QA Gates] PASSED ✓ Writing ${Object.keys(files).length} files to ${fullBasePath}...`);
for (const [relPath, content] of Object.entries(files)) {
if (content) await electron.fs.write(`${fullBasePath}/${relPath}`, content);
}
return files;
} else {
console.warn("Electron FS not available. Returning mock data.");
return files;
}
};
// --- Skills Service ---
// --- Skills Service ---
// Superseded by src/services/skillsService.ts
export const savePersonasToDisk = async (personas: import('../types').Persona[]) => {
const electron = getElectron();
if (!electron?.fs) return;
const userData = await getUserDataRoot();
if (!userData) return;
const personasDir = `${userData}/personas`;
await electron.fs.write(`${personasDir}/index.json`, JSON.stringify(personas, null, 2));
};
export const loadPersonasFromDisk = async (): Promise<import('../types').Persona[]> => {
const electron = getElectron();
if (!electron?.fs) return [];
const userData = await getUserDataRoot();
if (!userData) return [];
try {
const personasDir = `${userData}/personas`;
const raw = await electron.fs.read(`${personasDir}/index.json`);
return JSON.parse(raw);
} catch {
return [];
}
};