From 58882add6d78be89bb4d7bed243b76555855abfd Mon Sep 17 00:00:00 2001 From: Steven Ickman Date: Mon, 23 Feb 2026 22:55:57 -0800 Subject: [PATCH 1/4] new builder architecture --- src/builders/anthropic.ts | 105 ++++++++ src/builders/fireworksai.ts | 59 +++++ src/builders/index.ts | 27 ++ src/builders/openai.ts | 59 +++++ src/builders/types.ts | 36 +++ src/customizer/Customizer.ts | 17 ++ src/service/transformPage.ts | 368 ++++++-------------------- src/service/usePageRoutes.ts | 185 ++++++++++++- tests/transformPage.spec.ts | 494 ++++++++++------------------------- 9 files changed, 681 insertions(+), 669 deletions(-) create mode 100644 src/builders/anthropic.ts create mode 100644 src/builders/fireworksai.ts create mode 100644 src/builders/index.ts create mode 100644 src/builders/openai.ts create mode 100644 src/builders/types.ts diff --git a/src/builders/anthropic.ts b/src/builders/anthropic.ts new file mode 100644 index 0000000..3fe0cc8 --- /dev/null +++ b/src/builders/anthropic.ts @@ -0,0 +1,105 @@ +import { completePrompt } from '../models'; +import { parseChangeList, getTransformInstr } from '../service/transformPage'; +import { Builder, BuilderResult } from './types'; + +/** + * 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'). + */ +export function createAnthropicBuilder(complete: completePrompt, userInstructions?: string, productName?: string): Builder { + const name = productName ?? 'SynthOS'; + + return { + async run(currentPage, additionalSections, userMessage, newBuild): Promise { + try { + // -- System message: join all sections as title + content -- + 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: instructions then user message -- + const instructionParts: string[] = []; + if (userInstructions?.trim()) { + instructionParts.push(userInstructions); + } + // Section-specific instructions (in order) + for (const section of additionalSections) { + if (section.instructions?.trim()) { + instructionParts.push(section.instructions); + } + } + // Builder-specific instructions (transform rules + output format) + instructionParts.push(getTransformInstr(name)); + instructionParts.push(getOutputFormatInstructions()); + + const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); + const promptContent = `\n${userMessage}\n\n\n${instructions}`; + + // -- Call model -- + 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') }; + } + + // -- Parse response -- + return parseBuilderResponse(result.value!); + } catch (err: unknown) { + return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; + } + } + }; +} + +// --------------------------------------------------------------------------- +// Output format instructions +// --------------------------------------------------------------------------- + +function getOutputFormatInstructions(): string { + return `Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`; +} + +// --------------------------------------------------------------------------- +// 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..bac7fec --- /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 } 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(`Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`); + + 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..b93cae7 --- /dev/null +++ b/src/builders/index.ts @@ -0,0 +1,27 @@ +import { ProviderName, completePrompt } from '../models'; +import { createAnthropicBuilder } from './anthropic'; +import { createOpenAIBuilder } from './openai'; +import { createFireworksAIBuilder } from './fireworksai'; +import { Builder } from './types'; + +export { ContextSection, BuilderResult, Builder } from './types'; +export { createAnthropicBuilder } 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): Builder { + switch (provider) { + case 'Anthropic': + return createAnthropicBuilder(complete, userInstructions, productName); + 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..846ef76 --- /dev/null +++ b/src/builders/openai.ts @@ -0,0 +1,59 @@ +import { completePrompt } from '../models'; +import { getTransformInstr } from '../service/transformPage'; +import { parseBuilderResponse } from './anthropic'; +import { Builder, BuilderResult } from './types'; + +/** + * Create an OpenAI-tuned builder. + * Currently identical to the Anthropic builder — separate file enables + * future per-provider tuning. + */ +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)); + instructionParts.push(`Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`); + + 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/types.ts b/src/builders/types.ts new file mode 100644 index 0000000..04b517e --- /dev/null +++ b/src/builders/types.ts @@ -0,0 +1,36 @@ +import { ChangeList } from '../service/transformPage'; + +// --------------------------------------------------------------------------- +// 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/service/transformPage.ts b/src/service/transformPage.ts index b8d4cc2..124163f 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; } @@ -42,16 +28,6 @@ export type ChangeOp = export type ChangeList = ChangeOp[]; -interface FailedOp { - op: ChangeOp; - reason: string; -} - -interface ApplyResult { - html: string; - failedOps: FailedOp[]; -} - // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- @@ -62,163 +38,54 @@ export interface TransformPageResult { } export async function transformPage(args: TransformPageArgs): Promise> { - const { pagesFolder, pageState, message, completePrompt } = args; + const { pageState, message, builder, additionalSections } = args; // 1. Assign data-node-id to every element const { html: annotatedHtml } = assignNodeIds(pageState); 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 '; + 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) // --------------------------------------------------------------------------- @@ -702,4 +949,38 @@ describe('transformPage', () => { 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); + }); }); From 810a27a8c46a88f390aa5552a56416c4bad82e03 Mon Sep 17 00:00:00 2001 From: Steven Ickman Date: Tue, 24 Feb 2026 01:10:35 -0800 Subject: [PATCH 3/4] added a classifier mode to anthropic builder --- page-scripts/page-v2.js | 9 ++ src/builders/anthropic.ts | 209 +++++++++++++++++++++++++++++------ src/builders/index.ts | 14 ++- src/service/transformPage.ts | 108 +++++++++++++++++- src/service/usePageRoutes.ts | 100 +++++++++++++---- tests/builders.spec.ts | 139 +++++++++++++++++++++++ 6 files changed, 516 insertions(+), 63 deletions(-) create mode 100644 tests/builders.spec.ts 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 index 3fe0cc8..4925c40 100644 --- a/src/builders/anthropic.ts +++ b/src/builders/anthropic.ts @@ -1,6 +1,76 @@ -import { completePrompt } from '../models'; +import { anthropic as createAnthropicModel, completePrompt } from '../models'; import { parseChangeList, getTransformInstr } from '../service/transformPage'; -import { Builder, BuilderResult } from './types'; +import { Builder, BuilderResult, 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 are a request classifier and assistant for a web page builder. Given the current page HTML and a user message, classify the user's intent as exactly one of: + +- "question" — The user is asking a question, seeking information, or wants an explanation. They are NOT requesting any change to the page. +- "easy-change" — The user wants a simple, small change: editing text, changing colors/styles, adding or removing a single element, toggling visibility, simple layout adjustments, minor CSS tweaks. +- "hard-change" — The user wants a complex change: adding new features, creating games or animations, restructuring multiple components, writing significant new JavaScript logic, building forms with validation, adding interactivity, or any multi-step transformation. + +If the classification is "question", also provide a brief, helpful answer to the user's question. + +Return JSON: +- For changes: { "classification": "easy-change" } or { "classification": "hard-change" } +- For questions: { "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. @@ -8,54 +78,55 @@ import { Builder, BuilderResult } from './types'; * @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): Builder { +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 { - // -- System message: join all sections as title + content -- - 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'); + const isOpus = options?.model?.startsWith('claude-opus-'); - // -- User message: instructions then user message -- - const instructionParts: string[] = []; - if (userInstructions?.trim()) { - instructionParts.push(userInstructions); + // Non-Opus models or missing apiKey: existing behavior + if (!isOpus || !options?.apiKey) { + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); } - // Section-specific instructions (in order) - for (const section of additionalSections) { - if (section.instructions?.trim()) { - instructionParts.push(section.instructions); - } + + // 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); } - // Builder-specific instructions (transform rules + output format) - instructionParts.push(getTransformInstr(name)); - instructionParts.push(getOutputFormatInstructions()); - const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); - const promptContent = `\n${userMessage}\n\n\n${instructions}`; + // 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!)}`); - // -- Call model -- - const result = await complete({ - system: { role: 'system', content: systemContent }, - prompt: { role: 'user', content: promptContent }, - }); + // Questions — answer was already provided by the classifier + if (classifyResult.classification === 'question') { + return { kind: 'reply', text: classifyResult.answer ?? '' }; + } - if (!result.completed) { - return { kind: 'error', error: result.error ?? new Error('Model call failed') }; + // New builds always use Opus (the configured model) + if (newBuild) { + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); } - // -- Parse response -- - return parseBuilderResponse(result.value!); + // 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)) }; } @@ -63,6 +134,72 @@ export function createAnthropicBuilder(complete: completePrompt, userInstruction }; } +// --------------------------------------------------------------------------- +// 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: join all sections as title + content -- + 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: instructions then user message -- + const instructionParts: string[] = []; + if (userInstructions?.trim()) { + instructionParts.push(userInstructions); + } + // Section-specific instructions (in order) + for (const section of additionalSections) { + if (section.instructions?.trim()) { + instructionParts.push(section.instructions); + } + } + // Builder-specific instructions (transform rules + output format) + instructionParts.push(getTransformInstr(productName)); + instructionParts.push(getOutputFormatInstructions()); + + const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); + const promptContent = `\n${userMessage}\n\n\n${instructions}`; + + // -- Call model -- + const result = await model({ + system: { role: 'system', content: systemContent }, + prompt: { role: 'user', content: promptContent }, + }); + + 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-20250514'; + return configuredModel; +} + // --------------------------------------------------------------------------- // Output format instructions // --------------------------------------------------------------------------- diff --git a/src/builders/index.ts b/src/builders/index.ts index b93cae7..d498955 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -1,11 +1,11 @@ import { ProviderName, completePrompt } from '../models'; -import { createAnthropicBuilder } from './anthropic'; +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 } from './anthropic'; +export { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic'; export { createOpenAIBuilder } from './openai'; export { createFireworksAIBuilder } from './fireworksai'; export { parseBuilderResponse } from './anthropic'; @@ -13,10 +13,16 @@ export { parseBuilderResponse } from './anthropic'; /** * Factory that creates a provider-specific builder. */ -export function createBuilder(provider: ProviderName, complete: completePrompt, userInstructions?: string, productName?: string): Builder { +export function createBuilder( + provider: ProviderName, + complete: completePrompt, + userInstructions?: string, + productName?: string, + options?: AnthropicBuilderOptions +): Builder { switch (provider) { case 'Anthropic': - return createAnthropicBuilder(complete, userInstructions, productName); + return createAnthropicBuilder(complete, userInstructions, productName, options); case 'OpenAI': return createOpenAIBuilder(complete, userInstructions, productName); case 'FireworksAI': diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index 3ae4313..2402860 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -41,7 +41,10 @@ export interface TransformPageResult { } export async function transformPage(args: TransformPageArgs): Promise> { - const { pageState, message, builder, additionalSections } = 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); @@ -117,7 +120,8 @@ function appendChatReply(annotatedHtml: string, userMessage: string, replyText: const chatMessages = $('#chatMessages'); if (chatMessages.length > 0) { chatMessages.append(`

User: ${escapeHtml(userMessage)}

`); - chatMessages.append(`

${escapeHtml(productName)}: ${replyText}

`); + const replyHtml = simpleMarkdown(replyText); + chatMessages.append(`

${escapeHtml(productName)}: ${replyHtml}

`); } return $.html(); } @@ -130,6 +134,94 @@ function escapeHtml(text: string): string { .replace(/"/g, '"'); } +/** + * Lightweight markdown-to-HTML converter for chat reply text. + * Handles: code blocks, inline code, bold, italic, links, unordered/ordered lists, paragraphs. + */ +export function simpleMarkdown(text: string): string { + // Extract fenced code blocks first to protect their contents + const codeBlocks: string[] = []; + let processed = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => { + const idx = codeBlocks.length; + const escaped = escapeHtml(code.replace(/\n$/, '')); + const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; + codeBlocks.push(`
${escaped}
`); + return `\x00CODEBLOCK${idx}\x00`; + }); + + // Split into paragraphs by blank lines + const blocks = processed.split(/\n{2,}/); + const htmlBlocks: string[] = []; + + for (const block of blocks) { + const trimmed = block.trim(); + if (!trimmed) continue; + + // Code block placeholder + if (/^\x00CODEBLOCK\d+\x00$/.test(trimmed)) { + htmlBlocks.push(trimmed); + continue; + } + + // Unordered list (lines starting with - or *) + if (/^[\-\*] /m.test(trimmed) && trimmed.split('\n').every(l => /^[\-\*] /.test(l.trim()) || l.trim() === '')) { + const items = trimmed.split('\n') + .map(l => l.trim()) + .filter(l => l) + .map(l => `
  • ${inlineMarkdown(l.replace(/^[\-\*] /, ''))}
  • `) + .join(''); + htmlBlocks.push(`
      ${items}
    `); + continue; + } + + // Ordered list (lines starting with 1. 2. etc.) + if (/^\d+\. /m.test(trimmed) && trimmed.split('\n').every(l => /^\d+\. /.test(l.trim()) || l.trim() === '')) { + const items = trimmed.split('\n') + .map(l => l.trim()) + .filter(l => l) + .map(l => `
  • ${inlineMarkdown(l.replace(/^\d+\. /, ''))}
  • `) + .join(''); + htmlBlocks.push(`
      ${items}
    `); + continue; + } + + // Regular paragraph + htmlBlocks.push(`

    ${inlineMarkdown(trimmed.replace(/\n/g, '
    '))}

    `); + } + + // Restore code blocks + let result = htmlBlocks.join(''); + result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, idx) => codeBlocks[parseInt(idx)]); + return result; +} + +/** Apply inline markdown formatting: bold, italic, inline code, links. */ +function inlineMarkdown(text: string): string { + // Inline code (protect from further processing) + const codes: string[] = []; + let result = text.replace(/`([^`]+)`/g, (_m, code) => { + const idx = codes.length; + codes.push(`${escapeHtml(code)}`); + return `\x00CODE${idx}\x00`; + }); + + // Bold (**text** or __text__) + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/__(.+?)__/g, '$1'); + + // Italic (*text* or _text_) + result = result.replace(/\*(.+?)\*/g, '$1'); + result = result.replace(/(?$1'); + + // Links [text](url) + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Restore inline code + result = result.replace(/\x00CODE(\d+)\x00/g, (_m, idx) => codes[parseInt(idx)]); + + return result; +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -159,6 +251,18 @@ export function stripNodeIds(html: string): string { return $.html(); } +/** + * Remove the early error-capture script injected into so the LLM + * never sees it during page transformation. + */ +function stripErrorCapture(html: string): string { + const id = 'synthos-error-capture'; + if (!html.includes(id)) return html; + const $ = cheerio.load(html, { decodeEntities: false }); + $(`#${id}`).remove(); + return $.html(); +} + /** * Add line numbers to inline ``; + +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 // --------------------------------------------------------------------------- @@ -234,6 +283,7 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize } let html = ensureRequiredImports(pageState, pageVersion); + html = injectErrorCapture(html, pageVersion); html = injectPageInfoScript(html, page); html = injectPageHelpers(html, pageVersion); html = injectPageScript(html, pageVersion); @@ -382,28 +432,31 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize const debugVerbose = config.debugPageUpdates; let inputChars = 0; let outputChars = 0; - const wrappedCompletePrompt: completePrompt = async (args) => { - if (debugVerbose) { - console.log(green(dim('\n ===== PAGE UPDATE REQUEST ====='))); - console.log(green(` SYSTEM:\n${args.system?.content}`)); - console.log(green(`\n PROMPT:\n${args.prompt.content}`)); - } - inputChars += (args.system?.content?.length ?? 0) + (args.prompt.content?.length ?? 0); - const result = await innerCompletePrompt(args); - if (result.completed) { - outputChars += result.value?.length ?? 0; - } - if (debugVerbose) { - console.log(green(dim('\n ----- PAGE UPDATE RESPONSE -----'))); + const wrapModel = (inner: completePrompt): completePrompt => { + return async (args) => { + if (debugVerbose) { + console.log(green(dim('\n ===== PAGE UPDATE REQUEST ====='))); + console.log(green(` SYSTEM:\n${args.system?.content}`)); + console.log(green(`\n PROMPT:\n${args.prompt.content}`)); + } + inputChars += (args.system?.content?.length ?? 0) + (args.prompt.content?.length ?? 0); + const result = await inner(args); if (result.completed) { - console.log(green(` RESPONSE:\n${result.value}`)); - } else { - console.log(red(` ERROR: ${result.error?.message}`)); + outputChars += result.value?.length ?? 0; } - console.log(green(dim(' ================================\n'))); - } - return result; - } + if (debugVerbose) { + console.log(green(dim('\n ----- PAGE UPDATE RESPONSE -----'))); + if (result.completed) { + console.log(green(` RESPONSE:\n${result.value}`)); + } else { + console.log(red(` ERROR: ${result.error?.message}`)); + } + console.log(green(dim(' ================================\n'))); + } + return result; + }; + }; + const wrappedCompletePrompt = wrapModel(innerCompletePrompt); // Load settings and build context const pagesFolder = config.pagesFolder; @@ -453,7 +506,11 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize } // Create builder - const builder = createBuilder(entry.provider, wrappedCompletePrompt, instructions, productName); + const builder = createBuilder(entry.provider, wrappedCompletePrompt, instructions, productName, { + apiKey: entry.configuration.apiKey, + model: entry.configuration.model, + wrapModel, + }); // Transform page const result = await transformPage({ @@ -485,6 +542,7 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize // Inject required imports and page scripts (same as GET) const pv = metadata?.pageVersion ?? 0; let out = ensureRequiredImports(html, pv); + out = injectErrorCapture(out, pv); out = injectPageInfoScript(out, page); out = injectPageHelpers(out, pv); out = injectPageScript(out, pv); diff --git a/tests/builders.spec.ts b/tests/builders.spec.ts new file mode 100644 index 0000000..1c9a29a --- /dev/null +++ b/tests/builders.spec.ts @@ -0,0 +1,139 @@ +import assert from 'assert'; +import { createAnthropicBuilder, classifyRequest, buildWithModel } from '../src/builders/anthropic'; +import { createBuilder } from '../src/builders/index'; +import { ContextSection, BuilderResult } from '../src/builders/types'; +import { completePrompt, AgentCompletion } from '../src/models/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PAGE_HTML = '

    Hello

    '; + +const currentPage: ContextSection = { + title: '', + content: PAGE_HTML, + instructions: '', +}; + +const additionalSections: ContextSection[] = []; + +/** Creates a stub completePrompt that returns the given raw string. */ +function stubComplete(raw: string): completePrompt { + return async () => ({ completed: true, value: raw } as AgentCompletion); +} + +/** Creates a stub completePrompt that records calls and returns a value. */ +function trackingComplete(raw: string): { fn: completePrompt; calls: number } { + const tracker = { fn: null as unknown as completePrompt, calls: 0 }; + tracker.fn = async () => { + tracker.calls++; + return { completed: true, value: raw } as AgentCompletion; + }; + return tracker; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Anthropic Builder — Haiku Classifier Routing', () => { + + describe('Non-Opus model → no classification', () => { + it('calls the provided complete function directly for non-Opus models', async () => { + const changes = JSON.stringify([{ op: 'replace', nodeId: '1', html: '

    Hi

    ' }]); + const tracker = trackingComplete(changes); + + const builder = createAnthropicBuilder(tracker.fn, undefined, 'SynthOS', { + apiKey: 'test-key', + model: 'claude-sonnet-4-5-20250514', + }); + + const result = await builder.run(currentPage, additionalSections, 'change heading', false); + assert.strictEqual(result.kind, 'transforms'); + assert.strictEqual(tracker.calls, 1); + }); + }); + + describe('No apiKey → skip classification', () => { + it('uses existing behavior when options.apiKey is undefined', async () => { + const changes = JSON.stringify([{ op: 'replace', nodeId: '1', html: '

    Hi

    ' }]); + const tracker = trackingComplete(changes); + + const builder = createAnthropicBuilder(tracker.fn, undefined, 'SynthOS', { + model: 'claude-opus-4-20250514', + // no apiKey + }); + + const result = await builder.run(currentPage, additionalSections, 'change heading', false); + assert.strictEqual(result.kind, 'transforms'); + assert.strictEqual(tracker.calls, 1); + }); + }); + + describe('buildWithModel', () => { + it('constructs the prompt and parses a transforms response', async () => { + const changes = [{ op: 'replace', nodeId: '1', html: '

    Updated

    ' }]; + const complete = stubComplete(JSON.stringify(changes)); + + const result = await buildWithModel(complete, currentPage, additionalSections, 'update heading', undefined, 'SynthOS'); + assert.strictEqual(result.kind, 'transforms'); + if (result.kind === 'transforms') { + assert.strictEqual(result.changes.length, 1); + } + }); + + it('returns error when model call fails', async () => { + const complete: completePrompt = async () => ({ + completed: false, + error: new Error('API error'), + }); + + const result = await buildWithModel(complete, currentPage, additionalSections, 'test', undefined, 'SynthOS'); + assert.strictEqual(result.kind, 'error'); + }); + + it('returns a reply result for { kind: "reply" } responses', async () => { + const reply = JSON.stringify({ kind: 'reply', text: 'The heading says Hello.' }); + const complete = stubComplete(reply); + + const result = await buildWithModel(complete, currentPage, additionalSections, 'what does the heading say?', undefined, 'SynthOS'); + assert.strictEqual(result.kind, 'reply'); + if (result.kind === 'reply') { + assert.strictEqual(result.text, 'The heading says Hello.'); + } + }); + }); + + describe('createBuilder factory', () => { + it('passes options through to Anthropic builder', async () => { + const changes = JSON.stringify([{ op: 'replace', nodeId: '1', html: '

    test

    ' }]); + const tracker = trackingComplete(changes); + + // Non-Opus model should just work without classification + const builder = createBuilder('Anthropic', tracker.fn, undefined, 'SynthOS', { + apiKey: 'key', + model: 'claude-sonnet-4-5-20250514', + }); + + const result = await builder.run(currentPage, additionalSections, 'test', false); + assert.strictEqual(result.kind, 'transforms'); + assert.strictEqual(tracker.calls, 1); + }); + + it('does not pass options to non-Anthropic builders', async () => { + const changes = JSON.stringify([{ op: 'replace', nodeId: '1', html: '

    test

    ' }]); + const tracker = trackingComplete(changes); + + // OpenAI builder should work fine (options ignored) + const builder = createBuilder('OpenAI', tracker.fn, undefined, 'SynthOS', { + apiKey: 'key', + model: 'gpt-4o', + }); + + const result = await builder.run(currentPage, additionalSections, 'test', false); + assert.strictEqual(result.kind, 'transforms'); + assert.strictEqual(tracker.calls, 1); + }); + }); +}); From df9840f148e9275d010607ef64092cb2a7bed86b Mon Sep 17 00:00:00 2001 From: Steven Ickman Date: Tue, 24 Feb 2026 02:44:13 -0800 Subject: [PATCH 4/4] added structured output support --- src/builders/anthropic.ts | 57 ++++++------- src/builders/fireworksai.ts | 4 +- src/builders/openai.ts | 27 ++++-- src/builders/types.ts | 160 +++++++++++++++++++++++++++++++++++ src/models/anthropic.ts | 23 +++-- src/models/types.ts | 4 + src/service/transformPage.ts | 40 +-------- 7 files changed, 235 insertions(+), 80 deletions(-) diff --git a/src/builders/anthropic.ts b/src/builders/anthropic.ts index 4925c40..895143a 100644 --- a/src/builders/anthropic.ts +++ b/src/builders/anthropic.ts @@ -1,6 +1,6 @@ import { anthropic as createAnthropicModel, completePrompt } from '../models'; import { parseChangeList, getTransformInstr } from '../service/transformPage'; -import { Builder, BuilderResult, ContextSection } from './types'; +import { Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types'; // --------------------------------------------------------------------------- // Builder options — passed from the route handler @@ -25,17 +25,25 @@ export interface ClassifyResult { answer?: string; } -const CLASSIFIER_SYSTEM_PROMPT = `You are a request classifier and assistant for a web page builder. Given the current page HTML and a user message, classify the user's intent as exactly one of: +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. -- "question" — The user is asking a question, seeking information, or wants an explanation. They are NOT requesting any change to the page. -- "easy-change" — The user wants a simple, small change: editing text, changing colors/styles, adding or removing a single element, toggling visibility, simple layout adjustments, minor CSS tweaks. -- "hard-change" — The user wants a complex change: adding new features, creating games or animations, restructuring multiple components, writing significant new JavaScript logic, building forms with validation, adding interactivity, or any multi-step transformation. + +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. -If the classification is "question", also provide a brief, helpful answer to the user's question. +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" -Return JSON: -- For changes: { "classification": "easy-change" } or { "classification": "hard-change" } -- For questions: { "classification": "question", "answer": "" }`; +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, @@ -146,39 +154,38 @@ export async function buildWithModel( userInstructions: string | undefined, productName: string ): Promise { - // -- System message: join all sections as title + content -- - const systemParts: string[] = [ - `${currentPage.title}\n${currentPage.content}`, - ]; + // -- 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 systemContent = systemParts.join('\n\n'); - // -- User message: instructions then user message -- const instructionParts: string[] = []; if (userInstructions?.trim()) { instructionParts.push(userInstructions); } - // Section-specific instructions (in order) for (const section of additionalSections) { if (section.instructions?.trim()) { instructionParts.push(section.instructions); } } - // Builder-specific instructions (transform rules + output format) instructionParts.push(getTransformInstr(productName)); - instructionParts.push(getOutputFormatInstructions()); - const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); - const promptContent = `\n${userMessage}\n\n\n${instructions}`; + 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) { @@ -196,18 +203,10 @@ export async function buildWithModel( 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-20250514'; + if (classification === 'easy-change') return 'claude-sonnet-4-5'; return configuredModel; } -// --------------------------------------------------------------------------- -// Output format instructions -// --------------------------------------------------------------------------- - -function getOutputFormatInstructions(): string { - return `Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`; -} - // --------------------------------------------------------------------------- // Response parsing — shared across builders // --------------------------------------------------------------------------- diff --git a/src/builders/fireworksai.ts b/src/builders/fireworksai.ts index bac7fec..c88f9f4 100644 --- a/src/builders/fireworksai.ts +++ b/src/builders/fireworksai.ts @@ -1,7 +1,7 @@ import { completePrompt } from '../models'; import { getTransformInstr } from '../service/transformPage'; import { parseBuilderResponse } from './anthropic'; -import { Builder, BuilderResult } from './types'; +import { Builder, BuilderResult, CHANGE_OPS_FORMAT_INSTRUCTION } from './types'; /** * Create a FireworksAI-tuned builder. @@ -36,7 +36,7 @@ export function createFireworksAIBuilder(complete: completePrompt, userInstructi } } instructionParts.push(getTransformInstr(name)); - instructionParts.push(`Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`); + instructionParts.push(CHANGE_OPS_FORMAT_INSTRUCTION); const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); const promptContent = `\n${userMessage}\n\n\n${instructions}`; diff --git a/src/builders/openai.ts b/src/builders/openai.ts index 846ef76..5a832b2 100644 --- a/src/builders/openai.ts +++ b/src/builders/openai.ts @@ -1,12 +1,11 @@ import { completePrompt } from '../models'; import { getTransformInstr } from '../service/transformPage'; import { parseBuilderResponse } from './anthropic'; -import { Builder, BuilderResult } from './types'; +import { Builder, BuilderResult, OPENAI_CHANGE_OPS_SCHEMA } from './types'; /** * Create an OpenAI-tuned builder. - * Currently identical to the Anthropic builder — separate file enables - * future per-provider tuning. + * Uses OpenAI structured outputs (json_schema) for reliable JSON responses. */ export function createOpenAIBuilder(complete: completePrompt, userInstructions?: string, productName?: string): Builder { const name = productName ?? 'SynthOS'; @@ -36,7 +35,6 @@ export function createOpenAIBuilder(complete: completePrompt, userInstructions?: } } instructionParts.push(getTransformInstr(name)); - instructionParts.push(`Return ONLY the JSON array of change operations. Do not wrap it in markdown code fences or add any other text.`); const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); const promptContent = `\n${userMessage}\n\n\n${instructions}`; @@ -44,16 +42,35 @@ export function createOpenAIBuilder(complete: completePrompt, userInstructions?: 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') }; } - return parseBuilderResponse(result.value!); + // 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 index 04b517e..68b0686 100644 --- a/src/builders/types.ts +++ b/src/builders/types.ts @@ -1,5 +1,165 @@ 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 // --------------------------------------------------------------------------- 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 2402860..72745cc 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -663,45 +663,7 @@ Do not add duplicate script blocks with the same logic! Consolidate inline scrip Each element in the CURRENT_PAGE has a data-node-id attribute. Don't use the id attribute for targeting nodes (reserve it for scripts and styles) — use data-node-id. If you're trying to assign an id to script or style block, use "replace" not "update". -Your first operation should always be an update to your thoughts block, where you can reason through the user's request and plan your changes before applying them to the page. -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
    " } -]`; +Your first operation should always be an update to your thoughts block, where you can reason through the user's request and plan your changes before applying them to the page.`; } export const AGENT_API_REFERENCE =