@avocadostudio-ai/orchestrator-core 0.1.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 (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,1227 @@
1
+ /**
2
+ * Site-level agent tools using Claude Agent SDK's tool() format.
3
+ *
4
+ * 8 tools for site creation and migration:
5
+ * - list_sites, create_site, scrape_url (with screenshot), extract_design_tokens,
6
+ * bootstrap_pages, download_remote_image, apply_theme, discover_site_structure
7
+ */
8
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
9
+ import { join, dirname } from "node:path";
10
+ import { existsSync } from "node:fs";
11
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
12
+ import { createMigrationTools } from "../migration/migration-tools.js";
13
+ import { z } from "zod";
14
+ import { getAllBlockMeta, defaultPropsForType } from "@avocadostudio-ai/shared";
15
+ import { fetchPageContent, downloadImage, extractDesignTokens, mapToThemeVariables, discoverSitePages, scrapeFullPage } from "@avocadostudio-ai/migration-sdk";
16
+ import { getCachedScrape, setCachedScrape } from "../migration/scrape-cache.js";
17
+ import { saveScreenshot } from "../migration/migration-tools.js";
18
+ import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig } from "../state/session-state.js";
19
+ import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
20
+ import sharp from "sharp";
21
+ 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
+ /** Convert a package name like "villa-puravida-web" → "Villa Puravida Web" */
23
+ function humanizePkgName(name) {
24
+ return name
25
+ .replace(/^@[^/]+\//, "") // strip scope
26
+ .replace(/[-_]/g, " ")
27
+ .replace(/\b\w/g, c => c.toUpperCase())
28
+ .trim() || "My Site";
29
+ }
30
+ // Match remote image URLs — extension-based OR known image CDN hostnames
31
+ const IMAGE_URL_RE = /^https?:\/\/.+\.(jpe?g|png|webp|gif|svg|avif|ico)(\?.*)?$/i;
32
+ const IMAGE_CDN_RE = /^https?:\/\/(images\.unsplash\.com|plus\.unsplash\.com|res\.cloudinary\.com|images\.ctfassets\.net|cdn\.sanity\.io)/i;
33
+ function isRemoteImageUrl(url) {
34
+ return IMAGE_URL_RE.test(url) || IMAGE_CDN_RE.test(url);
35
+ }
36
+ /** Recursively walk props and download any remote image URLs, replacing with local paths. */
37
+ async function localizeRemoteImages(props, imagesDir) {
38
+ let downloaded = 0;
39
+ async function walk(value) {
40
+ if (typeof value === "string" && isRemoteImageUrl(value)) {
41
+ try {
42
+ const result = await downloadImage(value, undefined, imagesDir);
43
+ downloaded++;
44
+ console.log(`[sites-agent] Auto-downloaded remote image: ${value} → ${result.localPath}`);
45
+ return `/images/${result.fileName}`;
46
+ }
47
+ catch {
48
+ console.warn(`[sites-agent] Failed to auto-download image: ${value}`);
49
+ return value;
50
+ }
51
+ }
52
+ if (Array.isArray(value)) {
53
+ return Promise.all(value.map(walk));
54
+ }
55
+ if (value && typeof value === "object") {
56
+ const entries = Object.entries(value);
57
+ const resolved = await Promise.all(entries.map(([, v]) => walk(v)));
58
+ const result = {};
59
+ entries.forEach(([k], i) => { result[k] = resolved[i]; });
60
+ return result;
61
+ }
62
+ return value;
63
+ }
64
+ const result = await walk(props);
65
+ return { props: result, downloaded };
66
+ }
67
+ // Shared utilities imported from sites-agent-shared.ts
68
+ /**
69
+ * Create the MCP server with all sites-agent tools.
70
+ */
71
+ export function createSitesAgentMcpServer(options) {
72
+ const { session, emitSiteCreated, emitPhaseOutcome } = options;
73
+ // ── LIST SITES ──
74
+ const listSitesTool = tool("list_sites", "List all site project directories in the monorepo apps/ folder.", {}, async () => {
75
+ try {
76
+ const root = monorepoRoot();
77
+ const appsDir = join(root, "apps");
78
+ const { readdir, stat } = await import("node:fs/promises");
79
+ const entries = await readdir(appsDir);
80
+ const sites = [];
81
+ for (const entry of entries) {
82
+ if (entry === "editor" || entry === "orchestrator")
83
+ continue;
84
+ const entryPath = join(appsDir, entry);
85
+ const s = await stat(entryPath).catch(() => null);
86
+ if (!s?.isDirectory())
87
+ continue;
88
+ const hasPkg = existsSync(join(entryPath, "package.json"));
89
+ sites.push({ id: entry, path: entryPath, hasPackageJson: hasPkg });
90
+ }
91
+ return { content: [{ type: "text", text: JSON.stringify({ sites }) }] };
92
+ }
93
+ catch (e) {
94
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
95
+ }
96
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
97
+ // ── DISCOVER SITE STRUCTURE ──
98
+ const discoverStructureTool = tool("discover_site_structure", "Discover all pages on a website by checking sitemap.xml, robots.txt, and crawling links from the homepage. Returns a list of pages with URLs and slugs. Use this before migration to understand the site's page structure.", {
99
+ url: z.string().describe("Homepage URL of the site to analyze, e.g. 'https://example.com'"),
100
+ }, async (args) => {
101
+ try {
102
+ console.log(`[discover_structure] Discovering pages on ${args.url}...`);
103
+ const structure = await discoverSitePages(args.url);
104
+ console.log(`[discover_structure] Found ${structure.totalFound} pages via ${structure.source} on ${structure.origin}`);
105
+ emitPhaseOutcome?.({ tool: "discover_site_structure", data: { totalPages: structure.totalFound, origin: structure.origin } });
106
+ return {
107
+ content: [{
108
+ type: "text",
109
+ text: JSON.stringify({
110
+ origin: structure.origin,
111
+ source: structure.source,
112
+ totalPages: structure.totalFound,
113
+ pages: structure.pages,
114
+ }),
115
+ }],
116
+ };
117
+ }
118
+ catch (e) {
119
+ return { content: [{ type: "text", text: `Error discovering site structure: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
120
+ }
121
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
122
+ // ── CREATE SITE ──
123
+ const createSiteTool = tool("create_site", "Scaffold a complete, runnable Next.js site project in the monorepo. Creates package.json, next.config, layout, page routing, editor API integration, and content files. After creation the site is ready to run with 'pnpm dev'.", {
124
+ name: z.string().describe("Human-readable site name, e.g. 'My Portfolio'"),
125
+ siteId: z.string().optional().describe("Kebab-case ID. If omitted, derived from name."),
126
+ purpose: z.string().optional().describe("What the site is about (used in AI context)"),
127
+ tone: z.string().optional().describe("Voice/tone for AI editing"),
128
+ port: z.number().optional().describe("Dev server port (auto-assigned if omitted)"),
129
+ }, async (args) => {
130
+ try {
131
+ const siteId = args.siteId?.trim() || sanitizeSiteId(args.name);
132
+ const root = monorepoRoot();
133
+ const projectDir = join(root, "apps", siteId);
134
+ let existingPort = 0;
135
+ if (existsSync(projectDir)) {
136
+ // Clean content from previous migration but keep project skeleton (package.json, node_modules, etc.)
137
+ const { rm } = await import("node:fs/promises");
138
+ for (const dir of ["content", "blocks", "public/images", ".next"]) {
139
+ const target = join(projectDir, dir);
140
+ if (existsSync(target))
141
+ await rm(target, { recursive: true, force: true });
142
+ }
143
+ console.log(`[sites-agent] Cleaned previous content from apps/${siteId}`);
144
+ // Reuse the existing port if not explicitly overridden
145
+ if (!args.port) {
146
+ try {
147
+ const existingPkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
148
+ const portMatch = existingPkg.scripts?.dev?.match(/-p\s*(\d+)/);
149
+ if (portMatch)
150
+ existingPort = Number(portMatch[1]);
151
+ }
152
+ catch { /* use new port */ }
153
+ }
154
+ }
155
+ // Validate requested/existing port is actually free; fall back to auto-assign
156
+ let port = args.port || existingPort || 0;
157
+ if (port) {
158
+ const { createServer } = await import("node:net");
159
+ const free = await new Promise((res) => {
160
+ const s = createServer();
161
+ s.once("error", () => res(false));
162
+ s.once("listening", () => { s.close(() => res(true)); });
163
+ s.listen(port, "127.0.0.1");
164
+ });
165
+ if (!free) {
166
+ console.log(`[create_site] Requested port ${port} in use, auto-assigning...`);
167
+ port = 0;
168
+ }
169
+ }
170
+ if (!port)
171
+ port = await findAvailablePort(root);
172
+ console.log(`[create_site] START siteId=${siteId} port=${port} name="${args.name}"`);
173
+ // Create project structure
174
+ await mkdir(projectDir, { recursive: true });
175
+ await writeFile(join(projectDir, "package.json"), packageJson(siteId, args.name, port), "utf-8");
176
+ await writeFile(join(projectDir, "next.config.ts"), nextConfigTs(), "utf-8");
177
+ await writeFile(join(projectDir, "tsconfig.json"), tsconfigJson(), "utf-8");
178
+ await writeFile(join(projectDir, "postcss.config.mjs"), postcssConfig(), "utf-8");
179
+ await mkdir(join(projectDir, "app"), { recursive: true });
180
+ await writeFile(join(projectDir, "app/layout.tsx"), layoutTsx(args.name), "utf-8");
181
+ await writeFile(join(projectDir, "app/globals.css"), globalsCss(), "utf-8");
182
+ await mkdir(join(projectDir, "app/api/editor/[...path]"), { recursive: true });
183
+ await writeFile(join(projectDir, "app/api/editor/[...path]/route.ts"), editorApiRoute(), "utf-8");
184
+ await mkdir(join(projectDir, "app/[[...slug]]"), { recursive: true });
185
+ await writeFile(join(projectDir, "app/[[...slug]]/page.tsx"), pageTsx(siteId), "utf-8");
186
+ await mkdir(join(projectDir, "content"), { recursive: true });
187
+ await writeFile(join(projectDir, "content/pages.json"), samplePagesJson(), "utf-8");
188
+ await mkdir(join(projectDir, "lib"), { recursive: true });
189
+ await writeFile(join(projectDir, "lib/defaults.ts"), defaultsTs(siteId, args.name), "utf-8");
190
+ await mkdir(join(projectDir, "public"), { recursive: true });
191
+ await writeFile(join(projectDir, "public/.gitkeep"), "", "utf-8");
192
+ await mkdir(join(projectDir, "blocks"), { recursive: true });
193
+ await writeFile(join(projectDir, "blocks/register.tsx"), blocksRegisterTsx(), "utf-8");
194
+ await writeFile(join(projectDir, "public/logo.svg"), defaultLogoSvg(args.name), "utf-8");
195
+ await writeFile(join(projectDir, "public/favicon.svg"), faviconSvg(args.name), "utf-8");
196
+ const envContent = `ORCHESTRATOR_URL=http://localhost:4200\nDRAFT_MODE_SECRET=${getDraftModeSecret()}\nNEXT_PUBLIC_DEFAULT_SITE_ID=${siteId}\nNEXT_PUBLIC_SITE_NAME=${args.name}\nNEXT_PUBLIC_EDITOR_ORIGIN=http://localhost:4100\n`;
197
+ await writeFile(join(projectDir, ".env.local"), envContent, "utf-8");
198
+ console.log(`[create_site] Wrote 13 project files to apps/${siteId}/`);
199
+ // Run pnpm install if needed (skip if node_modules exists from previous run)
200
+ if (!existsSync(join(projectDir, "node_modules"))) {
201
+ console.log(`[create_site] Running pnpm install...`);
202
+ const { execFile } = await import("node:child_process");
203
+ const { promisify } = await import("node:util");
204
+ await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
205
+ console.log(`[create_site] pnpm install complete`);
206
+ }
207
+ else {
208
+ console.log(`[create_site] Skipping pnpm install — node_modules exists`);
209
+ }
210
+ const siteConfig = {
211
+ id: siteId,
212
+ name: args.name,
213
+ purpose: args.purpose ?? "",
214
+ tone: args.tone ?? "",
215
+ hosting: "local",
216
+ previewUrl: `http://localhost:${port}`,
217
+ constraints: [],
218
+ };
219
+ // Initialize orchestrator session
220
+ const sessionKey = scopedSessionKey(session, siteId);
221
+ setPage(sessionKey, {
222
+ id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
223
+ });
224
+ bumpVersion(sessionKey);
225
+ // Start dev server and wait for it to be ready
226
+ console.log(`[create_site] Starting dev server on port ${port}...`);
227
+ const { serverReady } = await startAndWaitForDevServer({
228
+ siteId, port, cwd: root, useFilter: true,
229
+ });
230
+ console.log(`[create_site] Dev server ${serverReady ? "READY" : "FAILED"} on port ${port}`);
231
+ emitSiteCreated(siteConfig);
232
+ emitPhaseOutcome?.({ tool: "create_site", data: { siteId, port, name: args.name, serverReady } });
233
+ console.log(`[create_site] DONE siteId=${siteId} port=${port} serverReady=${serverReady}`);
234
+ return {
235
+ content: [{
236
+ type: "text",
237
+ text: JSON.stringify({
238
+ status: "site_created",
239
+ config: siteConfig,
240
+ projectPath: projectDir,
241
+ port,
242
+ devServerStarted: serverReady,
243
+ instructions: serverReady
244
+ ? `Site scaffolded at apps/${siteId} and dev server running on port ${port}.`
245
+ : `Site scaffolded at apps/${siteId} but dev server failed to start on port ${port}.`,
246
+ }),
247
+ }],
248
+ };
249
+ }
250
+ catch (e) {
251
+ console.error(`[create_site] ERROR: ${e instanceof Error ? e.message : String(e)}`);
252
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
253
+ }
254
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
255
+ // ── SCRAPE URL (Playwright: rendered DOM + screenshot + sections) ──
256
+ const scrapeUrlTool = tool("scrape_url", "Scrape a web page using a headless browser. Returns: page title/meta, pre-extracted sections with block type suggestions, design tokens, and a full-page screenshot. Handles JS-rendered content, Elementor, and lazy-loaded images. Use this to analyze an existing website for migration.", {
257
+ url: z.string().describe("Full URL to scrape, e.g. 'https://example.com'"),
258
+ }, async (args) => {
259
+ try {
260
+ const scrapeStart = Date.now();
261
+ let result = getCachedScrape(args.url);
262
+ const cached = !!result;
263
+ if (!result) {
264
+ console.log(`[scrape_url] Scraping ${args.url} (browser)...`);
265
+ result = await scrapeFullPage(args.url);
266
+ setCachedScrape(args.url, result);
267
+ }
268
+ const { content, screenshot, sections, outline, nav } = result;
269
+ console.log(`[scrape_url] ${cached ? "CACHED" : "SCRAPED"} ${args.url} in ${Date.now() - scrapeStart}ms — ${sections.length} sections, screenshot=${!!screenshot}, nav=${!!nav}`);
270
+ // Extract design tokens from CSS (with resolved CSS variables from Playwright)
271
+ const tokens = extractDesignTokens(content.css, result.resolvedCssVars);
272
+ const themeVars = mapToThemeVariables(tokens);
273
+ const textData = JSON.stringify({
274
+ title: content.title,
275
+ metaDescription: content.metaDescription,
276
+ baseUrl: content.baseUrl,
277
+ navigation: nav ? { siteName: nav.siteName, logoUrl: nav.logoUrl, items: nav.items } : null,
278
+ // Page outline — compact representation of ALL sections on the page (~2KB)
279
+ // USE THIS as the primary source for identifying sections and blocks.
280
+ pageOutline: outline,
281
+ // Extracted sections with structured content (no rawHtml to save tokens)
282
+ sections: sections.map(s => ({
283
+ index: s.index,
284
+ suggestedBlockType: s.suggestedBlockType,
285
+ classHints: s.classHints,
286
+ id: s.id,
287
+ content: s.content,
288
+ })),
289
+ sectionCount: sections.length,
290
+ designTokens: { colors: tokens.colors.slice(0, 15), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
291
+ themeVariables: themeVars,
292
+ });
293
+ const { mobileScreenshot } = result;
294
+ const contentBlocks = [
295
+ { type: "text", text: textData },
296
+ ];
297
+ if (screenshot)
298
+ contentBlocks.push({ type: "image", data: screenshot.base64, mimeType: "image/jpeg" });
299
+ if (mobileScreenshot)
300
+ contentBlocks.push({ type: "image", data: mobileScreenshot.base64, mimeType: "image/jpeg" });
301
+ // Fire-and-forget screenshot saves — non-critical debug artifacts
302
+ const writes = [];
303
+ if (screenshot)
304
+ writes.push(saveScreenshot("desktop", screenshot.base64, args.url));
305
+ if (mobileScreenshot)
306
+ writes.push(saveScreenshot("mobile", mobileScreenshot.base64, args.url));
307
+ if (writes.length)
308
+ Promise.all(writes).then(() => console.log(`[scrape_url] Screenshots saved`)).catch(() => { });
309
+ return { content: contentBlocks };
310
+ }
311
+ catch (e) {
312
+ // Fall back to simple HTTP fetch (no browser)
313
+ try {
314
+ console.warn(`[sites-agent] Browser scrape failed, falling back to HTTP: ${e instanceof Error ? e.message : String(e)}`);
315
+ const content = await fetchPageContent(args.url);
316
+ const { extractSections: extract, extractPageOutline: outline, extractNavigation: navExtract } = await import("@avocadostudio-ai/migration-sdk");
317
+ const sections = extract(content.html, content.baseUrl);
318
+ const pageOutline = outline(content.html, content.baseUrl);
319
+ const navResult = navExtract(content.html, content.baseUrl);
320
+ const tokens = extractDesignTokens(content.css);
321
+ const themeVars = mapToThemeVariables(tokens);
322
+ const textData = JSON.stringify({
323
+ title: content.title,
324
+ metaDescription: content.metaDescription,
325
+ baseUrl: content.baseUrl,
326
+ navigation: navResult ? { siteName: navResult.siteName, logoUrl: navResult.logoUrl, items: navResult.items } : null,
327
+ pageOutline,
328
+ sections: sections.map(s => ({
329
+ index: s.index,
330
+ suggestedBlockType: s.suggestedBlockType,
331
+ classHints: s.classHints,
332
+ content: s.content,
333
+ })),
334
+ sectionCount: sections.length,
335
+ designTokens: { colors: tokens.colors.slice(0, 15), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
336
+ themeVariables: themeVars,
337
+ });
338
+ return { content: [{ type: "text", text: textData }] };
339
+ }
340
+ catch (fallbackErr) {
341
+ return { content: [{ type: "text", text: `Error scraping URL: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}` }], isError: true };
342
+ }
343
+ }
344
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
345
+ // ── EXTRACT DESIGN TOKENS ──
346
+ const extractTokensTool = tool("extract_design_tokens", "Extract design tokens (colors, fonts, border radii) from CSS text. Maps them to theme variables (--brand, --bg-0, --text-100, etc.).", {
347
+ css: z.string().describe("Raw CSS text to analyze"),
348
+ }, async (args) => {
349
+ try {
350
+ const tokens = extractDesignTokens(args.css);
351
+ const themeVars = mapToThemeVariables(tokens);
352
+ return {
353
+ content: [{
354
+ type: "text",
355
+ text: JSON.stringify({
356
+ tokens: { colors: tokens.colors.slice(0, 20), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
357
+ themeVariables: themeVars,
358
+ }),
359
+ }],
360
+ };
361
+ }
362
+ catch (e) {
363
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
364
+ }
365
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
366
+ // ── BOOTSTRAP PAGES ──
367
+ const bootstrapPagesTool = tool("bootstrap_pages", `Create pages with blocks for a site. The site project must exist under apps/{siteId} (created via create_site or integrate_site). Built-in block types: ${Object.keys(getAllBlockMeta()).join(", ")}. Custom block types registered in blocks/register.ts are also supported — use the exact component name as the block type.`, {
368
+ siteId: z.string().describe("Site ID to create pages for"),
369
+ pages: z.array(z.object({
370
+ slug: z.string().describe("Page slug, e.g. '/' or '/about'"),
371
+ title: z.string().describe("Page title"),
372
+ blocks: z.array(z.object({
373
+ type: z.string().describe("Block type (e.g. Hero, FeatureGrid, CTA)"),
374
+ props: z.record(z.string(), z.unknown()).describe("Block props matching the block schema"),
375
+ })),
376
+ meta: z.object({
377
+ title: z.string().optional().describe("SEO title (from <title> tag)"),
378
+ description: z.string().optional().describe("SEO description (from <meta name='description'>)"),
379
+ ogImage: z.string().optional().describe("Open Graph image URL"),
380
+ }).optional().describe("SEO metadata for the page"),
381
+ })),
382
+ themeOverrides: z.record(z.string(), z.string()).optional().describe("CSS variable overrides for theming"),
383
+ 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."),
384
+ 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."),
385
+ siteLogo: z.string().optional().describe("Logo URL (relative, e.g. '/images/logo.png'). Set after downloading with download_remote_image."),
386
+ siteName: z.string().optional().describe("Site name displayed in the header nav bar."),
387
+ purpose: z.string().optional().describe("What the site is about — 1-2 sentences describing the business/project. Shown in editor settings and used as AI context for future edits."),
388
+ tone: z.string().optional().describe("Voice/tone guide for AI content generation, e.g. 'Professional but approachable, uses du-form (German informal)'"),
389
+ 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']"),
390
+ }, async (args) => {
391
+ try {
392
+ const totalBlocks = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
393
+ const slugs = args.pages.map(p => p.slug);
394
+ console.log(`[bootstrap_pages] START siteId=${args.siteId} pages=${args.pages.length} blocks=${totalBlocks} slugs=[${slugs.join(", ")}]`);
395
+ // Verify the site project was scaffolded first
396
+ const root = monorepoRoot();
397
+ const projectPkg = join(root, "apps", args.siteId, "package.json");
398
+ if (!existsSync(projectPkg)) {
399
+ console.error(`[bootstrap_pages] GUARD FAILED: apps/${args.siteId}/package.json not found — create_site was not called`);
400
+ return { content: [{ type: "text", text: `Error: Site project apps/${args.siteId} does not exist. Call create_site first.` }], isError: true };
401
+ }
402
+ const sessionKey = scopedSessionKey(session, args.siteId);
403
+ let strippedCount = 0;
404
+ let footerBlock = null;
405
+ // Normalize slugs — ensure leading / and no trailing /
406
+ const normalizedPages = args.pages.map(page => ({
407
+ ...page,
408
+ slug: page.slug === "/" ? "/" : `/${page.slug.replace(/^\/+/, "").replace(/\/+$/, "")}`,
409
+ }));
410
+ // Build page docs once — reused for both session state and file persistence
411
+ const imagesDir = join(root, "apps", args.siteId, "public", "images");
412
+ await mkdir(imagesDir, { recursive: true });
413
+ let totalAutoDownloaded = 0;
414
+ // Process pages sequentially to avoid unbounded concurrent image downloads
415
+ const pageDocs = [];
416
+ for (const [pageIdx, page] of normalizedPages.entries()) {
417
+ console.log(`[bootstrap_pages] Processing page ${pageIdx + 1}/${normalizedPages.length}: ${page.slug} (${page.blocks.length} blocks)`);
418
+ const contentBlocks = page.blocks.filter(b => {
419
+ if (b.type === "SiteHeader") {
420
+ strippedCount++;
421
+ return false;
422
+ }
423
+ if (b.type === "Footer") {
424
+ if (!footerBlock) {
425
+ let fp = fixFooterLinks(b.props);
426
+ const v = validateAndCorrectProps("Footer", fp);
427
+ if (v.corrected)
428
+ fp = v.props;
429
+ footerBlock = { type: b.type, props: fp };
430
+ }
431
+ strippedCount++;
432
+ return false;
433
+ }
434
+ return true;
435
+ });
436
+ const blocks = contentBlocks.map((b, i) => {
437
+ const blockMeta = getAllBlockMeta()[b.type];
438
+ const baseProps = blockMeta ? defaultPropsForType(b.type) : {};
439
+ let mergedProps = { ...baseProps };
440
+ for (const [key, value] of Object.entries(b.props)) {
441
+ mergedProps[key] = value;
442
+ }
443
+ if (blockMeta) {
444
+ const validation = validateAndCorrectProps(b.type, mergedProps);
445
+ if (validation.corrected) {
446
+ console.log(`[bootstrap_pages] Block ${b.type}: auto-corrected props`);
447
+ mergedProps = { ...baseProps };
448
+ for (const [key, value] of Object.entries(validation.props)) {
449
+ mergedProps[key] = value;
450
+ }
451
+ }
452
+ else if (validation.error) {
453
+ console.warn(`[bootstrap_pages] Block ${b.type}: validation error — ${validation.error}`);
454
+ }
455
+ }
456
+ else if (!getAllBlockMeta()[b.type]) {
457
+ console.log(`[bootstrap_pages] Block ${b.type}: custom type (not in built-in registry)`);
458
+ }
459
+ return { id: `b_${b.type.toLowerCase()}_${i + 1}`, type: b.type, props: mergedProps };
460
+ });
461
+ // Auto-download any remote image URLs the agent missed
462
+ for (const block of blocks) {
463
+ const { props: localizedProps, downloaded } = await localizeRemoteImages(block.props, imagesDir);
464
+ if (downloaded > 0) {
465
+ block.props = localizedProps;
466
+ totalAutoDownloaded += downloaded;
467
+ }
468
+ }
469
+ pageDocs.push({
470
+ id: `p_${page.slug === "/" ? "home" : page.slug.replace(/^\//, "").replace(/\//g, "_")}`,
471
+ slug: page.slug,
472
+ title: page.title,
473
+ blocks,
474
+ ...(page.meta ? { meta: page.meta } : {}),
475
+ updatedAt: new Date().toISOString(),
476
+ });
477
+ }
478
+ if (strippedCount > 0)
479
+ console.log(`[sites-agent] Stripped ${strippedCount} chrome blocks (SiteHeader/Footer)`);
480
+ if (totalAutoDownloaded > 0)
481
+ console.log(`[sites-agent] Auto-downloaded ${totalAutoDownloaded} remote images as safety net`);
482
+ // Write to session state
483
+ for (const doc of pageDocs)
484
+ setPage(sessionKey, doc);
485
+ const createdPages = pageDocs.map(d => d.slug);
486
+ // Apply site config
487
+ const existing = getSiteConfig(sessionKey);
488
+ const patch = { ...existing };
489
+ if (args.themeOverrides && Object.keys(args.themeOverrides).length > 0) {
490
+ patch.themeOverrides = { ...(existing.themeOverrides ?? {}), ...args.themeOverrides };
491
+ }
492
+ if (args.navLabels)
493
+ patch.navLabels = { ...(existing.navLabels ?? {}), ...args.navLabels };
494
+ if (args.navGroups)
495
+ patch.navGroups = { ...(existing.navGroups ?? {}), ...args.navGroups };
496
+ if (args.siteLogo)
497
+ patch.logo = args.siteLogo;
498
+ if (args.siteName)
499
+ patch.name = args.siteName;
500
+ if (args.purpose)
501
+ patch.purpose = args.purpose;
502
+ if (args.tone)
503
+ patch.tone = args.tone;
504
+ if (args.constraints && args.constraints.length > 0)
505
+ patch.constraints = args.constraints;
506
+ setSiteConfig(sessionKey, patch);
507
+ bumpVersion(sessionKey);
508
+ // Persist to content/pages.json
509
+ const pagesJsonPath = join(root, "apps", args.siteId, "content", "pages.json");
510
+ try {
511
+ let existingPages = [];
512
+ try {
513
+ existingPages = JSON.parse(await readFile(pagesJsonPath, "utf-8"));
514
+ }
515
+ catch { /* fresh */ }
516
+ const allPages = [...existingPages];
517
+ for (const doc of pageDocs) {
518
+ const idx = allPages.findIndex(p => p.slug === doc.slug);
519
+ if (idx >= 0)
520
+ allPages[idx] = doc;
521
+ else
522
+ allPages.push(doc);
523
+ }
524
+ await mkdir(dirname(pagesJsonPath), { recursive: true });
525
+ await writeFile(pagesJsonPath, JSON.stringify(allPages, null, 2) + "\n", "utf-8");
526
+ console.log(`[sites-agent] Wrote ${allPages.length} pages to ${pagesJsonPath}`);
527
+ }
528
+ catch (writeErr) {
529
+ console.warn(`[sites-agent] Failed to write pages.json:`, writeErr);
530
+ }
531
+ // Persist site config (nav labels, nav groups, logo, name, footer) to content/site-config.json
532
+ {
533
+ const configPath = join(root, "apps", args.siteId, "content", "site-config.json");
534
+ try {
535
+ let existing = {};
536
+ if (existsSync(configPath)) {
537
+ try {
538
+ existing = JSON.parse(await readFile(configPath, "utf-8"));
539
+ }
540
+ catch { /* fresh */ }
541
+ }
542
+ const config = { ...existing };
543
+ if (args.siteName)
544
+ config.name = args.siteName;
545
+ if (args.siteLogo)
546
+ config.logo = args.siteLogo;
547
+ if (args.purpose)
548
+ config.purpose = args.purpose;
549
+ if (args.tone)
550
+ config.tone = args.tone;
551
+ if (args.constraints && args.constraints.length > 0)
552
+ config.constraints = args.constraints;
553
+ if (args.navLabels)
554
+ config.navLabels = { ...(existing.navLabels ?? {}), ...args.navLabels };
555
+ if (args.navGroups)
556
+ config.navGroups = { ...(existing.navGroups ?? {}), ...args.navGroups };
557
+ if (footerBlock) {
558
+ const fb = footerBlock;
559
+ config.footer = { id: "chrome_footer", type: fb.type, props: fb.props };
560
+ }
561
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
562
+ console.log(`[sites-agent] Wrote site config to ${configPath}`);
563
+ }
564
+ catch (err) {
565
+ console.warn(`[sites-agent] Failed to write site-config.json:`, err);
566
+ }
567
+ }
568
+ // Persist theme overrides to globals.css
569
+ if (args.themeOverrides && Object.keys(args.themeOverrides).length > 0) {
570
+ try {
571
+ await patchGlobalsCssVars(join(root, "apps", args.siteId, "app", "globals.css"), args.themeOverrides);
572
+ console.log(`[sites-agent] Updated ${Object.keys(args.themeOverrides).length} theme vars in globals.css`);
573
+ }
574
+ catch (cssErr) {
575
+ console.warn(`[sites-agent] Failed to update globals.css:`, cssErr);
576
+ }
577
+ }
578
+ const totalBlocksFinal = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
579
+ emitPhaseOutcome?.({ tool: "bootstrap_pages", data: { pagesCreated: createdPages.length, totalBlocks: totalBlocksFinal, pages: createdPages } });
580
+ console.log(`[bootstrap_pages] DONE siteId=${args.siteId} pages=${createdPages.length} blocks=${totalBlocksFinal} autoDownloaded=${totalAutoDownloaded}`);
581
+ return {
582
+ content: [{
583
+ type: "text",
584
+ text: JSON.stringify({
585
+ status: "applied",
586
+ pagesCreated: createdPages,
587
+ totalBlocks: totalBlocksFinal,
588
+ persistedToFile: true,
589
+ }),
590
+ }],
591
+ };
592
+ }
593
+ catch (e) {
594
+ console.error(`[bootstrap_pages] ERROR: ${e instanceof Error ? e.message : String(e)}`);
595
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
596
+ }
597
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
598
+ // ── DOWNLOAD REMOTE IMAGE ──
599
+ const downloadImageTool = tool("download_remote_image", "Download a remote image and save it to the site's public/images/ directory. Returns a relative URL (/images/filename) for use in block props. Next.js serves files from public/ automatically.", {
600
+ url: z.string().describe("Image URL to download"),
601
+ siteId: z.string().describe("Site ID — image saved to apps/{siteId}/public/images/"),
602
+ alt: z.string().optional().describe("Alt text for the image"),
603
+ }, async (args) => {
604
+ try {
605
+ const root = monorepoRoot();
606
+ const outputDir = join(root, "apps", args.siteId, "public", "images");
607
+ console.log(`[download_image] ${args.url.slice(0, 100)}`);
608
+ const result = await downloadImage(args.url, args.alt, outputDir);
609
+ const localUrl = `/images/${result.fileName}`;
610
+ console.log(`[download_image] OK → ${localUrl}`);
611
+ emitPhaseOutcome?.({ tool: "download_remote_image", data: { fileName: result.fileName } });
612
+ return { content: [{ type: "text", text: JSON.stringify({ localUrl, fileName: result.fileName }) }] };
613
+ }
614
+ catch (e) {
615
+ console.warn(`[download_image] FAILED ${args.url.slice(0, 100)} — ${e instanceof Error ? e.message : String(e)}`);
616
+ return { content: [{ type: "text", text: JSON.stringify({ localUrl: args.url, error: "Download failed, using original URL" }) }] };
617
+ }
618
+ }, { annotations: { destructiveHint: false, openWorldHint: true } });
619
+ // ── BATCH DOWNLOAD IMAGES ──
620
+ 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.", {
621
+ siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
622
+ images: z.array(z.object({
623
+ url: z.string().describe("Image URL to download"),
624
+ alt: z.string().optional().describe("Alt text"),
625
+ })).describe("Array of images to download"),
626
+ }, async (args) => {
627
+ const root = monorepoRoot();
628
+ const outputDir = join(root, "apps", args.siteId, "public", "images");
629
+ await mkdir(outputDir, { recursive: true });
630
+ // Download 4 at a time to avoid overwhelming the source server
631
+ const results = [];
632
+ for (let i = 0; i < args.images.length; i += 4) {
633
+ const batch = args.images.slice(i, i + 4);
634
+ const batchResults = await Promise.all(batch.map(async (img) => {
635
+ try {
636
+ const result = await downloadImage(img.url, img.alt, outputDir);
637
+ return { url: img.url, localUrl: `/images/${result.fileName}` };
638
+ }
639
+ catch {
640
+ return { url: img.url, localUrl: img.url, error: "Download failed" };
641
+ }
642
+ }));
643
+ results.push(...batchResults);
644
+ }
645
+ const succeeded = results.filter(r => !r.error).length;
646
+ emitPhaseOutcome?.({ tool: "download_remote_images", data: { succeeded, total: results.length } });
647
+ console.log(`[sites-agent] Batch downloaded ${succeeded}/${results.length} images`);
648
+ return { content: [{ type: "text", text: JSON.stringify({ results, succeeded, failed: results.length - succeeded }) }] };
649
+ }, { annotations: { destructiveHint: false, openWorldHint: true } });
650
+ // ── BROWSE GOOGLE DRIVE IMAGES ──
651
+ const browseGdriveTool = tool("browse_gdrive_images", "Browse images in a Google Drive folder. Downloads them to the site's public/images/ and returns thumbnails so you can see the images and decide where to place them. Use BEFORE bootstrap_pages when the user provides a Google Drive folder.", {
652
+ siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
653
+ folderId: z.string().optional().describe("Google Drive folder ID or URL. Falls back to the configured default folder."),
654
+ query: z.string().optional().describe("Optional search text to filter images by filename"),
655
+ limit: z.number().optional().describe("Max images to return (1-15, default 10)"),
656
+ }, async (args) => {
657
+ if (!isGdriveConfigured() && !args.folderId) {
658
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Google Drive not configured. Set GOOGLE_DRIVE_FOLDER_ID and GOOGLE_API_KEY in .env, or provide a folderId." }) }] };
659
+ }
660
+ const folderId = resolveGdriveFolderId(args.folderId);
661
+ if (!folderId) {
662
+ return { content: [{ type: "text", text: JSON.stringify({ error: "No folder ID provided or configured." }) }] };
663
+ }
664
+ const limit = Math.min(15, Math.max(1, Math.trunc(args.limit ?? 10)));
665
+ const files = await listGdriveImages(folderId, args.query, undefined, limit);
666
+ if (files.length === 0) {
667
+ return { content: [{ type: "text", text: JSON.stringify({ images: [], message: "No images found in the specified folder." }) }] };
668
+ }
669
+ const root = monorepoRoot();
670
+ const outputDir = join(root, "apps", args.siteId, "public", "images");
671
+ await mkdir(outputDir, { recursive: true });
672
+ const THUMB_WIDTH = 256;
673
+ const manifest = [];
674
+ const contentBlocks = [];
675
+ // Download and thumbnail 4 at a time
676
+ for (let i = 0; i < files.length; i += 4) {
677
+ const batch = files.slice(i, i + 4);
678
+ const batchResults = await Promise.all(batch.map(async (file) => {
679
+ try {
680
+ const result = await downloadGdriveImage(file.id);
681
+ if (!result)
682
+ return null;
683
+ // Copy to site's public/images/
684
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").toLowerCase();
685
+ const localName = `gdrive_${safeName.replace(/\.[^.]+$/, "")}.webp`;
686
+ const localPath = join(outputDir, localName);
687
+ const fullImage = await readFile(result.filePath);
688
+ await writeFile(localPath, fullImage);
689
+ // Generate small thumbnail for vision
690
+ const thumb = await sharp(fullImage)
691
+ .resize({ width: THUMB_WIDTH, withoutEnlargement: true })
692
+ .webp({ quality: 60 })
693
+ .toBuffer();
694
+ return {
695
+ name: file.name,
696
+ localUrl: `/images/${localName}`,
697
+ alt: fileNameToAlt(file.name),
698
+ thumbBase64: thumb.toString("base64"),
699
+ };
700
+ }
701
+ catch {
702
+ return null;
703
+ }
704
+ }));
705
+ for (const r of batchResults) {
706
+ if (!r)
707
+ continue;
708
+ manifest.push({ name: r.name, localUrl: r.localUrl, alt: r.alt });
709
+ contentBlocks.push({ type: "image", data: r.thumbBase64, mimeType: "image/webp" });
710
+ contentBlocks.push({ type: "text", text: `↑ ${r.name} → ${r.localUrl}` });
711
+ }
712
+ }
713
+ console.log(`[sites-agent] Browsed GDrive: ${manifest.length}/${files.length} images downloaded`);
714
+ return {
715
+ content: [
716
+ { type: "text", text: `Found ${manifest.length} images. Thumbnails below — use localUrl paths in block props.\n${JSON.stringify(manifest, null, 2)}` },
717
+ ...contentBlocks,
718
+ ],
719
+ };
720
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
721
+ // ── APPLY THEME ──
722
+ 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.", {
723
+ siteId: z.string().describe("Site ID to apply theme to"),
724
+ variables: z.record(z.string(), z.string()).describe("CSS variable overrides, e.g. { '--brand': '#2563eb' }"),
725
+ }, async (args) => {
726
+ const sessionKey = scopedSessionKey(session, args.siteId);
727
+ const existing = getSiteConfig(sessionKey);
728
+ setSiteConfig(sessionKey, {
729
+ ...existing,
730
+ themeOverrides: { ...(existing.themeOverrides ?? {}), ...args.variables },
731
+ });
732
+ // Also persist to the site's globals.css
733
+ try {
734
+ await patchGlobalsCssVars(join(monorepoRoot(), "apps", args.siteId, "app", "globals.css"), args.variables);
735
+ }
736
+ catch (err) {
737
+ console.warn(`[sites-agent] Failed to persist theme to globals.css:`, err);
738
+ }
739
+ return {
740
+ content: [{
741
+ type: "text",
742
+ text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true }),
743
+ }],
744
+ };
745
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
746
+ // ── CLONE REPO ──
747
+ const cloneRepoTool = tool("clone_repo", "Clone a GitHub repository to a local directory inside the monorepo. Uses `gh repo clone` for auth, falls back to `git clone`. Returns the local path for use with analyze_codebase.", {
748
+ url: z.string().describe("GitHub repo URL (e.g. 'https://github.com/user/repo') or shorthand ('user/repo')"),
749
+ targetDir: z.string().optional().describe("Target directory name inside apps/. Defaults to repo name."),
750
+ }, async (args) => {
751
+ try {
752
+ const result = await cloneRepo(args.url, args.targetDir);
753
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
754
+ }
755
+ catch (e) {
756
+ return { content: [{ type: "text", text: `Error cloning repo: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
757
+ }
758
+ }, { annotations: { destructiveHint: false, openWorldHint: true } });
759
+ // ── INTEGRATE SITE (composite — replaces 8-10 individual tool calls) ──
760
+ const integrateSiteTool = tool("integrate_site", "Add AI Site Editor SDK integration to an existing Next.js project in ONE step. Creates all integration files (catch-all page, editor API route, CMS adapter, content directory, .env.local, blocks register), adds block styles import to layout, installs workspace deps if needed, and starts the dev server. Returns the site config. Use this AFTER analyze_codebase confirms the project is a Next.js app-router site without existing integration.", {
761
+ siteId: z.string().describe("Site ID (kebab-case, matches the directory name in apps/)"),
762
+ name: z.string().describe("Human-readable site name"),
763
+ purpose: z.string().optional().describe("What the site is about"),
764
+ layoutPath: z.string().optional().describe("Relative path to layout file (from analyze_codebase)"),
765
+ useSrcDir: z.boolean().optional().describe("Whether the project uses src/app/ instead of app/"),
766
+ }, async (args) => {
767
+ try {
768
+ const root = monorepoRoot();
769
+ const projectDir = join(root, "apps", args.siteId);
770
+ if (!existsSync(join(projectDir, "package.json"))) {
771
+ return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found.` }], isError: true };
772
+ }
773
+ const appDir = args.useSrcDir ? "src/app" : "app";
774
+ const filesCreated = [];
775
+ // 1. Add workspace deps to package.json if missing
776
+ const pkgPath = join(projectDir, "package.json");
777
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
778
+ // Auto-detect site name: prefer <title> from layout metadata, fall back to package.json
779
+ let siteName = args.name;
780
+ const genericNames = new Set(["sample site", "my site", "site", "test site", "new site", "untitled", ""]);
781
+ if (!siteName || genericNames.has(siteName.toLowerCase())) {
782
+ // Try extracting title from layout metadata
783
+ const layoutFile = args.layoutPath
784
+ ? join(projectDir, args.layoutPath)
785
+ : join(projectDir, appDir, "layout.tsx");
786
+ if (existsSync(layoutFile)) {
787
+ const layoutSrc = await readFile(layoutFile, "utf-8");
788
+ // Match: title: "..." or title: { default: "..." }
789
+ const titleMatch = layoutSrc.match(/title:\s*(?:\{\s*default:\s*)?["']([^"']+)["']/);
790
+ if (titleMatch?.[1])
791
+ siteName = titleMatch[1];
792
+ }
793
+ // Fall back to humanized package name
794
+ if (!siteName || genericNames.has(siteName.toLowerCase())) {
795
+ siteName = humanizePkgName(String(pkg.name ?? args.siteId));
796
+ }
797
+ }
798
+ const deps = pkg.dependencies ?? {};
799
+ let depsAdded = false;
800
+ for (const dep of ["@avocadostudio-ai/site-sdk", "@avocadostudio-ai/blocks", "@avocadostudio-ai/shared"]) {
801
+ if (!deps[dep]) {
802
+ deps[dep] = "workspace:*";
803
+ depsAdded = true;
804
+ }
805
+ }
806
+ if (depsAdded) {
807
+ pkg.dependencies = deps;
808
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
809
+ filesCreated.push("package.json (deps added)");
810
+ }
811
+ // 2. Create or wrap catch-all page route for editor-managed pages
812
+ const catchAllDir = join(projectDir, appDir, "[[...slug]]");
813
+ const catchAllPage = join(catchAllDir, "page.tsx");
814
+ const originalPage = join(catchAllDir, "_original-page.tsx");
815
+ const editorPage = join(catchAllDir, "_editor-page.tsx");
816
+ const blocksPrefix = args.useSrcDir ? "../../../" : "../../";
817
+ let isHybrid = false;
818
+ if (!existsSync(catchAllDir)) {
819
+ // Case A: No catch-all — create standard editor catch-all
820
+ await mkdir(catchAllDir, { recursive: true });
821
+ const pageContent = pageTsx(args.siteId)
822
+ .replace('import "../../blocks/register"', `import "${blocksPrefix}blocks/register"`);
823
+ await writeFile(catchAllPage, pageContent, "utf-8");
824
+ filesCreated.push(`${appDir}/[[...slug]]/page.tsx`);
825
+ }
826
+ else if (existsSync(catchAllPage) && !existsSync(originalPage)) {
827
+ // Case B: Existing catch-all, not yet wrapped — create hybrid
828
+ isHybrid = true;
829
+ const { rename } = await import("node:fs/promises");
830
+ await rename(catchAllPage, originalPage);
831
+ const editorContent = pageTsx(args.siteId, { chrome: false })
832
+ .replace('import "../../blocks/register"', `import "${blocksPrefix}blocks/register"`);
833
+ await writeFile(editorPage, editorContent, "utf-8");
834
+ await writeFile(catchAllPage, hybridPageTsx(args.siteId), "utf-8");
835
+ filesCreated.push(`${appDir}/[[...slug]]/_original-page.tsx (preserved)`);
836
+ filesCreated.push(`${appDir}/[[...slug]]/_editor-page.tsx`);
837
+ filesCreated.push(`${appDir}/[[...slug]]/page.tsx (hybrid wrapper)`);
838
+ }
839
+ else if (existsSync(originalPage)) {
840
+ isHybrid = true; // already integrated
841
+ }
842
+ // 3. Create editor API route
843
+ const apiDir = join(projectDir, appDir, "api/editor/[...path]");
844
+ if (!existsSync(apiDir)) {
845
+ await mkdir(apiDir, { recursive: true });
846
+ const apiBlocksPrefix = args.useSrcDir ? "../../../../../" : "../../../../";
847
+ const routeContent = editorApiRoute()
848
+ .replace('import "../../../../blocks/register"', `import "${apiBlocksPrefix}blocks/register"`);
849
+ await writeFile(join(apiDir, "route.ts"), routeContent, "utf-8");
850
+ filesCreated.push(`${appDir}/api/editor/[...path]/route.ts`);
851
+ }
852
+ // 4. Create content directory (empty for hybrid — existing pages fall through to original)
853
+ const contentDir = join(projectDir, "content");
854
+ if (!existsSync(join(contentDir, "pages.json"))) {
855
+ await mkdir(contentDir, { recursive: true });
856
+ await writeFile(join(contentDir, "pages.json"), isHybrid ? "[]\n" : samplePagesJson(), "utf-8");
857
+ filesCreated.push("content/pages.json");
858
+ }
859
+ // 5. Create blocks register file
860
+ const blocksDir = join(projectDir, "blocks");
861
+ if (!existsSync(join(blocksDir, "register.ts")) && !existsSync(join(blocksDir, "register.tsx"))) {
862
+ await mkdir(blocksDir, { recursive: true });
863
+ await writeFile(join(blocksDir, "register.tsx"), blocksRegisterTsx(), "utf-8");
864
+ filesCreated.push("blocks/register.tsx");
865
+ }
866
+ // 6. Create lib/defaults.ts
867
+ const libDir = join(projectDir, "lib");
868
+ if (!existsSync(join(libDir, "defaults.ts"))) {
869
+ await mkdir(libDir, { recursive: true });
870
+ await writeFile(join(libDir, "defaults.ts"), defaultsTs(args.siteId, siteName), "utf-8");
871
+ filesCreated.push("lib/defaults.ts");
872
+ }
873
+ // 7. Create/merge .env.local
874
+ const envPath = join(projectDir, ".env.local");
875
+ const envVars = {
876
+ ORCHESTRATOR_URL: "http://localhost:4200",
877
+ DRAFT_MODE_SECRET: getDraftModeSecret(),
878
+ NEXT_PUBLIC_DEFAULT_SITE_ID: args.siteId,
879
+ NEXT_PUBLIC_SITE_NAME: siteName,
880
+ NEXT_PUBLIC_EDITOR_ORIGIN: "http://localhost:4100",
881
+ };
882
+ if (existsSync(envPath)) {
883
+ const existing = await readFile(envPath, "utf-8");
884
+ const existingKeys = new Set(existing.split("\n").map(l => l.split("=")[0]).filter(Boolean));
885
+ const newLines = [];
886
+ for (const [k, v] of Object.entries(envVars)) {
887
+ if (!existingKeys.has(k))
888
+ newLines.push(`${k}=${v}`);
889
+ }
890
+ if (newLines.length > 0) {
891
+ await writeFile(envPath, existing.trimEnd() + "\n" + newLines.join("\n") + "\n", "utf-8");
892
+ filesCreated.push(".env.local (merged)");
893
+ }
894
+ }
895
+ else {
896
+ await writeFile(envPath, Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n", "utf-8");
897
+ filesCreated.push(".env.local");
898
+ }
899
+ // 8. Add block styles import + EditorOverlay to existing layout
900
+ const layoutFile = args.layoutPath
901
+ ? join(projectDir, args.layoutPath)
902
+ : join(projectDir, appDir, "layout.tsx");
903
+ if (existsSync(layoutFile)) {
904
+ let layoutContent = await readFile(layoutFile, "utf-8");
905
+ let layoutModified = false;
906
+ // Add block styles import
907
+ if (!layoutContent.includes("@avocadostudio-ai/blocks/styles.css")) {
908
+ const lines = layoutContent.split("\n");
909
+ let lastImportIdx = -1;
910
+ for (let i = 0; i < lines.length; i++) {
911
+ if (lines[i].startsWith("import "))
912
+ lastImportIdx = i;
913
+ }
914
+ if (lastImportIdx >= 0) {
915
+ lines.splice(lastImportIdx + 1, 0, 'import "@avocadostudio-ai/blocks/styles.css"');
916
+ }
917
+ else {
918
+ lines.unshift('import "@avocadostudio-ai/blocks/styles.css"');
919
+ }
920
+ layoutContent = lines.join("\n");
921
+ layoutModified = true;
922
+ }
923
+ // Add EditorOverlay for preview bridge (enables editor communication)
924
+ if (!layoutContent.includes("EditorOverlay")) {
925
+ // Add import
926
+ const lines = layoutContent.split("\n");
927
+ let lastImportIdx = -1;
928
+ for (let i = 0; i < lines.length; i++) {
929
+ if (lines[i].startsWith("import "))
930
+ lastImportIdx = i;
931
+ }
932
+ const overlayImport = 'import { EditorOverlay } from "@avocadostudio-ai/site-sdk/editor"';
933
+ if (lastImportIdx >= 0) {
934
+ lines.splice(lastImportIdx + 1, 0, overlayImport);
935
+ }
936
+ else {
937
+ lines.unshift(overlayImport);
938
+ }
939
+ layoutContent = lines.join("\n");
940
+ // Insert <EditorOverlay /> before closing </body>
941
+ layoutContent = layoutContent.replace("</body>", ` <EditorOverlay slug="/" editorOrigin={process.env.NEXT_PUBLIC_EDITOR_ORIGIN ?? "http://localhost:4100"} />\n </body>`);
942
+ layoutModified = true;
943
+ }
944
+ if (layoutModified) {
945
+ await writeFile(layoutFile, layoutContent, "utf-8");
946
+ filesCreated.push(args.layoutPath ?? `${appDir}/layout.tsx (styles + editor overlay added)`);
947
+ }
948
+ }
949
+ // 9. Create public dir and default assets if missing
950
+ const publicDir = join(projectDir, "public");
951
+ if (!existsSync(publicDir))
952
+ await mkdir(publicDir, { recursive: true });
953
+ if (!existsSync(join(publicDir, "logo.svg"))) {
954
+ await writeFile(join(publicDir, "logo.svg"), defaultLogoSvg(siteName), "utf-8");
955
+ filesCreated.push("public/logo.svg");
956
+ }
957
+ if (!existsSync(join(publicDir, "favicon.svg"))) {
958
+ await writeFile(join(publicDir, "favicon.svg"), faviconSvg(siteName), "utf-8");
959
+ filesCreated.push("public/favicon.svg");
960
+ }
961
+ // 10. Install dependencies (skip if monorepo root node_modules exists)
962
+ const rootNodeModules = join(root, "node_modules");
963
+ if (depsAdded && existsSync(rootNodeModules)) {
964
+ const { execFile } = await import("node:child_process");
965
+ const { promisify } = await import("node:util");
966
+ await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
967
+ }
968
+ // 11. Start dev server and register site in editor dashboard
969
+ // (Previously split into separate launch_site step — now self-contained like create_site)
970
+ let port = await detectSitePort(projectDir);
971
+ // Check if port is in use — find a free one
972
+ try {
973
+ const { execSync } = await import("node:child_process");
974
+ const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
975
+ if (pids) {
976
+ console.log(`[integrate_site] Port ${port} in use, finding free port...`);
977
+ port = await findAvailablePort(root);
978
+ const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
979
+ if (pkg.scripts?.dev) {
980
+ pkg.scripts.dev = pkg.scripts.dev.replace(/-p\s*\d+/, `-p ${port}`);
981
+ await writeFile(join(projectDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf-8");
982
+ }
983
+ }
984
+ }
985
+ catch { /* no process on port — good */ }
986
+ // Initialize orchestrator session
987
+ const sessionKey = scopedSessionKey(session, args.siteId);
988
+ setPage(sessionKey, {
989
+ id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
990
+ });
991
+ bumpVersion(sessionKey);
992
+ // Start dev server and wait for ready
993
+ const previewUrl = `http://localhost:${port}`;
994
+ const { serverReady } = await startAndWaitForDevServer({
995
+ siteId: args.siteId, port, cwd: projectDir,
996
+ });
997
+ // Register in editor dashboard (emits site_created SSE event)
998
+ const siteConfig = {
999
+ id: args.siteId,
1000
+ name: siteName,
1001
+ purpose: args.purpose ?? "",
1002
+ tone: "",
1003
+ hosting: "local",
1004
+ previewUrl,
1005
+ constraints: [],
1006
+ };
1007
+ emitSiteCreated(siteConfig);
1008
+ emitPhaseOutcome?.({ tool: "integrate_site", data: { siteId: args.siteId, port, name: siteName, filesCreated: filesCreated.length, serverReady } });
1009
+ return {
1010
+ content: [{
1011
+ type: "text",
1012
+ text: JSON.stringify({
1013
+ status: serverReady ? "running" : "integrated",
1014
+ siteId: args.siteId,
1015
+ port,
1016
+ previewUrl,
1017
+ name: siteName,
1018
+ filesCreated,
1019
+ serverReady,
1020
+ }),
1021
+ }],
1022
+ };
1023
+ }
1024
+ catch (e) {
1025
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1026
+ }
1027
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
1028
+ // ── LAUNCH SITE ──
1029
+ const launchSiteTool = tool("launch_site", "Start the dev server for an integrated site, wait for it to be ready, and register it in the editor. Call this AFTER integrate_site completes. Returns the confirmed preview URL once the server is responding.", {
1030
+ siteId: z.string().describe("Site ID (matches the directory name in apps/)"),
1031
+ name: z.string().optional().describe("Human-readable site name"),
1032
+ purpose: z.string().optional().describe("What the site is about"),
1033
+ }, async (args) => {
1034
+ try {
1035
+ const root = monorepoRoot();
1036
+ const projectDir = join(root, "apps", args.siteId);
1037
+ if (!existsSync(join(projectDir, "package.json"))) {
1038
+ return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found.` }], isError: true };
1039
+ }
1040
+ let port = await detectSitePort(projectDir);
1041
+ // Check if port is in use — if so, find a free one and update package.json
1042
+ try {
1043
+ const { execSync } = await import("node:child_process");
1044
+ const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1045
+ if (pids) {
1046
+ console.log(`[sites-agent] Port ${port} in use, finding free port...`);
1047
+ port = await findAvailablePort(root);
1048
+ // Update dev script with new port
1049
+ const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
1050
+ if (pkg.scripts?.dev) {
1051
+ pkg.scripts.dev = pkg.scripts.dev.replace(/-p\s*\d+/, `-p ${port}`);
1052
+ await writeFile(join(projectDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf-8");
1053
+ }
1054
+ }
1055
+ }
1056
+ catch { /* no process on port — good */ }
1057
+ // Initialize orchestrator session
1058
+ const sessionKey = scopedSessionKey(session, args.siteId);
1059
+ setPage(sessionKey, {
1060
+ id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
1061
+ });
1062
+ bumpVersion(sessionKey);
1063
+ // Start dev server and wait for it to be ready
1064
+ const previewUrl = `http://localhost:${port}`;
1065
+ const { serverReady } = await startAndWaitForDevServer({
1066
+ siteId: args.siteId, port, cwd: projectDir,
1067
+ });
1068
+ const siteName = args.name ?? args.siteId;
1069
+ const siteConfig = {
1070
+ id: args.siteId,
1071
+ name: siteName,
1072
+ purpose: args.purpose ?? "",
1073
+ tone: "",
1074
+ hosting: "local",
1075
+ previewUrl,
1076
+ constraints: [],
1077
+ };
1078
+ emitSiteCreated(siteConfig);
1079
+ emitPhaseOutcome?.({ tool: "launch_site", data: { siteId: args.siteId, port, name: siteName, serverReady } });
1080
+ return {
1081
+ content: [{
1082
+ type: "text",
1083
+ text: JSON.stringify({ status: serverReady ? "running" : "timeout", siteId: args.siteId, port, previewUrl, name: siteName, serverReady }),
1084
+ }],
1085
+ };
1086
+ }
1087
+ catch (e) {
1088
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1089
+ }
1090
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
1091
+ // ── REGISTER SITE ──
1092
+ const registerSiteTool = tool("register_site", "Register an existing site project with the editor and start its dev server. Call this AFTER integrating the SDK into an existing codebase. The site will appear in the editor's site list and be ready for editing.", {
1093
+ siteId: z.string().describe("Site ID (kebab-case, matches the directory name in apps/)"),
1094
+ name: z.string().describe("Human-readable site name"),
1095
+ purpose: z.string().optional().describe("What the site is about"),
1096
+ port: z.number().optional().describe("Dev server port (auto-detected from package.json if omitted)"),
1097
+ }, async (args) => {
1098
+ try {
1099
+ const root = monorepoRoot();
1100
+ const projectDir = join(root, "apps", args.siteId);
1101
+ if (!existsSync(join(projectDir, "package.json"))) {
1102
+ return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found. Ensure the project exists.` }], isError: true };
1103
+ }
1104
+ const port = args.port || await detectSitePort(projectDir);
1105
+ // Initialize orchestrator session state
1106
+ const sessionKey = scopedSessionKey(session, args.siteId);
1107
+ setPage(sessionKey, {
1108
+ id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
1109
+ });
1110
+ bumpVersion(sessionKey);
1111
+ // Kill any existing process on the allocated port
1112
+ try {
1113
+ const { execSync } = await import("node:child_process");
1114
+ const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1115
+ if (pids) {
1116
+ for (const pid of pids.split("\n")) {
1117
+ try {
1118
+ process.kill(Number(pid), "SIGKILL");
1119
+ }
1120
+ catch { /* already dead */ }
1121
+ }
1122
+ }
1123
+ }
1124
+ catch { /* no process on port */ }
1125
+ // Install dependencies if needed
1126
+ if (!existsSync(join(projectDir, "node_modules"))) {
1127
+ const { execFile } = await import("node:child_process");
1128
+ const { promisify } = await import("node:util");
1129
+ await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
1130
+ }
1131
+ // Start dev server and wait for readiness
1132
+ const { serverReady } = await startAndWaitForDevServer({
1133
+ siteId: args.siteId, port, cwd: root, useFilter: true,
1134
+ });
1135
+ const siteConfig = {
1136
+ id: args.siteId,
1137
+ name: args.name,
1138
+ purpose: args.purpose ?? "",
1139
+ tone: "",
1140
+ hosting: "local",
1141
+ previewUrl: `http://localhost:${port}`,
1142
+ constraints: [],
1143
+ };
1144
+ emitSiteCreated(siteConfig);
1145
+ emitPhaseOutcome?.({ tool: "register_site", data: { siteId: args.siteId, port, name: args.name, serverReady } });
1146
+ return {
1147
+ content: [{
1148
+ type: "text",
1149
+ text: JSON.stringify({
1150
+ status: "registered",
1151
+ siteId: args.siteId,
1152
+ config: siteConfig,
1153
+ port,
1154
+ name: args.name,
1155
+ previewUrl: `http://localhost:${port}`,
1156
+ devServerStarted: serverReady,
1157
+ }),
1158
+ }],
1159
+ };
1160
+ }
1161
+ catch (e) {
1162
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1163
+ }
1164
+ }, { annotations: { destructiveHint: false, openWorldHint: false } });
1165
+ // ── ANALYZE CODEBASE ──
1166
+ const analyzeCodebaseTool = tool("analyze_codebase", "Analyze an existing site project to detect its framework, CMS, routes, styling, and readiness for AI Site Editor integration. Use this before integrating an existing codebase.", {
1167
+ projectPath: z.string().describe("Absolute path to the project root directory"),
1168
+ }, async (args) => {
1169
+ try {
1170
+ const analysis = await analyzeCodebase(args.projectPath);
1171
+ emitPhaseOutcome?.({ tool: "analyze_codebase", data: { framework: analysis.framework, routes: analysis.existingRoutes.length } });
1172
+ return { content: [{ type: "text", text: JSON.stringify(analysis, null, 2) }] };
1173
+ }
1174
+ catch (e) {
1175
+ return { content: [{ type: "text", text: `Error analyzing codebase: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1176
+ }
1177
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
1178
+ // ── VISUAL QA DIFF ──
1179
+ const visualQaDiffTool = tool("visual_qa_diff", "Take screenshots of the generated site and compare with original source screenshots. Returns a list of visual discrepancies found by comparing the screenshots. Use this AFTER bootstrap_pages to verify migration fidelity.", {
1180
+ generatedSiteUrl: z.string().describe("URL of the generated site to screenshot (e.g. http://localhost:3000)"),
1181
+ originalUrl: z.string().describe("URL of the original source site that was migrated"),
1182
+ }, async (args) => {
1183
+ try {
1184
+ // Take screenshots of the generated site (scrapeFullPage gives us both desktop + mobile)
1185
+ const genScrape = await scrapeFullPage(args.generatedSiteUrl);
1186
+ const genDesktop = genScrape.screenshot;
1187
+ const genMobile = genScrape.mobileScreenshot;
1188
+ // Get original screenshots from cache (should be available from prior scrape_url call)
1189
+ const cachedScrape = getCachedScrape(args.originalUrl);
1190
+ const content = [];
1191
+ content.push({ type: "text", text: "Visual QA comparison. The following images are paired: ORIGINAL then GENERATED for each viewport. Identify all visual discrepancies (colors, spacing, layout, fonts, missing images, wrong block types) and suggest specific fixes." });
1192
+ // Desktop comparison
1193
+ if (cachedScrape?.screenshot) {
1194
+ content.push({ type: "image", data: cachedScrape.screenshot.base64, mimeType: "image/jpeg" });
1195
+ content.push({ type: "text", text: "^ ORIGINAL desktop (1440px)" });
1196
+ }
1197
+ if (genDesktop) {
1198
+ content.push({ type: "image", data: genDesktop.base64, mimeType: "image/jpeg" });
1199
+ content.push({ type: "text", text: "^ GENERATED desktop (1440px)" });
1200
+ }
1201
+ // Mobile comparison
1202
+ if (cachedScrape?.mobileScreenshot && genMobile) {
1203
+ content.push({ type: "image", data: cachedScrape.mobileScreenshot.base64, mimeType: "image/jpeg" });
1204
+ content.push({ type: "text", text: "^ ORIGINAL mobile (390px)" });
1205
+ content.push({ type: "image", data: genMobile.base64, mimeType: "image/jpeg" });
1206
+ content.push({ type: "text", text: "^ GENERATED mobile (390px)" });
1207
+ }
1208
+ content.push({ type: "text", text: "List all discrepancies with severity (critical/major/minor) and suggest specific operations to fix them." });
1209
+ emitPhaseOutcome?.({ tool: "visual_qa_diff", data: { hasOriginal: !!cachedScrape?.screenshot, hasMobile: !!genMobile } });
1210
+ return { content };
1211
+ }
1212
+ catch (e) {
1213
+ return { content: [{ type: "text", text: `Error during visual QA: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1214
+ }
1215
+ }, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
1216
+ return createSdkMcpServer({
1217
+ name: "sites-agent",
1218
+ version: "1.0.0",
1219
+ tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
1220
+ });
1221
+ }
1222
+ // Template functions now live in sites-agent-shared.ts — imported at the top of this file.
1223
+ // (sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars,
1224
+ // validateAndCorrectProps, normalizePageBlocks, scaffoldSiteProject,
1225
+ // packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss,
1226
+ // defaultsTs, editorApiRoute, pageTsx, blocksRegisterTsx, samplePagesJson,
1227
+ // defaultLogoSvg, faviconSvg)