@avocadostudio-ai/orchestrator-core 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/NOTICE +8 -0
  2. package/dist/agent/site-theme-resolve.d.ts +62 -0
  3. package/dist/agent/site-theme-resolve.js +260 -0
  4. package/dist/agent/sites-agent-context.js +19 -6
  5. package/dist/agent/sites-agent-shared.d.ts +13 -1
  6. package/dist/agent/sites-agent-shared.js +155 -34
  7. package/dist/agent/sites-agent-tools.js +54 -8
  8. package/dist/agent/stock-photos.d.ts +54 -0
  9. package/dist/agent/stock-photos.js +75 -0
  10. package/dist/chat/anthropic-planner.js +1 -0
  11. package/dist/chat/chat-pipeline-context.d.ts +8 -14
  12. package/dist/chat/chat-pipeline-context.js +10 -2
  13. package/dist/chat/chat-pipeline-deterministic.js +28 -3
  14. package/dist/chat/chat-pipeline-translation.d.ts +2 -0
  15. package/dist/chat/chat-pipeline-translation.js +7 -3
  16. package/dist/chat/chat-pipeline.d.ts +32 -4
  17. package/dist/chat/chat-pipeline.js +472 -220
  18. package/dist/chat/gemini-planner.js +3 -1
  19. package/dist/chat/locale-strings.d.ts +10 -0
  20. package/dist/chat/locale-strings.js +21 -0
  21. package/dist/chat/page-field-scope.d.ts +23 -0
  22. package/dist/chat/page-field-scope.js +87 -0
  23. package/dist/chat/plan-json-schema.d.ts +24 -1
  24. package/dist/chat/plan-json-schema.js +25 -2
  25. package/dist/chat/planner.d.ts +1 -1
  26. package/dist/chat/planner.js +4 -2
  27. package/dist/chat/prompts.d.ts +11 -0
  28. package/dist/chat/prompts.js +39 -6
  29. package/dist/chat/site-locales.d.ts +87 -0
  30. package/dist/chat/site-locales.js +250 -0
  31. package/dist/cms/bootstrap.d.ts +10 -0
  32. package/dist/cms/bootstrap.js +9 -0
  33. package/dist/cms/editor-api-adapter.js +2 -1
  34. package/dist/cms/json-file-adapter.js +2 -1
  35. package/dist/demo-mode.d.ts +13 -0
  36. package/dist/demo-mode.js +69 -1
  37. package/dist/handler/create-orchestrator.js +122 -37
  38. package/dist/http/draft-sync-actions.d.ts +85 -0
  39. package/dist/http/draft-sync-actions.js +118 -0
  40. package/dist/http/history-actions.d.ts +9 -1
  41. package/dist/http/history-actions.js +32 -7
  42. package/dist/http/ops-actions.d.ts +2 -1
  43. package/dist/http/ops-actions.js +3 -0
  44. package/dist/http/publish-actions.d.ts +1 -0
  45. package/dist/http/publish-actions.js +9 -2
  46. package/dist/http/suggestions-actions.d.ts +18 -0
  47. package/dist/http/suggestions-actions.js +44 -0
  48. package/dist/migration/mcp-server-stdio.js +46 -35
  49. package/dist/nlp/deterministic-planner-context.d.ts +31 -8
  50. package/dist/nlp/deterministic-planner-context.js +40 -2
  51. package/dist/nlp/deterministic-planner-patches.js +5 -0
  52. package/dist/nlp/deterministic-planner.d.ts +1 -1
  53. package/dist/nlp/deterministic-planner.js +10 -0
  54. package/dist/nlp/intent-detection.d.ts +7 -0
  55. package/dist/nlp/intent-detection.js +1 -0
  56. package/dist/nlp/plan-normalizer.js +44 -0
  57. package/dist/nlp/suggestion-engine.d.ts +44 -3
  58. package/dist/nlp/suggestion-engine.js +150 -15
  59. package/dist/ops/ops-engine.d.ts +25 -0
  60. package/dist/ops/ops-engine.js +271 -13
  61. package/dist/publish/diff-engine.d.ts +6 -0
  62. package/dist/publish/diff-engine.js +99 -2
  63. package/dist/publish/publish-selection.d.ts +5 -0
  64. package/dist/publish/publish-selection.js +45 -0
  65. package/dist/publish/publish-target.d.ts +17 -0
  66. package/dist/publish/publish-target.js +17 -1
  67. package/dist/publish/targets/site-contract.js +35 -2
  68. package/dist/state/draft-sync.d.ts +48 -0
  69. package/dist/state/draft-sync.js +86 -0
  70. package/dist/state/in-memory-content-source.d.ts +2 -0
  71. package/dist/state/incoming-pages.d.ts +36 -0
  72. package/dist/state/incoming-pages.js +74 -0
  73. package/dist/state/session-state.d.ts +30 -0
  74. package/dist/state/session-state.js +74 -0
  75. package/dist/state/shared-blocks.d.ts +91 -0
  76. package/dist/state/shared-blocks.js +185 -0
  77. package/dist/state/sqlite-store.js +13 -1
  78. package/package.json +6 -4
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { consoleLogger } from "../logger.js";
8
8
  import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
9
- import { existsSync } from "node:fs";
9
+ import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
10
11
  import { resolve, join, dirname } from "node:path";
11
12
  import { getAllBlockMeta, defaultPropsForType, validateBlockProps } from "@avocadostudio-ai/shared";
