diff --git a/page-scripts/page-v2.js b/page-scripts/page-v2.js index 6bbdf10..90944b8 100644 --- a/page-scripts/page-v2.js +++ b/page-scripts/page-v2.js @@ -103,6 +103,15 @@ var chatForm = document.getElementById('chatForm'); if (chatForm) { chatForm.addEventListener('submit', function() { + // Append any captured console errors to the outgoing message + var errors = window.__synthOSErrors; + if (errors && errors.length > 0) { + var ci = document.getElementById('chatInput'); + if (ci && ci.value.trim()) { + ci.value = ci.value + '\n\nCONSOLE_ERRORS:\n' + errors.join('\n---\n'); + window.__synthOSErrors = []; + } + } var overlay = document.getElementById('loadingOverlay'); if (overlay) overlay.style.display = 'flex'; chatForm.action = window.location.pathname; diff --git a/src/builders/anthropic.ts b/src/builders/anthropic.ts new file mode 100644 index 0000000..895143a --- /dev/null +++ b/src/builders/anthropic.ts @@ -0,0 +1,241 @@ +import { anthropic as createAnthropicModel, completePrompt } from '../models'; +import { parseChangeList, getTransformInstr } from '../service/transformPage'; +import { Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types'; + +// --------------------------------------------------------------------------- +// Builder options — passed from the route handler +// --------------------------------------------------------------------------- + +export interface AnthropicBuilderOptions { + apiKey?: string; + model?: string; + /** Optional wrapper applied to any internally-created model (e.g. for debug logging). */ + wrapModel?: (model: completePrompt) => completePrompt; +} + +// --------------------------------------------------------------------------- +// Request classification +// --------------------------------------------------------------------------- + +export type Classification = 'hard-change' | 'easy-change' | 'question'; + +export interface ClassifyResult { + classification: Classification; + /** When classification is "question", this contains the answer text. */ + answer?: string; +} + +const CLASSIFIER_SYSTEM_PROMPT = `You classify user messages for a web page builder. Default to a change request. Only classify as "question" when the user is purely asking for information with zero implication that anything should change. + + +Step 1 — Does the message describe a problem, bug, broken behavior, or something that should be different? + Yes → it is a change request (the user wants it fixed). Go to step 2. + No → go to step 3. + +Step 2 — How complex is the change? + Simple (text edits, color/style changes, adding/removing a single element, toggling visibility, minor CSS tweaks) → "easy-change" + Complex (new features, games, animations, restructuring components, significant JS logic, forms with validation, multi-step work) → "hard-change" + +Step 3 — Is the message a direct, explicit question asking for information only? Examples: "What color is the header?", "How many sections are there?", "What font is the title using?" + Yes, and there is absolutely no suggestion that anything should change → "question" + Otherwise → treat as a change request, go to step 2. + + +Return only JSON. No other text. +- Change: { "classification": "easy-change" } or { "classification": "hard-change" } +- Question: { "classification": "question", "answer": "" }`; + +export async function classifyRequest( + apiKey: string, + pageHtml: string, + userMessage: string +): Promise { + try { + const sonnet = createAnthropicModel({ apiKey, model: 'claude-sonnet-4-5' }); + const result = await sonnet({ + system: { role: 'system', content: CLASSIFIER_SYSTEM_PROMPT }, + prompt: { role: 'user', content: `\n${pageHtml}\n\n\n\n${userMessage}\n` }, + jsonMode: true, + }); + + if (!result.completed || !result.value) { + return { classification: 'hard-change' }; + } + + const parsed = JSON.parse(result.value); + const c = parsed.classification; + if (c === 'question') { + return { classification: 'question', answer: typeof parsed.answer === 'string' ? parsed.answer : '' }; + } + if (c === 'easy-change' || c === 'hard-change') { + return { classification: c }; + } + return { classification: 'hard-change' }; + } catch { + return { classification: 'hard-change' }; + } +} + +// --------------------------------------------------------------------------- +// Anthropic builder factory +// --------------------------------------------------------------------------- + +/** + * Create an Anthropic-tuned builder. + * + * @param complete The completePrompt function (already configured with API key / model). + * @param userInstructions Optional user-level instructions from settings. + * @param productName Product name for branding in prompts (defaults to 'SynthOS'). + * @param options Optional API key and model name for classifier routing. + */ +export function createAnthropicBuilder( + complete: completePrompt, + userInstructions?: string, + productName?: string, + options?: AnthropicBuilderOptions +): Builder { + const name = productName ?? 'SynthOS'; + + return { + async run(currentPage, additionalSections, userMessage, newBuild): Promise { + try { + const isOpus = options?.model?.startsWith('claude-opus-'); + + // Non-Opus models or missing apiKey: existing behavior + if (!isOpus || !options?.apiKey) { + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + } + + // Console errors bypass classification — always route to Opus + if (userMessage.includes('CONSOLE_ERRORS:')) { + console.log('classifyRequest: console errors detected → routing to ' + options.model!); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + } + + // Classify the request using Sonnet + const classifyResult = await classifyRequest(options.apiKey, currentPage.content, userMessage); + console.log(`classifyRequest: "${classifyResult.classification}" → routing to ${routeLabel(classifyResult.classification, newBuild, options.model!)}`); + + // Questions — answer was already provided by the classifier + if (classifyResult.classification === 'question') { + return { kind: 'reply', text: classifyResult.answer ?? '' }; + } + + // New builds always use Opus (the configured model) + if (newBuild) { + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + } + + // Easy changes use Sonnet + if (classifyResult.classification === 'easy-change') { + let sonnet: completePrompt = createAnthropicModel({ apiKey: options.apiKey, model: 'claude-sonnet-4-5' }); + if (options.wrapModel) sonnet = options.wrapModel(sonnet); + return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name); + } + + // Hard changes use Opus + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + } catch (err: unknown) { + return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; + } + } + }; +} + +// --------------------------------------------------------------------------- +// Build flow — shared prompt construction + model call + parsing +// --------------------------------------------------------------------------- + +export async function buildWithModel( + model: completePrompt, + currentPage: ContextSection, + additionalSections: ContextSection[], + userMessage: string, + userInstructions: string | undefined, + productName: string +): Promise { + // -- System message: all static content (cacheable) -- + const systemParts: string[] = []; + for (const section of additionalSections) { + if (section.content) { + systemParts.push(`${section.title}\n${section.content}`); + } + } + + const instructionParts: string[] = []; + if (userInstructions?.trim()) { + instructionParts.push(userInstructions); + } + for (const section of additionalSections) { + if (section.instructions?.trim()) { + instructionParts.push(section.instructions); + } + } + instructionParts.push(getTransformInstr(productName)); + const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); + systemParts.push(`\n${instructions}`); + + const systemContent = systemParts.join('\n\n'); + + // -- User message: dynamic content only (current page + user message) -- + const promptContent = `${currentPage.title}\n${currentPage.content}\n\n\n${userMessage}`; + + // -- Call model -- + const result = await model({ + system: { role: 'system', content: systemContent }, + prompt: { role: 'user', content: promptContent }, + cacheSystem: true, + outputSchema: CHANGE_OPS_SCHEMA, + }); + + if (!result.completed) { + return { kind: 'error', error: result.error ?? new Error('Model call failed') }; + } + + // -- Parse response -- + return parseBuilderResponse(result.value!); +} + +// --------------------------------------------------------------------------- +// Route label for console logging +// --------------------------------------------------------------------------- + +function routeLabel(classification: Classification, newBuild: boolean, configuredModel: string): string { + if (classification === 'question') return 'classifier (answered inline)'; + if (newBuild) return configuredModel; + if (classification === 'easy-change') return 'claude-sonnet-4-5'; + return configuredModel; +} + +// --------------------------------------------------------------------------- +// Response parsing — shared across builders +// --------------------------------------------------------------------------- + +export function parseBuilderResponse(raw: string): BuilderResult { + // Try parsing as a JSON object with a kind discriminator + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (parsed.kind === 'transforms' && Array.isArray(parsed.changes)) { + return { kind: 'transforms', changes: parsed.changes }; + } + if (parsed.kind === 'reply' && typeof parsed.text === 'string') { + return { kind: 'reply', text: parsed.text }; + } + } + // Bare array — backward compat + if (Array.isArray(parsed)) { + return { kind: 'transforms', changes: parsed }; + } + } catch { + // fall through to parseChangeList extraction + } + + // Fall back to extracting a JSON array from the response text + try { + const changes = parseChangeList(raw); + return { kind: 'transforms', changes }; + } catch { + return { kind: 'error', error: new Error('Failed to parse model response as JSON') }; + } +} diff --git a/src/builders/fireworksai.ts b/src/builders/fireworksai.ts new file mode 100644 index 0000000..c88f9f4 --- /dev/null +++ b/src/builders/fireworksai.ts @@ -0,0 +1,59 @@ +import { completePrompt } from '../models'; +import { getTransformInstr } from '../service/transformPage'; +import { parseBuilderResponse } from './anthropic'; +import { Builder, BuilderResult, CHANGE_OPS_FORMAT_INSTRUCTION } from './types'; + +/** + * Create a FireworksAI-tuned builder. + * Currently identical to the Anthropic builder — separate file enables + * future per-provider tuning. + */ +export function createFireworksAIBuilder(complete: completePrompt, userInstructions?: string, productName?: string): Builder { + const name = productName ?? 'SynthOS'; + + return { + async run(currentPage, additionalSections, userMessage, newBuild): Promise { + try { + // -- System message -- + const systemParts: string[] = [ + `${currentPage.title}\n${currentPage.content}`, + ]; + for (const section of additionalSections) { + if (section.content) { + systemParts.push(`${section.title}\n${section.content}`); + } + } + const systemContent = systemParts.join('\n\n'); + + // -- User message -- + const instructionParts: string[] = []; + if (userInstructions?.trim()) { + instructionParts.push(userInstructions); + } + for (const section of additionalSections) { + if (section.instructions?.trim()) { + instructionParts.push(section.instructions); + } + } + instructionParts.push(getTransformInstr(name)); + instructionParts.push(CHANGE_OPS_FORMAT_INSTRUCTION); + + const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); + const promptContent = `\n${userMessage}\n\n\n${instructions}`; + + const result = await complete({ + system: { role: 'system', content: systemContent }, + prompt: { role: 'user', content: promptContent }, + }); + + if (!result.completed) { + return { kind: 'error', error: result.error ?? new Error('Model call failed') }; + } + + return parseBuilderResponse(result.value!); + } catch (err: unknown) { + return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; + } + } + }; +} diff --git a/src/builders/index.ts b/src/builders/index.ts new file mode 100644 index 0000000..d498955 --- /dev/null +++ b/src/builders/index.ts @@ -0,0 +1,33 @@ +import { ProviderName, completePrompt } from '../models'; +import { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic'; +import { createOpenAIBuilder } from './openai'; +import { createFireworksAIBuilder } from './fireworksai'; +import { Builder } from './types'; + +export { ContextSection, BuilderResult, Builder } from './types'; +export { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic'; +export { createOpenAIBuilder } from './openai'; +export { createFireworksAIBuilder } from './fireworksai'; +export { parseBuilderResponse } from './anthropic'; + +/** + * Factory that creates a provider-specific builder. + */ +export function createBuilder( + provider: ProviderName, + complete: completePrompt, + userInstructions?: string, + productName?: string, + options?: AnthropicBuilderOptions +): Builder { + switch (provider) { + case 'Anthropic': + return createAnthropicBuilder(complete, userInstructions, productName, options); + case 'OpenAI': + return createOpenAIBuilder(complete, userInstructions, productName); + case 'FireworksAI': + return createFireworksAIBuilder(complete, userInstructions, productName); + default: + throw new Error(`Unknown provider: ${provider}`); + } +} diff --git a/src/builders/openai.ts b/src/builders/openai.ts new file mode 100644 index 0000000..5a832b2 --- /dev/null +++ b/src/builders/openai.ts @@ -0,0 +1,76 @@ +import { completePrompt } from '../models'; +import { getTransformInstr } from '../service/transformPage'; +import { parseBuilderResponse } from './anthropic'; +import { Builder, BuilderResult, OPENAI_CHANGE_OPS_SCHEMA } from './types'; + +/** + * Create an OpenAI-tuned builder. + * Uses OpenAI structured outputs (json_schema) for reliable JSON responses. + */ +export function createOpenAIBuilder(complete: completePrompt, userInstructions?: string, productName?: string): Builder { + const name = productName ?? 'SynthOS'; + + return { + async run(currentPage, additionalSections, userMessage, newBuild): Promise { + try { + // -- System message -- + const systemParts: string[] = [ + `${currentPage.title}\n${currentPage.content}`, + ]; + for (const section of additionalSections) { + if (section.content) { + systemParts.push(`${section.title}\n${section.content}`); + } + } + const systemContent = systemParts.join('\n\n'); + + // -- User message -- + const instructionParts: string[] = []; + if (userInstructions?.trim()) { + instructionParts.push(userInstructions); + } + for (const section of additionalSections) { + if (section.instructions?.trim()) { + instructionParts.push(section.instructions); + } + } + instructionParts.push(getTransformInstr(name)); + + const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); + const promptContent = `\n${userMessage}\n\n\n${instructions}`; + + const result = await complete({ + system: { role: 'system', content: systemContent }, + prompt: { role: 'user', content: promptContent }, + jsonSchema: OPENAI_CHANGE_OPS_SCHEMA, + }); + + if (!result.completed) { + return { kind: 'error', error: result.error ?? new Error('Model call failed') }; + } + + // Unwrap the { changes: [...] } envelope from structured output + return parseOpenAIResponse(result.value!); + } catch (err: unknown) { + return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; + } + } + }; +} + +/** + * Parse the OpenAI structured output response. + * The schema wraps the array in { changes: [...] }, so unwrap before + * delegating to the shared parser. + */ +function parseOpenAIResponse(raw: string): BuilderResult { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.changes)) { + return { kind: 'transforms', changes: parsed.changes }; + } + } catch { + // fall through to shared parser + } + return parseBuilderResponse(raw); +} diff --git a/src/builders/types.ts b/src/builders/types.ts new file mode 100644 index 0000000..68b0686 --- /dev/null +++ b/src/builders/types.ts @@ -0,0 +1,196 @@ +import { ChangeList } from '../service/transformPage'; + +// --------------------------------------------------------------------------- +// Change operations output format — text instruction for non-structured builders +// --------------------------------------------------------------------------- + +/** + * Text instruction that tells the model to return a JSON array of change operations. + * Append this to for builders that don't support structured outputs. + */ +export const CHANGE_OPS_FORMAT_INSTRUCTION = `Return a JSON array of change operations to apply to the page. Do NOT return the full HTML page. + +Each operation must be one of: +{ "op": "update", "nodeId": "", "html": "" } + — replaces the innerHTML of the target element + +{ "op": "replace", "nodeId": "", "html": "" } + — replaces the entire element (outerHTML) with new markup + +{ "op": "delete", "nodeId": "" } + — removes the element from the page + +{ "op": "insert", "parentId": "", "position": "prepend"|"append"|"before"|"after", "html": "" } + — inserts new HTML relative to the parent element + +{ "op": "style-element", "nodeId": "", "style": "" } + — sets the style attribute of the target element (must be unlocked) + +{ "op": "update-lines", "nodeId": "", "startLine": , "endLine": , "content": "" } + — replaces lines startLine..endLine (inclusive, 1-based) in a script/style block + +{ "op": "delete-lines", "nodeId": "", "startLine": , "endLine": } + — removes lines startLine..endLine (inclusive, 1-based) from a script/style block + +{ "op": "insert-lines", "nodeId": "", "afterLine": , "content": "" } + — inserts lines after line n (1-based; 0 = before first line) in a script/style block + +Script and style blocks have line numbers prefixed (e.g. "01: let x = 1;"). Use these for +line-range ops. Do not include line number prefixes in your content. For small edits to large +scripts/styles, prefer update-lines/delete-lines/insert-lines over update to reduce output. +When using multiple line-range ops on the same block, apply from bottom to top (highest line +numbers first) to avoid line drift. + +Return ONLY the JSON array. Example: +[ + { "op": "update", "nodeId": "5", "html": "

Hello world

" }, + { "op": "insert", "parentId": "3", "position": "append", "html": "
New message
" } +]`; + +// --------------------------------------------------------------------------- +// Change operations JSON schema — for structured output (constrained decoding) +// --------------------------------------------------------------------------- + +/** + * JSON schema matching the ChangeOp union type for Anthropic structured outputs. + * The top-level schema is an array of change operations. + */ +export const CHANGE_OPS_SCHEMA: Record = { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + op: { type: 'string', const: 'update' }, + nodeId: { type: 'string' }, + html: { type: 'string' }, + }, + required: ['op', 'nodeId', 'html'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'replace' }, + nodeId: { type: 'string' }, + html: { type: 'string' }, + }, + required: ['op', 'nodeId', 'html'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'delete' }, + nodeId: { type: 'string' }, + }, + required: ['op', 'nodeId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'insert' }, + parentId: { type: 'string' }, + position: { type: 'string', enum: ['prepend', 'append', 'before', 'after'] }, + html: { type: 'string' }, + }, + required: ['op', 'parentId', 'position', 'html'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'style-element' }, + nodeId: { type: 'string' }, + style: { type: 'string' }, + }, + required: ['op', 'nodeId', 'style'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'update-lines' }, + nodeId: { type: 'string' }, + startLine: { type: 'integer' }, + endLine: { type: 'integer' }, + content: { type: 'string' }, + }, + required: ['op', 'nodeId', 'startLine', 'endLine', 'content'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'delete-lines' }, + nodeId: { type: 'string' }, + startLine: { type: 'integer' }, + endLine: { type: 'integer' }, + }, + required: ['op', 'nodeId', 'startLine', 'endLine'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + op: { type: 'string', const: 'insert-lines' }, + nodeId: { type: 'string' }, + afterLine: { type: 'integer' }, + content: { type: 'string' }, + }, + required: ['op', 'nodeId', 'afterLine', 'content'], + additionalProperties: false, + }, + ], + }, +}; + +/** + * OpenAI structured outputs require a root-level object. + * This wraps CHANGE_OPS_SCHEMA in { changes: [...] }. + */ +export const OPENAI_CHANGE_OPS_SCHEMA: Record = { + type: 'object', + properties: { + changes: CHANGE_OPS_SCHEMA, + }, + required: ['changes'], + additionalProperties: false, +}; + +// --------------------------------------------------------------------------- +// Context sections — structured blocks passed to the builder +// --------------------------------------------------------------------------- + +export interface ContextSection { + /** Section title, e.g. "", "" */ + title: string; + /** The text body of this section */ + content: string; + /** How the model should work with this section (appended to instructions) */ + instructions: string; +} + +// --------------------------------------------------------------------------- +// Builder result — discriminated union returned by Builder.run() +// --------------------------------------------------------------------------- + +export type BuilderResult = + | { kind: 'transforms'; changes: ChangeList } + | { kind: 'reply'; text: string } + | { kind: 'error'; error: Error }; + +// --------------------------------------------------------------------------- +// Builder interface +// --------------------------------------------------------------------------- + +export interface Builder { + run( + currentPage: ContextSection, + additionalSections: ContextSection[], + userMessage: string, + newBuild: boolean + ): Promise; +} diff --git a/src/customizer/Customizer.ts b/src/customizer/Customizer.ts index 32da5d6..c4e42fe 100644 --- a/src/customizer/Customizer.ts +++ b/src/customizer/Customizer.ts @@ -1,5 +1,6 @@ import { Application } from 'express'; import { SynthOSConfig } from '../init'; +import { ContextSection } from '../builders/types'; import path from 'path'; export type RouteInstaller = (config: SynthOSConfig, app: Application) => void; @@ -14,6 +15,9 @@ export class Customizer { /** Custom instructions appended to transformPage's instruction block. */ protected customTransformInstructions: string[] = []; + /** Custom context sections appended to the builder's additional sections. */ + protected customContextSections: ContextSection[] = []; + // --- Local data folder --- // Override in a derived class to change the local project folder name. @@ -128,4 +132,17 @@ export class Customizer { getTransformInstructions(): string[] { return this.customTransformInstructions; } + + // --- Custom context sections --- + + /** Add custom context sections for the builder. */ + addContextSections(...sections: ContextSection[]): this { + this.customContextSections.push(...sections); + return this; + } + + /** Get custom context sections. */ + getContextSections(): ContextSection[] { + return this.customContextSections; + } } diff --git a/src/models/anthropic.ts b/src/models/anthropic.ts index 3d29bef..91264b5 100644 --- a/src/models/anthropic.ts +++ b/src/models/anthropic.ts @@ -15,8 +15,9 @@ export interface AnthropicArgs { */ export function buildAnthropicRequest(args: PromptCompletionArgs, defaultTemp: number): { messages: { role: string; content: string }[]; - system: string | undefined; + system: string | Anthropic.TextBlockParam[] | undefined; temperature: number; + outputConfig?: Anthropic.OutputConfig; } { const reqTemp = args.temperature ?? defaultTemp; @@ -27,7 +28,8 @@ export function buildAnthropicRequest(args: PromptCompletionArgs, defaultTemp: n } } - const useJsonPrefill = args.jsonMode || args.jsonSchema; + // Structured output via output_config is incompatible with prefilling + const useJsonPrefill = !args.outputSchema && (args.jsonMode || args.jsonSchema); if (useJsonPrefill) { messages.push({ role: 'user', content: args.prompt.content }); messages.push({ role: 'assistant', content: '{' }); @@ -41,7 +43,17 @@ export function buildAnthropicRequest(args: PromptCompletionArgs, defaultTemp: n system = system ? system + schemaInstruction : schemaInstruction; } - return { messages, system, temperature: reqTemp }; + // Wrap system content with cache_control for prompt caching + const finalSystem: string | Anthropic.TextBlockParam[] | undefined = (system && args.cacheSystem) + ? [{ type: 'text' as const, text: system, cache_control: { type: 'ephemeral' as const } }] + : system; + + // Structured output config for constrained decoding + const outputConfig: Anthropic.OutputConfig | undefined = args.outputSchema + ? { format: { type: 'json_schema', schema: args.outputSchema } } + : undefined; + + return { messages, system: finalSystem, temperature: reqTemp, outputConfig }; } export function anthropic(args: AnthropicArgs): completePrompt { @@ -50,9 +62,9 @@ export function anthropic(args: AnthropicArgs): completePrompt { const client = new Anthropic({ apiKey, baseURL, maxRetries }); return async (completionArgs: PromptCompletionArgs): Promise> => { - const { messages, system: systemContent, temperature: reqTemp } = buildAnthropicRequest(completionArgs, temperature); + const { messages, system: systemContent, temperature: reqTemp, outputConfig } = buildAnthropicRequest(completionArgs, temperature); - const useJsonPrefill = completionArgs.jsonMode || completionArgs.jsonSchema; + const useJsonPrefill = !completionArgs.outputSchema && (completionArgs.jsonMode || completionArgs.jsonSchema); try { const stream = await client.messages.create({ @@ -62,6 +74,7 @@ export function anthropic(args: AnthropicArgs): completePrompt { system: systemContent, messages: messages as Anthropic.MessageParam[], stream: true, + ...(outputConfig && { output_config: outputConfig }), }); let text = ''; diff --git a/src/models/types.ts b/src/models/types.ts index 1bb834e..1dcc1be 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -61,6 +61,10 @@ export interface PromptCompletionArgs { jsonMode?: boolean; /** JSON schema for structured output. When provided, the model is asked to return JSON conforming to this schema. */ jsonSchema?: Record; + /** When true, system content is wrapped with cache_control for Anthropic prompt caching. */ + cacheSystem?: boolean; + /** JSON schema for structured output via constrained decoding (Anthropic output_config). */ + outputSchema?: Record; } export type completePrompt = (args: PromptCompletionArgs) => Promise>; diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index b8d4cc2..72745cc 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -1,34 +1,20 @@ -import { AgentArgs, AgentCompletion, SystemMessage, UserMessage } from "../models"; -import { listScripts } from "../scripts"; +import { AgentCompletion } from "../models"; import * as cheerio from "cheerio"; -import { ThemeInfo } from "../themes"; -import { getConnectorRegistry, ConnectorsConfig, ConnectorOAuthConfig } from "../connectors"; -import { AgentConfig } from "../agents"; import { Customizer } from "../customizer"; +import { Builder, ContextSection } from "../builders/types"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export interface TransformPageArgs extends AgentArgs { - pagesFolder: string; +export interface TransformPageArgs { pageState: string; message: string; instructions?: string; - /** Provider-specific formatting instructions injected into the prompt. */ - modelInstructions?: string; - /** Active theme metadata for theme-aware page generation. */ - themeInfo?: ThemeInfo; - /** Page mode. */ - mode?: 'unlocked' | 'locked'; - /** User's configured connectors (from settings). */ - configuredConnectors?: ConnectorsConfig; - /** User's configured A2A agents (from settings). */ - configuredAgents?: AgentConfig[]; - /** Pre-built route hints string (from buildRouteHints). Falls back to full serverAPIs. */ - routeHints?: string; - /** Custom transform instructions from Customizer. */ - customTransformInstructions?: string[]; + builder: Builder; + additionalSections: ContextSection[]; + /** True when this is the builder page (has chat panel). */ + isBuilder?: boolean; /** Product name for branding in prompts (defaults to 'SynthOS'). */ productName?: string; } @@ -38,20 +24,13 @@ export type ChangeOp = | { op: "replace"; nodeId: string; html: string } | { op: "delete"; nodeId: string } | { op: "insert"; parentId: string; position: "prepend" | "append" | "before" | "after"; html: string } - | { op: "style-element"; nodeId: string; style: string }; + | { op: "style-element"; nodeId: string; style: string } + | { op: "update-lines"; nodeId: string; startLine: number; endLine: number; content: string } + | { op: "delete-lines"; nodeId: string; startLine: number; endLine: number } + | { op: "insert-lines"; nodeId: string; afterLine: number; content: string }; export type ChangeList = ChangeOp[]; -interface FailedOp { - op: ChangeOp; - reason: string; -} - -interface ApplyResult { - html: string; - failedOps: FailedOp[]; -} - // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- @@ -62,170 +41,185 @@ export interface TransformPageResult { } export async function transformPage(args: TransformPageArgs): Promise> { - const { pagesFolder, pageState, message, completePrompt } = args; + const { message, builder, additionalSections } = args; + + // 0. Strip the early error-capture script so the LLM never sees it + const pageState = stripErrorCapture(args.pageState); // 1. Assign data-node-id to every element const { html: annotatedHtml } = assignNodeIds(pageState); + // 2. Add line numbers to script/style blocks + const numberedHtml = addLineNumbers(annotatedHtml); + try { - // 2. Build prompt - const scripts = await listScripts(pagesFolder); - const serverScripts = `\n${scripts || ''}`; - const currentPage = `\n${annotatedHtml}`; - - // Build theme context block - let themeBlock = '\n'; - if (args.themeInfo) { - const { mode, colors } = args.themeInfo; - const colorList = Object.entries(colors) - .map(([name, value]) => ` --${name}: ${value}`) - .join('\n'); - themeBlock += `Mode: ${mode}\nCSS custom properties (use instead of hardcoded values):\n${colorList}\n\nShared shell classes (pre-styled by theme, do not redefine):\n .chat-panel — Left sidebar container (30% width)\n .chat-header — Chat panel title bar\n .chat-messages — Scrollable message container\n .chat-message — Individual message wrapper\n .link-group — Navigation links row (Save, Pages, Reset)\n .chat-input — Message text input\n .chat-submit — Send button\n .viewer-panel — Right content area (70% width)\n .loading-overlay — Full-screen loading overlay\n .spinner — Animated loading spinner\n .modal-overlay — Full-screen modal backdrop (position:fixed, z-index:2000, backdrop-filter:blur). Add class "show" to display.\n .modal-content — Centered modal container\n .modal-header — Gradient header bar\n .modal-body — Modal content area\n .modal-footer — Bottom action bar (flex, space-between)\n .modal-footer-right — Right-aligned button group\n\nModals and popups: ALWAYS use the theme\'s .modal-overlay class for any modal or popup overlay. Do NOT create custom overlay classes with position:fixed and z-index. Structure:\n \nShow/hide by toggling the "show" class: el.classList.add(\'show\') / el.classList.remove(\'show\'). This ensures correct z-index layering above the chat toggle and other UI elements.\n\nPage title bars: To align with the chat header, apply these styles:\n min-height: var(--header-min-height);\n padding: var(--header-padding-vertical) var(--header-padding-horizontal);\n line-height: var(--header-line-height);\n display: flex; align-items: center; justify-content: center; box-sizing: border-box;\n\nFull-viewer mode: For games, animations, or full-screen content, add class "full-viewer" to the viewer-panel element to remove its padding.\n\nChat panel behaviours (auto-injected via page script — do NOT recreate in page code):\n The server injects page-v2.js after transformation. It provides:\n - Form submit handler: sets action to window.location.pathname, shows #loadingOverlay, disables inputs\n - Save/Reset link handlers (#saveLink, #resetLink)\n - Chat scroll to bottom (#chatMessages)\n - Chat toggle button (.chat-toggle) — created dynamically if not in markup\n - .chat-input-wrapper — wraps #chatInput with a brainstorm icon button\n - Brainstorm modal (#brainstormModal) — LLM-powered brainstorm UI, created dynamically\n - Focus management — keeps keyboard input directed to #chatInput\n\n Do NOT:\n - Create your own form submit handler, toggle button, or input wrapper\n - Modify or replace .chat-panel, .chat-header, .link-group, #chatForm, or .chat-toggle\n - INSERT new `; + +function injectErrorCapture(html: string, pageVersion: number): string { + if (pageVersion < 2) return html; + if (html.includes(`id="${ERROR_CAPTURE_ID}"`)) return html; + const $ = cheerio.load(html, { decodeEntities: false }); + $('head').prepend(ERROR_CAPTURE_SCRIPT + '\n'); + return $.html(); +} + + +// --------------------------------------------------------------------------- +// Context section builders — assemble ContextSections from enabled features +// --------------------------------------------------------------------------- + +function buildServerApisSection(customizer?: Customizer): ContextSection { + const content = customizer ? buildRouteHints(customizer) : serverAPIs; + return { + title: '', + content: content.replace(/^\n?/, ''), + instructions: 'provides a list of available server APIs and helper functions you can call from injected scripts. Use synthos.* helpers instead of raw fetch().', + }; +} + +async function buildServerScriptsSection(pagesFolder: string): Promise { + const scripts = await listScripts(pagesFolder); + return { + title: '', + content: scripts || '', + instructions: 'provides a list of available scripts callable via synthos.scripts.run(id, variables).', + }; +} + +function buildConnectorsSection(configuredConnectors?: ConnectorsConfig): ContextSection | undefined { + if (!configuredConnectors) return undefined; + + const entries = Object.entries(configuredConnectors) + .filter(([, cfg]) => cfg.enabled && cfg.apiKey); + if (entries.length === 0) return undefined; + + const blocks = entries.map(([id, cfg]) => { + const def = getConnectorRegistry().find(d => d.id === id); + if (!def) return `- ${id}`; + let block = `- ${def.name} (id: "${id}", category: ${def.category})\n Base URL: ${def.baseUrl}`; + if (def.hints) { + block += `\n Usage:\n${def.hints.split('\n').map(l => ' ' + l).join('\n')}`; + } + // Append dynamic OAuth context + if (def.authStrategy === 'oauth2') { + const oauthCfg = cfg as ConnectorOAuthConfig; + block += '\n Auth: The proxy attaches the access token automatically. Do NOT pass access_token in body or query params.'; + if (oauthCfg.userId) { + block += `\n User ID: ${oauthCfg.userId} — use this directly in API paths (e.g. /${oauthCfg.userId}/media).`; + } else { + block += '\n User ID: Not yet resolved. Call GET /me/accounts to discover it, then GET /{page-id}?fields=instagram_business_account to get the IG user ID.'; + } + } + return block; + }); + + const content = `The user has configured and enabled these connectors:\n${blocks.join('\n\n')}\n\nYou may use synthos.connectors.call(connector, method, path, opts) to call them.\nIMPORTANT: Before making any connector call, ALWAYS check that the connector is configured first using synthos.connectors.list(). If the connector is not configured, show the user a friendly message with a link to the Settings > Connectors page (/settings?tab=connectors) so they can set it up.\nDo NOT hardcode API keys. The connector proxy attaches authentication automatically.`; + + return { + title: '', + content, + instructions: '', + }; +} + +function buildAgentsSection(configuredAgents?: AgentConfig[]): ContextSection | undefined { + const enabledAgents = (configuredAgents ?? []).filter(a => a.enabled); + if (enabledAgents.length === 0) return undefined; + + const agentBlocks = enabledAgents.map(a => { + let block = `- ${a.name} (id: "${a.id}", provider: ${a.provider})`; + block += `\n Description: ${a.description}`; + if (a.capabilities?.streaming) { + block += `\n Supports streaming: yes`; + } + if (a.skills && a.skills.length > 0) { + const skillList = a.skills.map(s => ` - ${s.name}: ${s.description}`).join('\n'); + block += `\n Skills:\n${skillList}`; + } + return block; + }); + + return { + title: '', + content: `The user has configured these agents:\n\n${agentBlocks.join('\n\n')}\n\n${AGENT_API_REFERENCE}`, + instructions: '', + }; +} + +function buildThemeSection(themeInfo?: ThemeInfo): ContextSection { + let content = ''; + if (themeInfo) { + const { mode, colors } = themeInfo; + const colorList = Object.entries(colors) + .map(([name, value]) => ` --${name}: ${value}`) + .join('\n'); + content = `Mode: ${mode}\nCSS custom properties (use instead of hardcoded values):\n${colorList}\n\nShared shell classes (pre-styled by theme, do not redefine):\n .chat-panel — Left sidebar container (30% width)\n .chat-header — Chat panel title bar\n .chat-messages — Scrollable message container\n .chat-message — Individual message wrapper\n .link-group — Navigation links row (Save, Pages, Reset)\n .chat-input — Message text input\n .chat-submit — Send button\n .viewer-panel — Right content area (70% width)\n .loading-overlay — Full-screen loading overlay\n .spinner — Animated loading spinner\n .modal-overlay — Full-screen modal backdrop (position:fixed, z-index:2000, backdrop-filter:blur). Add class "show" to display.\n .modal-content — Centered modal container\n .modal-header — Gradient header bar\n .modal-body — Modal content area\n .modal-footer — Bottom action bar (flex, space-between)\n .modal-footer-right — Right-aligned button group\n\nModals and popups: ALWAYS use the theme\'s .modal-overlay class for any modal or popup overlay. Do NOT create custom overlay classes with position:fixed and z-index. Structure:\n \nShow/hide by toggling the "show" class: el.classList.add(\'show\') / el.classList.remove(\'show\'). This ensures correct z-index layering above the chat toggle and other UI elements.\n\nPage title bars: To align with the chat header, apply these styles:\n min-height: var(--header-min-height);\n padding: var(--header-padding-vertical) var(--header-padding-horizontal);\n line-height: var(--header-line-height);\n display: flex; align-items: center; justify-content: center; box-sizing: border-box;\n\nFull-viewer mode: For games, animations, or full-screen content, add class "full-viewer" to the viewer-panel element to remove its padding.\n\nChat panel behaviours (auto-injected via page script — do NOT recreate in page code):\n The server injects page-v2.js after transformation. It provides:\n - Form submit handler: sets action to window.location.pathname, shows #loadingOverlay, disables inputs\n - Save/Reset link handlers (#saveLink, #resetLink)\n - Chat scroll to bottom (#chatMessages)\n - Chat toggle button (.chat-toggle) — created dynamically if not in markup\n - .chat-input-wrapper — wraps #chatInput with a brainstorm icon button\n - Brainstorm modal (#brainstormModal) — LLM-powered brainstorm UI, created dynamically\n - Focus management — keeps keyboard input directed to #chatInput\n\n Do NOT:\n - Create your own form submit handler, toggle button, or input wrapper\n - Modify or replace .chat-panel, .chat-header, .link-group, #chatForm, or .chat-toggle\n - INSERT new '; + const result = addLineNumbers(html); + assert.ok(result.includes('01: let x = 1;')); + assert.ok(result.includes('02: let y = 2;')); + assert.ok(result.includes('03: return x + y;')); + }); + + it('adds 2-digit line numbers to style content', () => { + const html = ''; + const result = addLineNumbers(html); + assert.ok(result.includes('01: .a { color: red; }')); + assert.ok(result.includes('02: .b { color: blue; }')); + }); + + it('uses 3-digit padding when 100+ lines', () => { + const lines = Array.from({ length: 100 }, (_, i) => `let v${i} = ${i};`).join('\n'); + const html = ``; + const result = addLineNumbers(html); + assert.ok(result.includes('001: let v0 = 0;')); + assert.ok(result.includes('100: let v99 = 99;')); + }); + + it('skips scripts with src attribute', () => { + const html = ''; + const result = addLineNumbers(html); + assert.ok(!result.includes('01:')); + }); + + it('skips scripts with type="application/json"', () => { + const html = ''; + const result = addLineNumbers(html); + assert.ok(!result.includes('01:')); + }); + + it('leaves empty script blocks unchanged', () => { + const html = ''; + const result = addLineNumbers(html); + assert.ok(result.includes('')); + }); +}); + +// --------------------------------------------------------------------------- +// stripLineNumbers +// --------------------------------------------------------------------------- + +describe('stripLineNumbers', () => { + it('strips 2-digit line number prefixes', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('let x = 1;\nlet y = 2;')); + assert.ok(!result.includes('01:')); + }); + + it('strips 3-digit line number prefixes', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('let x = 1;\nlet y = 2;')); + assert.ok(!result.includes('001:')); + }); + + it('passes through lines without prefix unchanged', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('no prefix here\nhas prefix')); + }); + + it('handles mixed lines (some with, some without prefixes)', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('line one\nplain line\nline three')); + }); + + it('skips scripts with src attribute', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('01: should stay')); + }); + + it('skips scripts with type="application/json"', () => { + const html = ''; + const result = stripLineNumbers(html); + assert.ok(result.includes('01: should stay')); + }); +}); + +// --------------------------------------------------------------------------- +// addLineNumbers -> stripLineNumbers roundtrip +// --------------------------------------------------------------------------- + +describe('addLineNumbers -> stripLineNumbers roundtrip', () => { + it('roundtrip preserves script content', () => { + const original = ''; + const numbered = addLineNumbers(original); + assert.ok(numbered.includes('01:')); + const stripped = stripLineNumbers(numbered); + assert.ok(!stripped.includes('01:')); + assert.ok(stripped.includes('let x = 1;\nlet y = 2;\nreturn x + y;')); + }); + + it('roundtrip preserves style content', () => { + const original = ''; + const numbered = addLineNumbers(original); + const stripped = stripLineNumbers(numbered); + assert.ok(stripped.includes('.a { color: red; }\n.b { color: blue; }')); + }); +}); + +// --------------------------------------------------------------------------- +// applyChangeList — line-range ops +// --------------------------------------------------------------------------- + +describe('applyChangeList — line-range ops', () => { + const scriptHtml = '' + + '' + + ''; + + it('update-lines replaces a range of lines', () => { + const changes: ChangeList = [ + { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 3, content: 'let b = 20;\nlet c = 30;' }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(result.includes('let b = 20;')); + assert.ok(result.includes('let c = 30;')); + assert.ok(result.includes('01: let a = 1;')); + assert.ok(result.includes('04: let d = 4;')); + }); + + it('update-lines strips line number prefixes from model-provided content', () => { + const changes: ChangeList = [ + { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 2, content: '02: let b = 99;' }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(result.includes('let b = 99;')); + // Should not have double line number prefix + assert.ok(!result.includes('02: 02:')); + }); + + it('update-lines can expand (replace 1 line with 3)', () => { + const changes: ChangeList = [ + { op: 'update-lines', nodeId: '5', startLine: 3, endLine: 3, content: 'let c1 = 31;\nlet c2 = 32;\nlet c3 = 33;' }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(result.includes('let c1 = 31;')); + assert.ok(result.includes('let c2 = 32;')); + assert.ok(result.includes('let c3 = 33;')); + // Lines after should still be present + assert.ok(result.includes('04: let d = 4;')); + }); + + it('update-lines can contract (replace 3 lines with 1)', () => { + const changes: ChangeList = [ + { op: 'update-lines', nodeId: '5', startLine: 2, endLine: 4, content: 'let combined = 234;' }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(result.includes('let combined = 234;')); + assert.ok(result.includes('01: let a = 1;')); + assert.ok(result.includes('05: let e = 5;')); + assert.ok(!result.includes('let b = 2;')); + }); + + it('delete-lines removes a range of lines', () => { + const changes: ChangeList = [ + { op: 'delete-lines', nodeId: '5', startLine: 2, endLine: 3 }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(!result.includes('let b = 2;')); + assert.ok(!result.includes('let c = 3;')); + assert.ok(result.includes('01: let a = 1;')); + assert.ok(result.includes('04: let d = 4;')); + }); + + it('delete-lines removes a single line', () => { + const changes: ChangeList = [ + { op: 'delete-lines', nodeId: '5', startLine: 3, endLine: 3 }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(!result.includes('let c = 3;')); + assert.ok(result.includes('02: let b = 2;')); + assert.ok(result.includes('04: let d = 4;')); + }); + + it('insert-lines inserts after a specific line', () => { + const changes: ChangeList = [ + { op: 'insert-lines', nodeId: '5', afterLine: 2, content: 'let inserted = true;' }, + ]; + const result = applyChangeList(scriptHtml, changes); + assert.ok(result.includes('let inserted = true;')); + // Verify ordering: line 2 content, then inserted, then line 3 content + const idx2 = result.indexOf('02: let b = 2;'); + const idxInserted = result.indexOf('let inserted = true;'); + const idx3 = result.indexOf('03: let c = 3;'); + assert.ok(idx2 < idxInserted); + assert.ok(idxInserted < idx3); + }); + + it('insert-lines at afterLine 0 inserts before first line', () => { + const changes: ChangeList = [ + { op: 'insert-lines', nodeId: '5', afterLine: 0, content: '// header comment' }, + ]; + const result = applyChangeList(scriptHtml, changes); + const idxComment = result.indexOf('// header comment'); + const idxFirst = result.indexOf('01: let a = 1;'); + assert.ok(idxComment < idxFirst); + }); + + it('insert-lines at end inserts after last line', () => { + const changes: ChangeList = [ + { op: 'insert-lines', nodeId: '5', afterLine: 5, content: '// footer comment' }, + ]; + const result = applyChangeList(scriptHtml, changes); + const idxLast = result.indexOf('05: let e = 5;'); + const idxComment = result.indexOf('// footer comment'); + assert.ok(idxLast < idxComment); + }); + + it('warns but does not throw on missing node for line-range ops', () => { + const ops: ChangeList = [ + { op: 'update-lines', nodeId: '999', startLine: 1, endLine: 1, content: 'x' }, + { op: 'delete-lines', nodeId: '999', startLine: 1, endLine: 1 }, + { op: 'insert-lines', nodeId: '999', afterLine: 1, content: 'x' }, + ]; + for (const change of ops) { + const result = applyChangeList(scriptHtml, [change]); + // Should not throw, content preserved + assert.ok(result.includes('let a = 1;')); + } + }); + + it('works on '; + const changes: ChangeList = [ + { op: 'update-lines', nodeId: '3', startLine: 2, endLine: 2, content: '.b { color: purple; }' }, + ]; + const result = applyChangeList(styleHtml, changes); + assert.ok(result.includes('.b { color: purple; }')); + assert.ok(!result.includes('color: blue')); + }); +}); +// --------------------------------------------------------------------------- +// transformPage (integration with stub Builder) +// --------------------------------------------------------------------------- + +describe('transformPage', () => { // Minimal page with a viewer-panel and thoughts div const testPage = `
@@ -471,14 +716,6 @@ describe('transformPage', () => { `; - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'synthos-tp-test-')); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - /** Extract the data-node-id for an element identified by a CSS-style attribute (e.g. id="content") from annotated HTML. */ function findNodeId(annotatedHtml: string, idAttr: string): string { // Match a tag that contains both data-node-id="X" and the target id, in either order @@ -488,28 +725,30 @@ describe('transformPage', () => { return m ? m[1] : '99999'; } - function makeArgs(stub: (args: PromptCompletionArgs) => Promise>, pageState?: string): TransformPageArgs { + /** Create a stub builder that calls the given handler to produce a BuilderResult. */ + function makeBuilder(handler: (currentPage: ContextSection, additionalSections: ContextSection[], userMessage: string, newBuild: boolean) => Promise): Builder { + return { run: handler }; + } + + function makeArgs(builder: Builder, pageState?: string): TransformPageArgs { return { - completePrompt: stub, - pagesFolder: tmpDir, + builder, + additionalSections: [], pageState: pageState ?? testPage, message: 'Change the content', }; } it('happy path — applies valid change list and returns transformed HTML', async () => { - const stub = async (args: PromptCompletionArgs): Promise> => { - const sys = args.system?.content ?? ''; - const nodeId = findNodeId(sys, 'id="content"'); + const builder = makeBuilder(async (currentPage) => { + const nodeId = findNodeId(currentPage.content, 'id="content"'); return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId, html: 'Updated content' }, - ]), + kind: 'transforms', + changes: [{ op: 'update', nodeId, html: 'Updated content' }], }; - }; + }); - const result = await transformPage(makeArgs(stub)); + const result = await transformPage(makeArgs(builder)); assert.strictEqual(result.completed, true); assert.ok(result.value); assert.ok(result.value.html.includes('Updated content')); @@ -518,239 +757,72 @@ describe('transformPage', () => { assert.ok(!result.value.html.includes('data-node-id')); }); - it('returns error when completePrompt fails', async () => { - const stub = async (): Promise> => ({ - completed: false, + it('returns error HTML when builder returns error', async () => { + const builder = makeBuilder(async () => ({ + kind: 'error', error: new Error('API quota exceeded'), - }); - - const result = await transformPage(makeArgs(stub)); - assert.strictEqual(result.completed, false); - assert.strictEqual(result.error?.message, 'API quota exceeded'); - }); + })); - it('injects error block when response is not valid JSON', async () => { - const stub = async (): Promise> => ({ - completed: true, - value: 'I cannot help with that request.', - }); - - const result = await transformPage(makeArgs(stub)); - // Should still complete (error is injected into HTML, not thrown) + const result = await transformPage(makeArgs(builder)); assert.strictEqual(result.completed, true); assert.ok(result.value); - assert.strictEqual(result.value.changeCount, 0); assert.ok(result.value.html.includes('id="error"')); + assert.strictEqual(result.value.changeCount, 0); }); - it('handles failed ops and triggers repair pass', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - const sys = args.system?.content ?? ''; - if (callCount === 1) { - const contentNodeId = findNodeId(sys, 'id="content"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: contentNodeId, html: 'First pass change' }, - { op: 'update', nodeId: '9999', html: 'Ghost node' }, // will fail - ]), - }; - } else { - // Repair call: target an element that exists in re-annotated HTML - const thoughtsNodeId = findNodeId(sys, 'id="thoughts"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: thoughtsNodeId, html: 'Repaired content' }, - ]), - }; - } - }; - - const result = await transformPage(makeArgs(stub)); - assert.strictEqual(result.completed, true); - assert.ok(result.value); - // Should have made 2 calls (initial + repair) - assert.strictEqual(callCount, 2); - // changeCount should be 2 (1 success from first pass + 1 from repair) - assert.strictEqual(result.value.changeCount, 2); - }); - - it('keeps partial result when repair pass LLM call fails', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - if (callCount === 1) { - const sys = args.system?.content ?? ''; - const contentNodeId = findNodeId(sys, 'id="content"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: contentNodeId, html: 'Partial update' }, - { op: 'update', nodeId: '9999', html: 'Ghost' }, - ]), - }; - } else { - // Repair call fails - return { completed: false, error: new Error('Repair failed') }; - } - }; + it('handles reply result by appending chat messages', async () => { + const builder = makeBuilder(async () => ({ + kind: 'reply', + text: 'I cannot help with that.', + })); - const result = await transformPage(makeArgs(stub)); + const result = await transformPage({ + ...makeArgs(builder), + productName: 'SynthOS', + }); assert.strictEqual(result.completed, true); assert.ok(result.value); - assert.ok(result.value.html.includes('Partial update')); - assert.strictEqual(result.value.changeCount, 1); - }); - - it('handles repair pass returning empty array (no repairs needed)', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - if (callCount === 1) { - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: '9999', html: 'Ghost' }, - ]), - }; - } else { - return { completed: true, value: '[]' }; - } - }; - - const result = await transformPage(makeArgs(stub)); - assert.strictEqual(result.completed, true); - assert.strictEqual(callCount, 2); - }); - - it('includes instructions and modelInstructions in prompt', async () => { - let capturedPrompt: string | undefined; - const stub = async (args: PromptCompletionArgs): Promise> => { - capturedPrompt = args.prompt.content; - return { completed: true, value: '[]' }; - }; - - await transformPage({ - ...makeArgs(stub), - instructions: 'Be creative', - modelInstructions: 'Return concise JSON', - }); - - assert.ok(capturedPrompt); - assert.ok(capturedPrompt.includes('Be creative')); - assert.ok(capturedPrompt.includes('Return concise JSON')); - }); - - it('includes theme info in system prompt when provided', async () => { - let capturedSystem: string | undefined; - const stub = async (args: PromptCompletionArgs): Promise> => { - capturedSystem = args.system?.content; - return { completed: true, value: '[]' }; - }; - - await transformPage({ - ...makeArgs(stub), - themeInfo: { - mode: 'dark', - colors: { 'accent-primary': '#ff0000', 'text-primary': '#ffffff' }, - }, - }); - - assert.ok(capturedSystem); - assert.ok(capturedSystem.includes('Mode: dark')); - assert.ok(capturedSystem.includes('--accent-primary: #ff0000')); - }); - - it('includes connector info in system prompt when provided', async () => { - let capturedSystem: string | undefined; - const stub = async (args: PromptCompletionArgs): Promise> => { - capturedSystem = args.system?.content; - return { completed: true, value: '[]' }; - }; - - await transformPage({ - ...makeArgs(stub), - configuredConnectors: { - 'brave-search': { enabled: true, apiKey: 'test-key' } as any, - }, - }); - - assert.ok(capturedSystem); - assert.ok(capturedSystem.includes('CONFIGURED_CONNECTORS')); - }); - - it('includes agent info in system prompt when provided', async () => { - let capturedSystem: string | undefined; - const stub = async (args: PromptCompletionArgs): Promise> => { - capturedSystem = args.system?.content; - return { completed: true, value: '[]' }; - }; - - await transformPage({ - ...makeArgs(stub), - configuredAgents: [{ - id: 'test-agent', - name: 'Test Agent', - description: 'A test agent', - url: 'http://localhost:3000', - enabled: true, - provider: 'a2a' as any, - }], - }); - - assert.ok(capturedSystem); - assert.ok(capturedSystem.includes('CONFIGURED_AGENTS')); - assert.ok(capturedSystem.includes('Test Agent')); + assert.ok(result.value.html.includes('User:')); + assert.ok(result.value.html.includes('SynthOS:')); + assert.ok(result.value.html.includes('I cannot help with that.')); + assert.strictEqual(result.value.changeCount, 0); }); - it('includes agent capabilities and skills in system prompt', async () => { - let capturedSystem: string | undefined; - const stub = async (args: PromptCompletionArgs): Promise> => { - capturedSystem = args.system?.content; - return { completed: true, value: '[]' }; - }; - - await transformPage({ - ...makeArgs(stub), - configuredAgents: [{ - id: 'agent-1', - name: 'Streaming Agent', - description: 'Agent with streaming', - url: 'http://localhost:3000', - enabled: true, - provider: 'a2a', - capabilities: { streaming: true }, - skills: [ - { id: 'summarize', name: 'summarize', description: 'Summarizes text', tags: [] }, + it('handles missing nodes gracefully (no repair pass)', async () => { + const builder = makeBuilder(async (currentPage) => { + const contentNodeId = findNodeId(currentPage.content, 'id="content"'); + return { + kind: 'transforms', + changes: [ + { op: 'update', nodeId: contentNodeId, html: 'First pass change' }, + { op: 'update', nodeId: '9999', html: 'Ghost node' }, // will be skipped ], - }], + }; }); - assert.ok(capturedSystem); - assert.ok(capturedSystem.includes('Supports streaming: yes')); - assert.ok(capturedSystem.includes('summarize')); - assert.ok(capturedSystem.includes('Summarizes text')); + const result = await transformPage(makeArgs(builder)); + assert.strictEqual(result.completed, true); + assert.ok(result.value); + assert.ok(result.value.html.includes('First pass change')); + // changeCount counts all ops, including skipped ones + assert.strictEqual(result.value.changeCount, 2); }); it('exercises replace, delete, insert, and style-element ops through pipeline', async () => { - const stub = async (args: PromptCompletionArgs): Promise> => { - const sys = args.system?.content ?? ''; - const contentNodeId = findNodeId(sys, 'id="content"'); - const viewerNodeId = findNodeId(sys, 'class="viewer-panel"'); + const builder = makeBuilder(async (currentPage) => { + const contentNodeId = findNodeId(currentPage.content, 'id="content"'); + const viewerNodeId = findNodeId(currentPage.content, 'class="viewer-panel"'); return { - completed: true, - value: JSON.stringify([ + kind: 'transforms', + changes: [ { op: 'replace', nodeId: contentNodeId, html: '

Replaced

' }, { op: 'insert', parentId: viewerNodeId, position: 'append', html: 'Appended' }, { op: 'style-element', nodeId: viewerNodeId, style: 'background: blue' }, - ]), + ], }; - }; + }); - const result = await transformPage(makeArgs(stub)); + const result = await transformPage(makeArgs(builder)); assert.strictEqual(result.completed, true); assert.ok(result.value); assert.ok(result.value.html.includes('Replaced')); @@ -759,8 +831,7 @@ describe('transformPage', () => { assert.strictEqual(result.value.changeCount, 3); }); - it('reports failed replace on locked element and triggers repair', async () => { - // Use a page where the content is data-locked + it('skips replace on locked element', async () => { const lockedPage = `

SynthOS: Welcome!

@@ -769,77 +840,20 @@ describe('transformPage', () => { `; - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - const sys = args.system?.content ?? ''; - if (callCount === 1) { - const contentNodeId = findNodeId(sys, 'id="content"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'replace', nodeId: contentNodeId, html: '

Should fail

' }, - ]), - }; - } else { - // Repair: return empty array (nothing to fix) - return { completed: true, value: '[]' }; - } - }; + const builder = makeBuilder(async (currentPage) => { + const contentNodeId = findNodeId(currentPage.content, 'id="content"'); + return { + kind: 'transforms', + changes: [{ op: 'replace', nodeId: contentNodeId, html: '

Should fail

' }], + }; + }); - const result = await transformPage(makeArgs(stub, lockedPage)); + const result = await transformPage(makeArgs(builder, lockedPage)); assert.strictEqual(result.completed, true); - assert.strictEqual(callCount, 2); - // Original locked content should still be present assert.ok(result.value!.html.includes('Locked content')); }); - it('handles repair pass that throws an error', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - if (callCount === 1) { - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: '9999', html: 'Ghost' }, - ]), - }; - } else { - // Repair call returns invalid JSON that will cause parseChangeList to throw - return { completed: true, value: 'not valid json at all' }; - } - }; - - const result = await transformPage(makeArgs(stub)); - assert.strictEqual(result.completed, true); - assert.strictEqual(callCount, 2); - // Should have kept partial result from first pass - assert.ok(result.value); - }); - - it('reports failed insert on missing parent through pipeline', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - if (callCount === 1) { - return { - completed: true, - value: JSON.stringify([ - { op: 'insert', parentId: '9999', position: 'append', html: 'Ghost' }, - ]), - }; - } else { - return { completed: true, value: '[]' }; - } - }; - - const result = await transformPage(makeArgs(stub)); - assert.strictEqual(result.completed, true); - assert.strictEqual(callCount, 2); - }); - - it('reports failed delete on locked element through pipeline', async () => { + it('skips delete on locked element', async () => { const lockedPage = `

SynthOS: Welcome!

@@ -848,29 +862,20 @@ describe('transformPage', () => { `; - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - const sys = args.system?.content ?? ''; - if (callCount === 1) { - const contentNodeId = findNodeId(sys, 'id="content"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'delete', nodeId: contentNodeId }, - ]), - }; - } else { - return { completed: true, value: '[]' }; - } - }; + const builder = makeBuilder(async (currentPage) => { + const contentNodeId = findNodeId(currentPage.content, 'id="content"'); + return { + kind: 'transforms', + changes: [{ op: 'delete', nodeId: contentNodeId }], + }; + }); - const result = await transformPage(makeArgs(stub, lockedPage)); + const result = await transformPage(makeArgs(builder, lockedPage)); assert.strictEqual(result.completed, true); assert.ok(result.value!.html.includes('Locked')); }); - it('reports failed style-element on locked element through pipeline', async () => { + it('skips style-element on locked element', async () => { const lockedPage = `

SynthOS: Welcome!

@@ -879,53 +884,103 @@ describe('transformPage', () => { `; - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - const sys = args.system?.content ?? ''; - if (callCount === 1) { - const contentNodeId = findNodeId(sys, 'id="content"'); - return { - completed: true, - value: JSON.stringify([ - { op: 'style-element', nodeId: contentNodeId, style: 'color: red' }, - ]), - }; - } else { - return { completed: true, value: '[]' }; - } - }; + const builder = makeBuilder(async (currentPage) => { + const contentNodeId = findNodeId(currentPage.content, 'id="content"'); + return { + kind: 'transforms', + changes: [{ op: 'style-element', nodeId: contentNodeId, style: 'color: red' }], + }; + }); - const result = await transformPage(makeArgs(stub, lockedPage)); + const result = await transformPage(makeArgs(builder, lockedPage)); assert.strictEqual(result.completed, true); - // Locked element should not have the style applied assert.ok(!result.value!.html.includes('color: red')); }); - it('repair pass with remaining failures keeps partial result', async () => { - let callCount = 0; - const stub = async (args: PromptCompletionArgs): Promise> => { - callCount++; - if (callCount === 1) { - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: '9999', html: 'Ghost' }, - ]), - }; - } else { - // Repair also fails — targets non-existent node - return { - completed: true, - value: JSON.stringify([ - { op: 'update', nodeId: '8888', html: 'Still ghost' }, - ]), - }; - } - }; + it('catches exceptions from builder and injects error', async () => { + const builder = makeBuilder(async () => { + throw new Error('Unexpected builder crash'); + }); - const result = await transformPage(makeArgs(stub)); + const result = await transformPage(makeArgs(builder)); assert.strictEqual(result.completed, true); - assert.strictEqual(callCount, 2); + assert.ok(result.value); + assert.ok(result.value.html.includes('id="error"')); + assert.strictEqual(result.value.changeCount, 0); + }); + + it('detects newBuild when isBuilder is true and only one chat message', async () => { + let capturedNewBuild: boolean | undefined; + const builder = makeBuilder(async (_cp, _as, _msg, newBuild) => { + capturedNewBuild = newBuild; + return { kind: 'transforms', changes: [] }; + }); + + await transformPage({ + ...makeArgs(builder), + isBuilder: true, + }); + + assert.strictEqual(capturedNewBuild, true); + }); + + it('detects existing build when multiple chat messages present', async () => { + const multiMessagePage = ` +
+
+

SynthOS: Welcome!

+

User: Build me a todo app

+

SynthOS: Here you go!

+
+
+

Content

+ `; + + let capturedNewBuild: boolean | undefined; + const builder = makeBuilder(async (_cp, _as, _msg, newBuild) => { + capturedNewBuild = newBuild; + return { kind: 'transforms', changes: [] }; + }); + + await transformPage({ + ...makeArgs(builder, multiMessagePage), + isBuilder: true, + }); + + assert.strictEqual(capturedNewBuild, false); + }); + + it('applies update-lines through full pipeline and strips line numbers', async () => { + const pageWithScript = ` +
+

SynthOS: Welcome!

+
+

Hello

+ + `; + + const builder = makeBuilder(async (currentPage) => { + // The current page should have line numbers + assert.ok(currentPage.content.includes('01:'), 'currentPage should contain line numbers'); + // Find the script node id + const scriptNodeId = findNodeId(currentPage.content, 'id="page-script"'); + return { + kind: 'transforms', + changes: [ + { op: 'update-lines', nodeId: scriptNodeId, startLine: 1, endLine: 1, content: 'let count = 42;' }, + ], + }; + }); + + const result = await transformPage(makeArgs(builder, pageWithScript)); + assert.strictEqual(result.completed, true); + assert.ok(result.value); + // Edit should be applied + assert.ok(result.value.html.includes('let count = 42;')); + // Line numbers should be stripped + assert.ok(!result.value.html.match(/\d{2}: /), 'No line number prefixes should remain'); + // Node ids should be stripped + assert.ok(!result.value.html.includes('data-node-id')); + assert.strictEqual(result.value.changeCount, 1); }); });