12
13
  // ── Utility helpers ──
@@ -190,7 +191,7 @@ export const THEME_PRESETS = [
190
191
  * can copy them into themeOverrides.
191
192
  */
192
193
  export function buildThemePresetsCatalog() {
193
- const lines = ["### Theme Presets\nPick the preset that best matches the site's purpose and tone. Pass its `overrides` as `themeOverrides` in `bootstrap_pages`. You may tweak individual values (e.g. swap brand color) while keeping the rest of the palette.\n"];
194
+ const lines = ["### Theme Presets\nPick the preset that best matches the site's purpose and tone and pass its name as `themePreset` in `bootstrap_pages`. The variables below show what each one sets and are the token names to use in `themeOverrides` for a tweak on top (e.g. swap `--brand`).\n"];
194
195
  for (const preset of THEME_PRESETS) {
195
196
  const overridesJson = JSON.stringify(preset.overrides, null, 2);
196
197
  lines.push(`#### ${preset.name}\n${preset.description}\n\`\`\`json\n${overridesJson}\n\`\`\``);
@@ -198,7 +199,7 @@ export function buildThemePresetsCatalog() {
198
199
  lines.push(`Google Fonts: \`${preset.googleFontsUrl}\`\n`);
199
200
  }
200
201
  }
201
- lines.push(`\n**Custom tweaks are encouraged.** If the user says "nature site but teal", start from \`forest\` and swap \`--brand\` to teal. Always keep hover/subtle/fg consistent with the brand hue.`);
202
+ lines.push(`\n**Custom tweaks are encouraged.** If the user says "nature site but teal", pass \`themePreset: "forest"\` and \`themeOverrides: { "--brand": "<teal>" }\`; hover, subtle and foreground follow the brand automatically.`);
202
203
  return lines.join("\n");
203
204
  }
204
205
  // ── Sites agent model tiers ──
@@ -269,6 +270,42 @@ Extract URLs, site names, and scope constraints from the message. If not present
269
270
  }
270
271
  }
271
272
  // ── CSS variable persistence ──
273
+ /*
274
+ * The page itself, painted from the theme. Without it `body` is transparent
275
+ * and any section that does not paint its own background shows the browser's
276
+ * white — invisible on a light theme, a white band down a dark one.
277
+ */
278
+ const PAGE_CANVAS_MARKER = "/* page canvas */";
279
+ const PAGE_CANVAS_RULE = `${PAGE_CANVAS_MARKER}
280
+ html,
281
+ body {
282
+ background: var(--bg-0);
283
+ color: var(--body);
284
+ }`;
285
+ /**
286
+ * A theme that names a web font without importing it renders in the fallback,
287
+ * and nothing says so. Build the import for any family not already loaded.
288
+ * Weighted first, checked against Google, because one family that lacks a
289
+ * requested weight fails the whole stylesheet; unweighted if that is refused
290
+ * or the network is unavailable.
291
+ */
292
+ async function importForNamedWebFonts(vars, css) {
293
+ const { webFontFamily, googleFontsUrlFor } = await import("./site-theme-resolve.js");
294
+ const families = [vars["--font-heading"], vars["--font-body"]]
295
+ .map(webFontFamily)
296
+ .filter((f) => f !== null)
297
+ .filter((f) => !css.includes(`family=${f.replace(/ /g, "+")}`));
298
+ if (families.length === 0)
299
+ return undefined;
300
+ const weighted = googleFontsUrlFor(families, true);
301
+ try {
302
+ const res = await fetch(weighted, { signal: AbortSignal.timeout(3_000) });
303
+ if (res.ok)
304
+ return weighted;
305
+ }
306
+ catch { /* offline — fall through */ }
307
+ return googleFontsUrlFor(families, false);
308
+ }
272
309
  export async function patchGlobalsCssVars(cssPath, vars) {
273
310
  if (!existsSync(cssPath))
274
311
  return;
@@ -280,19 +317,17 @@ export async function patchGlobalsCssVars(cssPath, vars) {
280
317
  css = css.replace(/\.dark\s*\{[^}]*\}/g, "");
281
318
  css = css.replace(/\n{3,}/g, "\n\n"); // collapse leftover blank lines
282
319
  // Handle Google Fonts import — inject @import url(...) after @import "tailwindcss"
283
- const googleFontsUrl = vars["--google-fonts-import"];
320
+ let googleFontsUrl = vars["--google-fonts-import"];
321
+ if (!googleFontsUrl)
322
+ googleFontsUrl = await importForNamedWebFonts(vars, css);
284
323
  if (googleFontsUrl) {
285
324
  // Remove existing Google Fonts imports to avoid duplicates
286
325
  css = css.replace(/@import\s+url\(["']https:\/\/fonts\.googleapis\.com[^)]*\);\s*\n?/g, "");
287
- // Insert after the last @import line
288
- const lastImportIdx = css.lastIndexOf("@import");
289
- if (lastImportIdx >= 0) {
290
- const lineEnd = css.indexOf("\n", lastImportIdx);
291
- css = css.slice(0, lineEnd + 1) + `@import url("${googleFontsUrl}");\n` + css.slice(lineEnd + 1);
292
- }
293
- else {
294
- css = `@import url("${googleFontsUrl}");\n` + css;
295
- }
326
+ // First line of the file, never after `@import "tailwindcss"`. Tailwind
327
+ // inlines its imports, so anything below them compiles to an @import
328
+ // after thousands of lines of rules — which browsers ignore. Every theme
329
+ // font this agent chose rendered in the fallback for exactly that reason.
330
+ css = `@import url("${googleFontsUrl}");\n` + css;
296
331
  }
297
332
  for (const [prop, value] of Object.entries(vars)) {
298
333
  if (prop === "--google-fonts-import")
@@ -306,7 +341,35 @@ export async function patchGlobalsCssVars(cssPath, vars) {
306
341
  css = css.replace(/(:root\s*\{[^}]*)(\})/, `$1 ${prop}: ${value};\n$2`);
307
342
  }
308
343
  }
344
+ if (!css.includes(PAGE_CANVAS_MARKER))
345
+ css = `${css.trimEnd()}\n\n${PAGE_CANVAS_RULE}\n`;
309
346
  await writeFile(cssPath, css, "utf-8");
347
+ if (vars["--brand"])
348
+ await recolorGeneratedMonograms(join(dirname(cssPath), "..", "public"), vars["--brand"], vars["--brand-fg"]);
349
+ }
350
+ /**
351
+ * The scaffold's logo and favicon are monogram SVGs shown through `<img>`,
352
+ * which cannot see the page's CSS variables — so `fill="var(--brand, …)"`
353
+ * always rendered its fallback blue, whatever brand the theme chose. Bake the
354
+ * brand in. Only a generated monogram (a rect plus initials) is touched; a
355
+ * downloaded or hand-made logo is left alone.
356
+ */
357
+ async function recolorGeneratedMonograms(publicDir, brand, fg) {
358
+ if (!/^#[0-9a-f]{3,8}$/i.test(brand.trim()))
359
+ return;
360
+ for (const file of ["logo.svg", "favicon.svg"]) {
361
+ const path = join(publicDir, file);
362
+ if (!existsSync(path))
363
+ continue;
364
+ const svg = await readFile(path, "utf-8");
365
+ if (!/^<svg[^>]*>\s*<rect[^>]*\/>\s*<text[^>]*>[A-Za-z0-9]{1,3}<\/text>\s*<\/svg>\s*$/.test(svg))
366
+ continue;
367
+ let next = svg.replace(/(<rect[^>]*fill=")[^"]*(")/, `$1${brand.trim()}$2`);
368
+ if (fg && /^#[0-9a-f]{3,8}$/i.test(fg.trim()))
369
+ next = next.replace(/(<text[^>]*fill=")[^"]*(")/, `$1${fg.trim()}$2`);
370
+ if (next !== svg)
371
+ await writeFile(path, next, "utf-8");
372
+ }
310
373
  }
311
374
  // ── Schema validation + auto-correction ──
312
375
  const PROP_RENAME_RULES = {
@@ -441,7 +504,30 @@ export function normalizePageBlocks(blocks, log = consoleLogger()) {
441
504
  return { contentBlocks, footerBlock, strippedCount };
442
505
  }
443
506
  // ── Site scaffolding templates ──
444
- export function packageJson(siteId, name, port) {
507
+ /**
508
+ * The zod a new site must use: exactly the one `@avocadostudio-ai/shared`
509
+ * resolves. Zod 4 brands its minor version into its types, so a site on
510
+ * another minor makes every `registerBlock` schema a cross-version type
511
+ * comparison. With `^4.3.6` a fresh site resolved 4.6.4 against shared's
512
+ * 4.3.6, and `tsc` on it ran out of heap — which failed the repo's
513
+ * typecheck, and the agent's own verification of every custom block.
514
+ */
515
+ /** What a dev server or a typecheck leaves behind — never a project on its own. */
516
+ const BUILD_LEFTOVERS = new Set([".next", "node_modules", "tsconfig.tsbuildinfo", "next-env.d.ts", ".turbo"]);
517
+ /** An existing site project to reuse, as opposed to a leftover directory. */
518
+ export function isSiteProject(projectDir) {
519
+ return existsSync(join(projectDir, "package.json"));
520
+ }
521
+ export function sharedZodVersion(root = monorepoRoot()) {
522
+ try {
523
+ const pkg = JSON.parse(readFileSync(join(root, "packages/shared/node_modules/zod/package.json"), "utf-8"));
524
+ if (pkg.version)
525
+ return pkg.version;
526
+ }
527
+ catch { /* not installed here — fall back */ }
528
+ return "4.3.6";
529
+ }
530
+ export function packageJson(siteId, name, port, zodVersion = sharedZodVersion()) {
445
531
  return JSON.stringify({
446
532
  name: `@ai-site-editor/${siteId}`,
447
533
  version: "0.0.1",
@@ -450,7 +536,7 @@ export function packageJson(siteId, name, port) {
450
536
  scripts: { dev: `next dev -p ${port}`, build: "next build", start: `next start -p ${port}`, typecheck: "tsc --noEmit" },
451
537
  dependencies: {
452
538
  "@avocadostudio-ai/blocks": "workspace:*", "@avocadostudio-ai/shared": "workspace:*", "@avocadostudio-ai/site-sdk": "workspace:*",
453
- "@tailwindcss/postcss": "^4.2.1", next: "15.2.8", react: "^19.0.0", "react-dom": "^19.0.0", tailwindcss: "^4.2.1", zod: "^4.3.6",
539
+ "@tailwindcss/postcss": "^4.2.1", next: "15.2.8", react: "^19.0.0", "react-dom": "^19.0.0", tailwindcss: "^4.2.1", zod: zodVersion,
454
540
  },
455
541
  devDependencies: { "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", typescript: "^5.7.3" },
456
542
  }, null, 2) + "\n";
@@ -599,6 +685,8 @@ export function globalsCss() {
599
685
  --surface: #1e293b; --surface-border: #334155; --border: #334155; --card-bg: #1e293b; --card-shadow: 0 1px 3px rgba(0,0,0,0.3); --hero-bg: var(--bg-0); --cta-bg: var(--bg-100); --placeholder-img: #334155;
600
686
  --footer-bg: #020617; --footer-text: #94a3b8; --footer-heading: #e2e8f0; --footer-link: #64748b; --footer-link-hover: #cbd5e1; --footer-border: #1e293b;
601
687
  }
688
+
689
+ ${PAGE_CANVAS_RULE}
602
690
  `;
603
691
  }
604
692
  export function defaultsTs(siteId, siteName) {
@@ -812,7 +900,18 @@ export async function scaffoldSiteProject(options) {
812
900
  const root = monorepoRoot();
813
901
  const projectDir = join(root, "apps", siteId);
814
902
  if (existsSync(projectDir)) {
815
- throw new Error(`Directory apps/${siteId} already exists`);
903
+ // A directory holding only build output is what a dev server leaves when
904
+ // it outlives its site being deleted: a worker writes .next back. It is
905
+ // not a project, and refusing it made create_site fail until the agent
906
+ // rm -rf'd it by hand.
907
+ const entries = await readdir(projectDir);
908
+ if (entries.every((e) => BUILD_LEFTOVERS.has(e))) {
909
+ const { rm } = await import("node:fs/promises");
910
+ await rm(projectDir, { recursive: true, force: true });
911
+ }
912
+ else {
913
+ throw new Error(`Directory apps/${siteId} already exists`);
914
+ }
816
915
  }
817
916
  const port = options.port || await findAvailablePort(root);
818
917
  await mkdir(projectDir, { recursive: true });
@@ -1042,6 +1141,14 @@ export async function detectSitePort(projectDir) {
1042
1141
  /**
1043
1142
  * Start a Next.js dev server and wait for it to report "Ready" (or timeout).
1044
1143
  * Replaces fire-and-forget spawn patterns that reported success before the server was up.
1144
+ *
1145
+ * The server writes to a log file, never to a pipe back to this process. The
1146
+ * server is detached so it outlives its launcher, but a pipe does not: in CLI
1147
+ * mode the launcher is the short-lived MCP stdio server the Claude CLI spawns,
1148
+ * and once it exited nobody drained the pipe. The server kept listening and
1149
+ * hung on its first request, so the agent reported "Site running at :3500"
1150
+ * over a site that never answered. An orchestrator restart did the same to
1151
+ * every site the SDK path had started.
1045
1152
  */
1046
1153
  export async function startAndWaitForDevServer(opts) {
1047
1154
  const log = opts.logger ?? consoleLogger();
@@ -1049,10 +1156,12 @@ export async function startAndWaitForDevServer(opts) {
1049
1156
  const args = opts.useFilter
1050
1157
  ? ["--filter", `@ai-site-editor/${opts.siteId}`, "dev"]
1051
1158
  : ["dev"];
1052
- // Kill any existing process on the port first
1159
+ // Kill whatever is listening on the port first. Listeners only: a bare
1160
+ // `lsof -ti:<port>` also names every client connected to it, which can be
1161
+ // the orchestrator itself.
1053
1162
  try {
1054
1163
  const { execSync } = await import("node:child_process");
1055
- const pids = execSync(`lsof -ti:${opts.port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1164
+ const pids = execSync(`lsof -ti:${opts.port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
1056
1165
  if (pids) {
1057
1166
  for (const pid of pids.split("\n")) {
1058
1167
  try {
@@ -1064,10 +1173,14 @@ export async function startAndWaitForDevServer(opts) {
1064
1173
  }
1065
1174
  }
1066
1175
  catch { /* no process on port — fine */ }
1067
- log.info(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port}...`);
1176
+ const logDir = join(tmpdir(), "avocado-dev-servers");
1177
+ await mkdir(logDir, { recursive: true });
1178
+ const logFile = join(logDir, `${opts.siteId}.log`);
1179
+ const out = openSync(logFile, "w");
1180
+ log.info(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port} (log: ${logFile})...`);
1068
1181
  const devProcess = spawn("pnpm", args, {
1069
1182
  cwd: opts.cwd,
1070
- stdio: ["ignore", "pipe", "pipe"],
1183
+ stdio: ["ignore", out, out],
1071
1184
  detached: true,
1072
1185
  env: {
1073
1186
  ...process.env,
@@ -1080,25 +1193,33 @@ export async function startAndWaitForDevServer(opts) {
1080
1193
  CHOKIDAR_INTERVAL: "1000",
1081
1194
  },
1082
1195
  });
1196
+ // The child holds its own copy of the descriptor.
1197
+ closeSync(out);
1083
1198
  devProcess.unref();
1084
1199
  let serverReady = false;
1085
1200
  const timeoutMs = opts.timeoutMs ?? 60_000;
1086
1201
  await new Promise((resolve) => {
1087
- const timer = setTimeout(() => resolve(), timeoutMs);
1088
- const onData = (chunk) => {
1089
- const text = chunk.toString();
1090
- if (text.includes("Ready") || text.includes(`localhost:${opts.port}`)) {
1091
- serverReady = true;
1092
- clearTimeout(timer);
1093
- devProcess.stdout?.removeAllListeners();
1094
- devProcess.stderr?.removeAllListeners();
1095
- resolve();
1096
- }
1202
+ let settled = false;
1203
+ const finish = () => {
1204
+ if (settled)
1205
+ return;
1206
+ settled = true;
1207
+ clearTimeout(timer);
1208
+ clearInterval(poll);
1209
+ devProcess.off("exit", finish);
1210
+ resolve();
1097
1211
  };
1098
- devProcess.stdout?.on("data", onData);
1099
- devProcess.stderr?.on("data", onData);
1100
- devProcess.on("exit", () => { clearTimeout(timer); resolve(); });
1212
+ const timer = setTimeout(finish, timeoutMs);
1213
+ const poll = setInterval(() => {
1214
+ readFile(logFile, "utf-8").then((text) => {
1215
+ if (text.includes("Ready") || text.includes(`localhost:${opts.port}`)) {
1216
+ serverReady = true;
1217
+ finish();
1218
+ }
1219
+ }, () => { });
1220
+ }, 250);
1221
+ devProcess.on("exit", finish);
1101
1222
  });
1102
1223
  log.info(`[sites-agent] Dev server ${serverReady ? "ready" : "timed out"} for ${opts.siteId} at http://localhost:${opts.port}`);
1103
- return { serverReady };
1224
+ return { serverReady, logFile };
1104
1225
  }
@@ -19,7 +19,9 @@ import { saveScreenshot } from "../migration/migration-tools.js";
19
19
  import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig } from "../state/session-state.js";
20
20
  import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
21
21
  import sharp from "sharp";
22
- import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
22
+ import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, isSiteProject, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
23
+ import { findStockPhotos } from "./stock-photos.js";
24
+ import { resolveThemeVars, normalizeSiteSlug, normalizeNavLabels, normalizeNavGroups, THEME_PRESET_NAMES } from "./site-theme-resolve.js";
23
25
  /** Convert a package name like "coastal-villa-web" → "Coastal Villa Web" */
24
26
  function humanizePkgName(name) {
25
27
  return name
@@ -134,7 +136,7 @@ export function createSitesAgentMcpServer(options) {
134
136
  const root = monorepoRoot();
135
137
  const projectDir = join(root, "apps", siteId);
136
138
  let existingPort = 0;
137
- if (existsSync(projectDir)) {
139
+ if (isSiteProject(projectDir)) {
138
140
  // Clean content from previous migration but keep project skeleton (package.json, node_modules, etc.)
139
141
  const { rm } = await import("node:fs/promises");
140
142
  for (const dir of ["content", "blocks", "public/images", ".next"]) {
@@ -154,6 +156,13 @@ export function createSitesAgentMcpServer(options) {
154
156
  catch { /* use new port */ }
155
157
  }
156
158
  }
159
+ else if (existsSync(join(projectDir, ".next"))) {
160
+ // Not a project, just build output a dev server wrote back after its
161
+ // site was deleted. A stale .next under a fresh scaffold serves the
162
+ // old site's chunks.
163
+ const { rm } = await import("node:fs/promises");
164
+ await rm(join(projectDir, ".next"), { recursive: true, force: true });
165
+ }
157
166
  // Validate requested/existing port is actually free; fall back to auto-assign
158
167
  let port = args.port || existingPort || 0;
159
168
  if (port) {
@@ -387,7 +396,8 @@ export function createSitesAgentMcpServer(options) {
387
396
  ogImage: z.string().optional().describe("Open Graph image URL"),
388
397
  }).optional().describe("SEO metadata for the page"),
389
398
  })),
390
- themeOverrides: z.record(z.string(), z.string()).optional().describe("CSS variable overrides for theming"),
399
+ themePreset: z.enum(THEME_PRESET_NAMES).optional().describe("Name of a theme preset to apply in full (palette, fonts and their import, radii). Prefer this over copying a preset's variables; use themeOverrides only for tweaks on top, e.g. a different --brand."),
400
+ themeOverrides: z.record(z.string(), z.string()).optional().describe("CSS variable overrides for theming, applied over themePreset. Use the preset token names (--bg-0, --heading, --body, --brand, --font-heading, …); hover/subtle shades and dark-background text are derived."),
391
401
  navLabels: z.record(z.string(), z.string()).optional().describe("Custom nav labels per slug, e.g. { '/about': 'Über uns', '/events/teamevent': 'Teamevent' }. Overrides auto-generated labels."),
392
402
  navGroups: z.record(z.string(), z.array(z.string())).optional().describe("Group child pages under a parent nav dropdown, e.g. { 'Events': ['/events/teamevent', '/events/polterabend'] }. Parent has no href, children shown in dropdown."),
393
403
  siteLogo: z.string().optional().describe("Logo URL (relative, e.g. '/images/logo.png'). Set after downloading with download_remote_image."),
@@ -397,6 +407,18 @@ export function createSitesAgentMcpServer(options) {
397
407
  constraints: z.array(z.string()).optional().describe("Content rules the AI must follow, e.g. ['Always use Swiss German spelling', 'Never discount below CHF 49']"),
398
408
  }, async (args) => {
399
409
  try {
410
+ // Normalize what the agent wrote before anything reads it: `index` is
411
+ // the home page, nav keys are routes, and theme values are resolved to
412
+ // the tokens blocks actually read (see site-theme-resolve.ts).
413
+ for (const page of args.pages)
414
+ page.slug = normalizeSiteSlug(page.slug);
415
+ args.navLabels = normalizeNavLabels(args.navLabels);
416
+ args.navGroups = normalizeNavGroups(args.navGroups);
417
+ const theme = args.themePreset || (args.themeOverrides && Object.keys(args.themeOverrides).length > 0)
418
+ ? resolveThemeVars(args.themeOverrides, { preset: args.themePreset })
419
+ : null;
420
+ if (theme)
421
+ args.themeOverrides = theme.vars;
400
422
  const totalBlocks = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
401
423
  const slugs = args.pages.map(p => p.slug);
402
424
  log.info(`[bootstrap_pages] START siteId=${args.siteId} pages=${args.pages.length} blocks=${totalBlocks} slugs=[${slugs.join(", ")}]`);
@@ -594,6 +616,7 @@ export function createSitesAgentMcpServer(options) {
594
616
  pagesCreated: createdPages,
595
617
  totalBlocks: totalBlocksFinal,
596
618
  persistedToFile: true,
619
+ ...(theme?.notes.length ? { themeNotes: theme.notes } : {}),
597
620
  }),
598
621
  }],
599
622
  };
@@ -624,6 +647,26 @@ export function createSitesAgentMcpServer(options) {
624
647
  return { content: [{ type: "text", text: JSON.stringify({ localUrl: args.url, error: "Download failed, using original URL" }) }] };
625
648
  }
626
649
  }, { annotations: { destructiveHint: false, openWorldHint: true } });
650
+ // ── STOCK PHOTOS ──
651
+ const findStockPhotosTool = tool("find_stock_photos", "Find stock photos by search query and download them into the site, in one call. Use this for every photo on a new site unless the user supplied images: never pass download_remote_images a URL you did not get from a tool — an Unsplash URL from memory resolves to an unrelated photo. Queries are keyword searches: two to four concrete words naming the subject (\"pediatric dentist child\", \"dental clinic interior\"); long descriptive phrases find nothing. Each result carries the photo's own description: if it does not match the use, search again with a different query rather than use it.", {
652
+ siteId: z.string().describe("Site ID — photos saved to apps/{siteId}/public/images/"),
653
+ photos: z.array(z.object({
654
+ query: z.string().min(2).describe("Two to four keywords naming the subject, e.g. \"dentist child smiling\""),
655
+ alt: z.string().optional().describe("Alt text for the page"),
656
+ orientation: z.enum(["landscape", "portrait", "squarish"]).optional().describe("landscape (default) for heroes and wide cards, squarish for team portraits"),
657
+ })).min(1).max(20),
658
+ }, async (args) => {
659
+ const outputDir = join(monorepoRoot(), "apps", args.siteId, "public", "images");
660
+ await mkdir(outputDir, { recursive: true });
661
+ const results = await findStockPhotos({
662
+ siteId: args.siteId, photos: args.photos, outputDir, download: downloadImage,
663
+ accessKey: process.env.UNSPLASH_ACCESS_KEY?.trim() || undefined,
664
+ orchestratorUrl: process.env.ORCHESTRATOR_URL,
665
+ });
666
+ const succeeded = results.filter((r) => r.localUrl).length;
667
+ emitPhaseOutcome?.({ tool: "find_stock_photos", data: { succeeded, total: results.length } });
668
+ return { content: [{ type: "text", text: JSON.stringify({ results, succeeded, failed: results.length - succeeded }) }] };
669
+ }, { annotations: { destructiveHint: false, openWorldHint: true } });
627
670
  // ── BATCH DOWNLOAD IMAGES ──
628
671
  const downloadImagesTool = tool("download_remote_images", "Download multiple remote images in one call. Much more efficient than calling download_remote_image repeatedly — use this when you have 3+ images to download. Returns an array of { url, localUrl } mappings.", {
629
672
  siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
@@ -730,7 +773,10 @@ export function createSitesAgentMcpServer(options) {
730
773
  const applyThemeTool = tool("apply_theme", "Apply CSS custom property overrides to a site's theme. These are injected into the preview so blocks render with the migrated site's colors and fonts.", {
731
774
  siteId: z.string().describe("Site ID to apply theme to"),
732
775
  variables: z.record(z.string(), z.string()).describe("CSS variable overrides, e.g. { '--brand': '#2563eb' }"),
776
+ preset: z.enum(THEME_PRESET_NAMES).optional().describe("Theme preset to apply first; variables override it."),
733
777
  }, async (args) => {
778
+ const theme = resolveThemeVars(args.variables, { preset: args.preset });
779
+ args.variables = theme.vars;
734
780
  const sessionKey = scopedSessionKey(session, args.siteId);
735
781
  const existing = getSiteConfig(sessionKey);
736
782
  setSiteConfig(sessionKey, {
@@ -747,7 +793,7 @@ export function createSitesAgentMcpServer(options) {
747
793
  return {
748
794
  content: [{
749
795
  type: "text",
750
- text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true }),
796
+ text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true, ...(theme.notes.length ? { themeNotes: theme.notes } : {}) }),
751
797
  }],
752
798
  };
753
799
  }, { annotations: { destructiveHint: false, openWorldHint: false } });
@@ -979,7 +1025,7 @@ export function createSitesAgentMcpServer(options) {
979
1025
  // Check if port is in use — find a free one
980
1026
  try {
981
1027
  const { execSync } = await import("node:child_process");
982
- const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1028
+ const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
983
1029
  if (pids) {
984
1030
  log.info(`[integrate_site] Port ${port} in use, finding free port...`);
985
1031
  port = await findAvailablePort(root, log);
@@ -1050,7 +1096,7 @@ export function createSitesAgentMcpServer(options) {
1050
1096
  // Check if port is in use — if so, find a free one and update package.json
1051
1097
  try {
1052
1098
  const { execSync } = await import("node:child_process");
1053
- const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1099
+ const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
1054
1100
  if (pids) {
1055
1101
  log.info(`[sites-agent] Port ${port} in use, finding free port...`);
1056
1102
  port = await findAvailablePort(root, log);
@@ -1121,7 +1167,7 @@ export function createSitesAgentMcpServer(options) {
1121
1167
  // Kill any existing process on the allocated port
1122
1168
  try {
1123
1169
  const { execSync } = await import("node:child_process");
1124
- const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1170
+ const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
1125
1171
  if (pids) {
1126
1172
  for (const pid of pids.split("\n")) {
1127
1173
  try {
@@ -1227,7 +1273,7 @@ export function createSitesAgentMcpServer(options) {
1227
1273
  return createSdkMcpServer({
1228
1274
  name: "sites-agent",
1229
1275
  version: "1.0.0",
1230
- tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
1276
+ tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, findStockPhotosTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
1231
1277
  });
1232
1278
  }
1233
1279
  // Template functions now live in sites-agent-shared.ts — imported at the top of this file.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Stock photography for a site built from a sentence.
3
+ *
4
+ * Create mode had no way to find a photo: the prompt said "use one shared
5
+ * style phrase in every image search", but no tool searched anything, so the
6
+ * agent handed `download_remote_images` Unsplash URLs recalled from memory.
7
+ * Those resolve to whatever photo now owns the id. A children's dental
8
+ * practice got vintage cars, a sparkler and a truck dashboard as its team.
9
+ *
10
+ * This searches first and downloads what the search returned, and hands back
11
+ * each photo's own description so the agent can see a mismatch rather than
12
+ * trust a URL it made up.
13
+ */
14
+ export type StockPhotoRequest = {
15
+ query: string;
16
+ alt?: string;
17
+ orientation?: "landscape" | "portrait" | "squarish";
18
+ };
19
+ export type StockPhotoResult = {
20
+ query: string;
21
+ localUrl?: string;
22
+ /** What the photo shows, from Unsplash — check it matches the use. */
23
+ description?: string;
24
+ author?: string;
25
+ error?: string;
26
+ };
27
+ type Candidate = {
28
+ id: string;
29
+ url: string;
30
+ description: string;
31
+ author: string;
32
+ };
33
+ type SearchDeps = {
34
+ fetchImpl?: typeof fetch;
35
+ /** Direct Unsplash access; without it the orchestrator's proxy is asked. */
36
+ accessKey?: string;
37
+ orchestratorUrl?: string;
38
+ };
39
+ export declare function searchStockPhotos(req: StockPhotoRequest, deps?: SearchDeps): Promise<Candidate[]>;
40
+ /**
41
+ * Unsplash matches keywords: "pediatric dentist with child patient, bright
42
+ * clinic" finds nothing, "pediatric dentist" finds hundreds. Try the query as
43
+ * given, then its first clause, then its first three and two words.
44
+ */
45
+ export declare function queryFallbacks(query: string): string[];
46
+ export declare function findStockPhotos(opts: {
47
+ siteId: string;
48
+ photos: StockPhotoRequest[];
49
+ outputDir: string;
50
+ download: (url: string, alt: string | undefined, outputDir: string) => Promise<{
51
+ fileName: string;
52
+ }>;
53
+ } & SearchDeps): Promise<StockPhotoResult[]>;
54
+ export {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Stock photography for a site built from a sentence.
3
+ *
4
+ * Create mode had no way to find a photo: the prompt said "use one shared
5
+ * style phrase in every image search", but no tool searched anything, so the
6
+ * agent handed `download_remote_images` Unsplash URLs recalled from memory.
7
+ * Those resolve to whatever photo now owns the id. A children's dental
8
+ * practice got vintage cars, a sparkler and a truck dashboard as its team.
9
+ *
10
+ * This searches first and downloads what the search returned, and hands back
11
+ * each photo's own description so the agent can see a mismatch rather than
12
+ * trust a URL it made up.
13
+ */
14
+ export async function searchStockPhotos(req, deps = {}) {
15
+ const fetchImpl = deps.fetchImpl ?? fetch;
16
+ const q = encodeURIComponent(req.query.trim());
17
+ if (deps.accessKey) {
18
+ const res = await fetchImpl(`https://api.unsplash.com/search/photos?query=${q}&orientation=${req.orientation ?? "landscape"}&per_page=10&content_filter=high`, { headers: { Authorization: `Client-ID ${deps.accessKey}`, "Accept-Version": "v1" }, signal: AbortSignal.timeout(10_000) });
19
+ if (!res.ok)
20
+ throw new Error(`Unsplash search answered ${res.status}`);
21
+ const payload = (await res.json());
22
+ return (payload.results ?? [])
23
+ .filter((r) => r.id && r.urls?.regular)
24
+ .map((r) => ({ id: r.id, url: r.urls.regular, description: r.alt_description ?? r.description ?? "", author: r.user?.name ?? "Unsplash" }));
25
+ }
26
+ const base = deps.orchestratorUrl ?? "http://localhost:4200";
27
+ const res = await fetchImpl(`${base}/unsplash/search?q=${q}&limit=10`, { signal: AbortSignal.timeout(10_000) });
28
+ if (!res.ok)
29
+ throw new Error(res.status === 404 ? "stock photo search is not configured (UNSPLASH_ACCESS_KEY)" : `photo search answered ${res.status}`);
30
+ const payload = (await res.json());
31
+ return (payload.items ?? []).map((i) => ({ id: i.id, url: i.imageUrl, description: i.alt ?? "", author: i.author ?? "Unsplash" }));
32
+ }
33
+ /**
34
+ * Unsplash matches keywords: "pediatric dentist with child patient, bright
35
+ * clinic" finds nothing, "pediatric dentist" finds hundreds. Try the query as
36
+ * given, then its first clause, then its first three and two words.
37
+ */
38
+ export function queryFallbacks(query) {
39
+ const full = query.trim().replace(/\s+/g, " ");
40
+ const clause = full.split(/[,;.]/)[0].trim();
41
+ const words = clause.split(" ").filter((w) => !/^(with|and|of|in|at|for|the|a|an|on)$/i.test(w));
42
+ return [...new Set([full, clause, words.slice(0, 3).join(" "), words.slice(0, 2).join(" ")])].filter(Boolean);
43
+ }
44
+ /** Photo ids already placed on each site, so a second call does not repeat one. */
45
+ const usedBySite = new Map();
46
+ export async function findStockPhotos(opts) {
47
+ const used = usedBySite.get(opts.siteId) ?? new Set();
48
+ usedBySite.set(opts.siteId, used);
49
+ const results = [];
50
+ // In order, not in parallel: two requests for similar subjects must not
51
+ // both pick the same top hit.
52
+ for (const req of opts.photos) {
53
+ try {
54
+ let candidates = [];
55
+ let pick;
56
+ for (const query of queryFallbacks(req.query)) {
57
+ candidates = await searchStockPhotos({ ...req, query }, opts);
58
+ pick = candidates.find((c) => !used.has(c.id));
59
+ if (pick)
60
+ break;
61
+ }
62
+ if (!pick) {
63
+ results.push({ query: req.query, error: candidates.length ? "every match is already used on this site" : "no match — try a broader query" });
64
+ continue;
65
+ }
66
+ used.add(pick.id);
67
+ const file = await opts.download(pick.url, req.alt ?? pick.description, opts.outputDir);
68
+ results.push({ query: req.query, localUrl: `/images/${file.fileName}`, description: pick.description, author: pick.author });
69
+ }
70
+ catch (e) {
71
+ results.push({ query: req.query, error: e instanceof Error ? e.message : String(e) });
72
+ }
73
+ }
74
+ return results;
75
+ }
@@ -250,6 +250,7 @@ export async function generatePlanWithAnthropic(args) {
250
250
  locale: args.locale,
251
251
  imageSourceChoiceOpen: args.imageSourceChoiceOpen,
252
252
  attachmentCount: args.contextPack.attachments?.length,
253
+ attachmentImages: args.contextPack.attachments?.filter((attachment) => attachment.kind === "image").map(({ url, name }) => ({ url, name })),
253
254
  });
254
255
  // Stable prefix carries cache_control; dynamic tail is sent as a second
255
256
  // text block without it, so per-request flags don't bust the cached prefix.
@@ -45,15 +45,12 @@ export declare function compactPlannerContextPack(args: {
45
45
  type: import("@avocadostudio-ai/shared").BlockType;
46
46
  } | null;
47
47
  };
48
- pageOutline: {
48
+ pageOutline: ({
49
49
  id: string;
50
- type: import("@avocadostudio-ai/shared").BlockType;
50
+ type: string;
51
51
  props: Record<string, unknown>;
52
- arrayProps: Record<string, {
53
- length: number;
54
- labels?: string[];
55
- }>;
56
- }[];
52
+ arrayProps: ReturnType<typeof import("../nlp/deterministic-planner-context.ts").arrayPropLengths>;
53
+ } & import("../nlp/deterministic-planner-context.ts").BlockConstraintNote)[];
57
54
  pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
58
55
  pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
59
56
  pageIntent: string;
@@ -123,15 +120,12 @@ export declare function minimalPlannerContextPack(args: {
123
120
  type: import("@avocadostudio-ai/shared").BlockType;
124
121
  } | null;
125
122
  };
126
- pageOutline: {
123
+ pageOutline: ({
127
124
  id: string;
128
- type: import("@avocadostudio-ai/shared").BlockType;
125
+ type: string;
129
126
  props: Record<string, unknown>;
130
- arrayProps: Record<string, {
131
- length: number;
132
- labels?: string[];
133
- }>;
134
- }[];
127
+ arrayProps: ReturnType<typeof import("../nlp/deterministic-planner-context.ts").arrayPropLengths>;
128
+ } & import("../nlp/deterministic-planner-context.ts").BlockConstraintNote)[];
135
129
  pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
136
130
  pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
137
131
  pageIntent: string;