@avocadostudio-ai/orchestrator-core 0.11.8 → 0.12.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.
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Contains: prop validation/correction, CSS patching, port allocation, site ID sanitization.
6
6
  */
7
+ import { type Logger } from "../logger.ts";
7
8
  export declare function sanitizeSiteId(name: string): string;
8
9
  export declare function monorepoRoot(): string;
9
10
  /**
@@ -15,7 +16,7 @@ export declare function monorepoRoot(): string;
15
16
  * forgotten.
16
17
  */
17
18
  export declare function getDraftModeSecret(): string;
18
- export declare function findAvailablePort(root: string): Promise<number>;
19
+ export declare function findAvailablePort(root: string, log?: Logger): Promise<number>;
19
20
  export interface ThemePreset {
20
21
  name: string;
21
22
  description: string;
@@ -52,10 +53,10 @@ export type TriageResult = {
52
53
  * Fast triage using Haiku — classifies intent, extracts params, answers simple questions.
53
54
  * ~200ms, ~$0.001 per call.
54
55
  */
55
- export declare function triageWithHaiku(message: string, apiKey: string): Promise<TriageResult>;
56
+ export declare function triageWithHaiku(message: string, apiKey: string, log?: Logger): Promise<TriageResult>;
56
57
  export declare function patchGlobalsCssVars(cssPath: string, vars: Record<string, string>): Promise<void>;
57
58
  export declare function fixFooterLinks(props: Record<string, unknown>): Record<string, unknown>;
58
- export declare function validateAndCorrectProps(blockType: string, props: Record<string, unknown>): {
59
+ export declare function validateAndCorrectProps(blockType: string, props: Record<string, unknown>, log?: Logger): {
59
60
  props: Record<string, unknown>;
60
61
  corrected: boolean;
61
62
  error?: string;
@@ -67,7 +68,7 @@ export declare function validateAndCorrectProps(blockType: string, props: Record
67
68
  export declare function normalizePageBlocks(blocks: Array<{
68
69
  type: string;
69
70
  props: Record<string, unknown>;
70
- }>): {
71
+ }>, log?: Logger): {
71
72
  contentBlocks: Array<{
72
73
  id: string;
73
74
  type: string;
@@ -156,6 +157,8 @@ export declare function startAndWaitForDevServer(opts: {
156
157
  cwd: string;
157
158
  useFilter?: boolean;
158
159
  timeoutMs?: number;
160
+ /** Where progress goes. Defaults to `consoleLogger()`. */
161
+ logger?: Logger;
159
162
  }): Promise<{
160
163
  serverReady: boolean;
161
164
  }>;
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Contains: prop validation/correction, CSS patching, port allocation, site ID sanitization.
6
6
  */
7
+ import { consoleLogger } from "../logger.js";
7
8
  import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
8
9
  import { existsSync } from "node:fs";
9
10
  import { resolve, join, dirname } from "node:path";
@@ -30,7 +31,7 @@ export function monorepoRoot() {
30
31
  export function getDraftModeSecret() {
31
32
  return process.env.DRAFT_MODE_SECRET ?? "top-secret";
32
33
  }
33
- export async function findAvailablePort(root) {
34
+ export async function findAvailablePort(root, log = consoleLogger()) {
34
35
  const appsDir = join(root, "apps");
35
36
  const usedPorts = new Set([3000, 4100, 4200]);
36
37
  try {
@@ -58,7 +59,7 @@ export async function findAvailablePort(root) {
58
59
  let port = 3500;
59
60
  while (usedPorts.has(port) || !(await isPortFree(createServer, port)))
60
61
  port++;
61
- console.log(`[sites-agent] Selected port ${port} (used: ${[...usedPorts].sort().join(", ")})`);
62
+ log.info(`[sites-agent] Selected port ${port} (used: ${[...usedPorts].sort().join(", ")})`);
62
63
  return port;
63
64
  }
64
65
  function isPortFree(createServer, port) {
@@ -217,7 +218,7 @@ export const SITES_AGENT_MODELS = {
217
218
  * Fast triage using Haiku — classifies intent, extracts params, answers simple questions.
218
219
  * ~200ms, ~$0.001 per call.
219
220
  */
220
- export async function triageWithHaiku(message, apiKey) {
221
+ export async function triageWithHaiku(message, apiKey, log = consoleLogger()) {
221
222
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
222
223
  const client = new Anthropic({ apiKey });
223
224
  const response = await client.messages.create({
@@ -259,11 +260,11 @@ Extract URLs, site names, and scope constraints from the message. If not present
259
260
  // Strip markdown code fences if present
260
261
  const clean = text.replace(/^```json?\s*|\s*```$/g, "").trim();
261
262
  const parsed = JSON.parse(clean);
262
- console.log(`[sites-agent] Triage: intent=${parsed.intent} url=${parsed.url ?? "-"} site=${parsed.siteName ?? "-"} scope=${parsed.scope ?? "-"}`);
263
+ log.info(`[sites-agent] Triage: intent=${parsed.intent} url=${parsed.url ?? "-"} site=${parsed.siteName ?? "-"} scope=${parsed.scope ?? "-"}`);
263
264
  return parsed;
264
265
  }
265
266
  catch {
266
- console.log(`[sites-agent] Triage parse failed, defaulting to migrate: ${text.slice(0, 100)}`);
267
+ log.info(`[sites-agent] Triage parse failed, defaulting to migrate: ${text.slice(0, 100)}`);
267
268
  return { intent: "migrate" };
268
269
  }
269
270
  }
@@ -366,7 +367,7 @@ function renameKeys(obj, renames) {
366
367
  }
367
368
  return result;
368
369
  }
369
- export function validateAndCorrectProps(blockType, props) {
370
+ export function validateAndCorrectProps(blockType, props, log = consoleLogger()) {
370
371
  const first = validateBlockProps(blockType, props);
371
372
  if (first.success)
372
373
  return { props, corrected: false };
@@ -382,7 +383,7 @@ export function validateAndCorrectProps(blockType, props) {
382
383
  corrected = fixFooterLinks(corrected);
383
384
  const second = validateBlockProps(blockType, corrected);
384
385
  if (second.success) {
385
- console.log(`[sites-agent] Auto-corrected ${blockType} props`);
386
+ log.info(`[sites-agent] Auto-corrected ${blockType} props`);
386
387
  return { props: corrected, corrected: true };
387
388
  }
388
389
  const errorMsg = first.success === false && "error" in first
@@ -394,7 +395,7 @@ export function validateAndCorrectProps(blockType, props) {
394
395
  * Normalize and validate blocks for a page — applies prop correction, strips chrome blocks,
395
396
  * extracts footer. Shared between SDK and stdio bootstrap_pages implementations.
396
397
  */
397
- export function normalizePageBlocks(blocks) {
398
+ export function normalizePageBlocks(blocks, log = consoleLogger()) {
398
399
  let strippedCount = 0;
399
400
  let footerBlock = null;
400
401
  const contentBlocks = blocks
@@ -432,7 +433,7 @@ export function normalizePageBlocks(blocks) {
432
433
  }
433
434
  }
434
435
  else if (validation.error) {
435
- console.warn(`[sites-agent] ${b.type} block validation: ${validation.error}`);
436
+ log.warn(`[sites-agent] ${b.type} block validation: ${validation.error}`);
436
437
  }
437
438
  }
438
439
  return { id: `b_${b.type.toLowerCase()}_${i + 1}`, type: b.type, props: mergedProps };
@@ -1043,6 +1044,7 @@ export async function detectSitePort(projectDir) {
1043
1044
  * Replaces fire-and-forget spawn patterns that reported success before the server was up.
1044
1045
  */
1045
1046
  export async function startAndWaitForDevServer(opts) {
1047
+ const log = opts.logger ?? consoleLogger();
1046
1048
  const { spawn } = await import("node:child_process");
1047
1049
  const args = opts.useFilter
1048
1050
  ? ["--filter", `@ai-site-editor/${opts.siteId}`, "dev"]
@@ -1058,11 +1060,11 @@ export async function startAndWaitForDevServer(opts) {
1058
1060
  }
1059
1061
  catch { /* already dead */ }
1060
1062
  }
1061
- console.log(`[sites-agent] Killed existing process(es) on port ${opts.port}`);
1063
+ log.info(`[sites-agent] Killed existing process(es) on port ${opts.port}`);
1062
1064
  }
1063
1065
  }
1064
1066
  catch { /* no process on port — fine */ }
1065
- console.log(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port}...`);
1067
+ log.info(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port}...`);
1066
1068
  const devProcess = spawn("pnpm", args, {
1067
1069
  cwd: opts.cwd,
1068
1070
  stdio: ["ignore", "pipe", "pipe"],
@@ -1097,6 +1099,6 @@ export async function startAndWaitForDevServer(opts) {
1097
1099
  devProcess.stderr?.on("data", onData);
1098
1100
  devProcess.on("exit", () => { clearTimeout(timer); resolve(); });
1099
1101
  });
1100
- console.log(`[sites-agent] Dev server ${serverReady ? "ready" : "timed out"} for ${opts.siteId} at http://localhost:${opts.port}`);
1102
+ log.info(`[sites-agent] Dev server ${serverReady ? "ready" : "timed out"} for ${opts.siteId} at http://localhost:${opts.port}`);
1101
1103
  return { serverReady };
1102
1104
  }
@@ -5,6 +5,7 @@
5
5
  * - list_sites, create_site, scrape_url (with screenshot), extract_design_tokens,
6
6
  * bootstrap_pages, download_remote_image, apply_theme, discover_site_structure
7
7
  */
8
+ import { type Logger } from "../logger.ts";
8
9
  /**
9
10
  * Create the MCP server with all sites-agent tools.
10
11
  */
@@ -15,4 +16,15 @@ export declare function createSitesAgentMcpServer(options: {
15
16
  tool: string;
16
17
  data: Record<string, unknown>;
17
18
  }) => void;
19
+ /**
20
+ * Where these tools report progress. Defaults to `consoleLogger()`.
21
+ *
22
+ * It matters that this is a parameter. This module is published, and it had
23
+ * forty-two bare `console.log` calls writing `[create_site] …` onto whatever
24
+ * stdout the host happened to own — unsilenceable, unstructured, and outside
25
+ * the `Logger` seam the rest of the package already threads through. The
26
+ * Fastify host passes its own request logger; a library consumer passes
27
+ * theirs, or takes the console default knowingly.
28
+ */
29
+ logger?: Logger;
18
30
  }): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path";
10
10
  import { existsSync } from "node:fs";
11
11
  import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { createMigrationTools } from "../migration/migration-tools.js";
13
+ import { consoleLogger } from "../logger.js";
13
14
  import { z } from "zod";
14
15
  import { getAllBlockMeta, defaultPropsForType } from "@avocadostudio-ai/shared";
15
16
  import { fetchPageContent, downloadImage, extractDesignTokens, mapToThemeVariables, discoverSitePages, scrapeFullPage } from "@avocadostudio-ai/migration-sdk";
@@ -34,18 +35,18 @@ function isRemoteImageUrl(url) {
34
35
  return IMAGE_URL_RE.test(url) || IMAGE_CDN_RE.test(url);
35
36
  }
36
37
  /** Recursively walk props and download any remote image URLs, replacing with local paths. */
37
- async function localizeRemoteImages(props, imagesDir) {
38
+ async function localizeRemoteImages(props, imagesDir, log) {
38
39
  let downloaded = 0;
39
40
  async function walk(value) {
40
41
  if (typeof value === "string" && isRemoteImageUrl(value)) {
41
42
  try {
42
43
  const result = await downloadImage(value, undefined, imagesDir);
43
44
  downloaded++;
44
- console.log(`[sites-agent] Auto-downloaded remote image: ${value} → ${result.localPath}`);
45
+ log.info(`[sites-agent] Auto-downloaded remote image: ${value} → ${result.localPath}`);
45
46
  return `/images/${result.fileName}`;
46
47
  }
47
48
  catch {
48
- console.warn(`[sites-agent] Failed to auto-download image: ${value}`);
49
+ log.warn(`[sites-agent] Failed to auto-download image: ${value}`);
49
50
  return value;
50
51
  }
51
52
  }
@@ -70,6 +71,7 @@ async function localizeRemoteImages(props, imagesDir) {
70
71
  */
71
72
  export function createSitesAgentMcpServer(options) {
72
73
  const { session, emitSiteCreated, emitPhaseOutcome } = options;
74
+ const log = options.logger ?? consoleLogger();
73
75
  // ── LIST SITES ──
74
76
  const listSitesTool = tool("list_sites", "List all site project directories in the monorepo apps/ folder.", {}, async () => {
75
77
  try {
@@ -99,9 +101,9 @@ export function createSitesAgentMcpServer(options) {
99
101
  url: z.string().describe("Homepage URL of the site to analyze, e.g. 'https://example.com'"),
100
102
  }, async (args) => {
101
103
  try {
102
- console.log(`[discover_structure] Discovering pages on ${args.url}...`);
104
+ log.info(`[discover_structure] Discovering pages on ${args.url}...`);
103
105
  const structure = await discoverSitePages(args.url);
104
- console.log(`[discover_structure] Found ${structure.totalFound} pages via ${structure.source} on ${structure.origin}`);
106
+ log.info(`[discover_structure] Found ${structure.totalFound} pages via ${structure.source} on ${structure.origin}`);
105
107
  emitPhaseOutcome?.({ tool: "discover_site_structure", data: { totalPages: structure.totalFound, origin: structure.origin } });
106
108
  return {
107
109
  content: [{
@@ -140,7 +142,7 @@ export function createSitesAgentMcpServer(options) {
140
142
  if (existsSync(target))
141
143
  await rm(target, { recursive: true, force: true });
142
144
  }
143
- console.log(`[sites-agent] Cleaned previous content from apps/${siteId}`);
145
+ log.info(`[sites-agent] Cleaned previous content from apps/${siteId}`);
144
146
  // Reuse the existing port if not explicitly overridden
145
147
  if (!args.port) {
146
148
  try {
@@ -163,49 +165,54 @@ export function createSitesAgentMcpServer(options) {
163
165
  s.listen(port, "127.0.0.1");
164
166
  });
165
167
  if (!free) {
166
- console.log(`[create_site] Requested port ${port} in use, auto-assigning...`);
168
+ log.info(`[create_site] Requested port ${port} in use, auto-assigning...`);
167
169
  port = 0;
168
170
  }
169
171
  }
170
172
  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");
173
+ port = await findAvailablePort(root, log);
174
+ log.info(`[create_site] START siteId=${siteId} port=${port} name="${args.name}"`);
175
+ // Create project structure.
176
+ //
177
+ // One entry per file, and the log line below counts the list rather than
178
+ // a number someone typed. It used to say "Wrote 13 project files" while
179
+ // writing fifteen — the count was right when the sequence of writeFile
180
+ // calls above it was shorter, and nothing connected the two.
196
181
  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}/`);
182
+ const projectFiles = [
183
+ ["package.json", packageJson(siteId, args.name, port)],
184
+ ["next.config.ts", nextConfigTs()],
185
+ ["tsconfig.json", tsconfigJson()],
186
+ ["postcss.config.mjs", postcssConfig()],
187
+ ["app/layout.tsx", layoutTsx(args.name)],
188
+ ["app/globals.css", globalsCss()],
189
+ ["app/api/editor/[...path]/route.ts", editorApiRoute()],
190
+ ["app/[[...slug]]/page.tsx", pageTsx(siteId)],
191
+ ["content/pages.json", samplePagesJson()],
192
+ ["lib/defaults.ts", defaultsTs(siteId, args.name)],
193
+ ["public/.gitkeep", ""],
194
+ ["blocks/register.tsx", blocksRegisterTsx()],
195
+ ["public/logo.svg", defaultLogoSvg(args.name)],
196
+ ["public/favicon.svg", faviconSvg(args.name)],
197
+ [".env.local", envContent]
198
+ ];
199
+ await mkdir(projectDir, { recursive: true });
200
+ for (const [relativePath, contents] of projectFiles) {
201
+ const target = join(projectDir, relativePath);
202
+ await mkdir(dirname(target), { recursive: true });
203
+ await writeFile(target, contents, "utf-8");
204
+ }
205
+ log.info(`[create_site] Wrote ${projectFiles.length} project files to apps/${siteId}/`);
199
206
  // Run pnpm install if needed (skip if node_modules exists from previous run)
200
207
  if (!existsSync(join(projectDir, "node_modules"))) {
201
- console.log(`[create_site] Running pnpm install...`);
208
+ log.info(`[create_site] Running pnpm install...`);
202
209
  const { execFile } = await import("node:child_process");
203
210
  const { promisify } = await import("node:util");
204
211
  await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
205
- console.log(`[create_site] pnpm install complete`);
212
+ log.info(`[create_site] pnpm install complete`);
206
213
  }
207
214
  else {
208
- console.log(`[create_site] Skipping pnpm install — node_modules exists`);
215
+ log.info(`[create_site] Skipping pnpm install — node_modules exists`);
209
216
  }
210
217
  const siteConfig = {
211
218
  id: siteId,
@@ -223,14 +230,15 @@ export function createSitesAgentMcpServer(options) {
223
230
  });
224
231
  bumpVersion(sessionKey);
225
232
  // Start dev server and wait for it to be ready
226
- console.log(`[create_site] Starting dev server on port ${port}...`);
233
+ log.info(`[create_site] Starting dev server on port ${port}...`);
227
234
  const { serverReady } = await startAndWaitForDevServer({
235
+ logger: log,
228
236
  siteId, port, cwd: root, useFilter: true,
229
237
  });
230
- console.log(`[create_site] Dev server ${serverReady ? "READY" : "FAILED"} on port ${port}`);
238
+ log.info(`[create_site] Dev server ${serverReady ? "READY" : "FAILED"} on port ${port}`);
231
239
  emitSiteCreated(siteConfig);
232
240
  emitPhaseOutcome?.({ tool: "create_site", data: { siteId, port, name: args.name, serverReady } });
233
- console.log(`[create_site] DONE siteId=${siteId} port=${port} serverReady=${serverReady}`);
241
+ log.info(`[create_site] DONE siteId=${siteId} port=${port} serverReady=${serverReady}`);
234
242
  return {
235
243
  content: [{
236
244
  type: "text",
@@ -248,7 +256,7 @@ export function createSitesAgentMcpServer(options) {
248
256
  };
249
257
  }
250
258
  catch (e) {
251
- console.error(`[create_site] ERROR: ${e instanceof Error ? e.message : String(e)}`);
259
+ log.error(`[create_site] ERROR: ${e instanceof Error ? e.message : String(e)}`);
252
260
  return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
253
261
  }
254
262
  }, { annotations: { destructiveHint: false, openWorldHint: false } });
@@ -261,12 +269,12 @@ export function createSitesAgentMcpServer(options) {
261
269
  let result = getCachedScrape(args.url);
262
270
  const cached = !!result;
263
271
  if (!result) {
264
- console.log(`[scrape_url] Scraping ${args.url} (browser)...`);
272
+ log.info(`[scrape_url] Scraping ${args.url} (browser)...`);
265
273
  result = await scrapeFullPage(args.url);
266
274
  setCachedScrape(args.url, result);
267
275
  }
268
276
  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}`);
277
+ log.info(`[scrape_url] ${cached ? "CACHED" : "SCRAPED"} ${args.url} in ${Date.now() - scrapeStart}ms — ${sections.length} sections, screenshot=${!!screenshot}, nav=${!!nav}`);
270
278
  // Extract design tokens from CSS (with resolved CSS variables from Playwright)
271
279
  const tokens = extractDesignTokens(content.css, result.resolvedCssVars);
272
280
  const themeVars = mapToThemeVariables(tokens);
@@ -305,13 +313,13 @@ export function createSitesAgentMcpServer(options) {
305
313
  if (mobileScreenshot)
306
314
  writes.push(saveScreenshot("mobile", mobileScreenshot.base64, args.url));
307
315
  if (writes.length)
308
- Promise.all(writes).then(() => console.log(`[scrape_url] Screenshots saved`)).catch(() => { });
316
+ Promise.all(writes).then(() => log.info(`[scrape_url] Screenshots saved`)).catch(() => { });
309
317
  return { content: contentBlocks };
310
318
  }
311
319
  catch (e) {
312
320
  // Fall back to simple HTTP fetch (no browser)
313
321
  try {
314
- console.warn(`[sites-agent] Browser scrape failed, falling back to HTTP: ${e instanceof Error ? e.message : String(e)}`);
322
+ log.warn(`[sites-agent] Browser scrape failed, falling back to HTTP: ${e instanceof Error ? e.message : String(e)}`);
315
323
  const content = await fetchPageContent(args.url);
316
324
  const { extractSections: extract, extractPageOutline: outline, extractNavigation: navExtract } = await import("@avocadostudio-ai/migration-sdk");
317
325
  const sections = extract(content.html, content.baseUrl);
@@ -391,12 +399,12 @@ export function createSitesAgentMcpServer(options) {
391
399
  try {
392
400
  const totalBlocks = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
393
401
  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(", ")}]`);
402
+ log.info(`[bootstrap_pages] START siteId=${args.siteId} pages=${args.pages.length} blocks=${totalBlocks} slugs=[${slugs.join(", ")}]`);
395
403
  // Verify the site project was scaffolded first
396
404
  const root = monorepoRoot();
397
405
  const projectPkg = join(root, "apps", args.siteId, "package.json");
398
406
  if (!existsSync(projectPkg)) {
399
- console.error(`[bootstrap_pages] GUARD FAILED: apps/${args.siteId}/package.json not found — create_site was not called`);
407
+ log.error(`[bootstrap_pages] GUARD FAILED: apps/${args.siteId}/package.json not found — create_site was not called`);
400
408
  return { content: [{ type: "text", text: `Error: Site project apps/${args.siteId} does not exist. Call create_site first.` }], isError: true };
401
409
  }
402
410
  const sessionKey = scopedSessionKey(session, args.siteId);
@@ -414,7 +422,7 @@ export function createSitesAgentMcpServer(options) {
414
422
  // Process pages sequentially to avoid unbounded concurrent image downloads
415
423
  const pageDocs = [];
416
424
  for (const [pageIdx, page] of normalizedPages.entries()) {
417
- console.log(`[bootstrap_pages] Processing page ${pageIdx + 1}/${normalizedPages.length}: ${page.slug} (${page.blocks.length} blocks)`);
425
+ log.info(`[bootstrap_pages] Processing page ${pageIdx + 1}/${normalizedPages.length}: ${page.slug} (${page.blocks.length} blocks)`);
418
426
  const contentBlocks = page.blocks.filter(b => {
419
427
  if (b.type === "SiteHeader") {
420
428
  strippedCount++;
@@ -423,7 +431,7 @@ export function createSitesAgentMcpServer(options) {
423
431
  if (b.type === "Footer") {
424
432
  if (!footerBlock) {
425
433
  let fp = fixFooterLinks(b.props);
426
- const v = validateAndCorrectProps("Footer", fp);
434
+ const v = validateAndCorrectProps("Footer", fp, log);
427
435
  if (v.corrected)
428
436
  fp = v.props;
429
437
  footerBlock = { type: b.type, props: fp };
@@ -441,26 +449,26 @@ export function createSitesAgentMcpServer(options) {
441
449
  mergedProps[key] = value;
442
450
  }
443
451
  if (blockMeta) {
444
- const validation = validateAndCorrectProps(b.type, mergedProps);
452
+ const validation = validateAndCorrectProps(b.type, mergedProps, log);
445
453
  if (validation.corrected) {
446
- console.log(`[bootstrap_pages] Block ${b.type}: auto-corrected props`);
454
+ log.info(`[bootstrap_pages] Block ${b.type}: auto-corrected props`);
447
455
  mergedProps = { ...baseProps };
448
456
  for (const [key, value] of Object.entries(validation.props)) {
449
457
  mergedProps[key] = value;
450
458
  }
451
459
  }
452
460
  else if (validation.error) {
453
- console.warn(`[bootstrap_pages] Block ${b.type}: validation error — ${validation.error}`);
461
+ log.warn(`[bootstrap_pages] Block ${b.type}: validation error — ${validation.error}`);
454
462
  }
455
463
  }
456
464
  else if (!getAllBlockMeta()[b.type]) {
457
- console.log(`[bootstrap_pages] Block ${b.type}: custom type (not in built-in registry)`);
465
+ log.info(`[bootstrap_pages] Block ${b.type}: custom type (not in built-in registry)`);
458
466
  }
459
467
  return { id: `b_${b.type.toLowerCase()}_${i + 1}`, type: b.type, props: mergedProps };
460
468
  });
461
469
  // Auto-download any remote image URLs the agent missed
462
470
  for (const block of blocks) {
463
- const { props: localizedProps, downloaded } = await localizeRemoteImages(block.props, imagesDir);
471
+ const { props: localizedProps, downloaded } = await localizeRemoteImages(block.props, imagesDir, log);
464
472
  if (downloaded > 0) {
465
473
  block.props = localizedProps;
466
474
  totalAutoDownloaded += downloaded;
@@ -476,9 +484,9 @@ export function createSitesAgentMcpServer(options) {
476
484
  });
477
485
  }
478
486
  if (strippedCount > 0)
479
- console.log(`[sites-agent] Stripped ${strippedCount} chrome blocks (SiteHeader/Footer)`);
487
+ log.info(`[sites-agent] Stripped ${strippedCount} chrome blocks (SiteHeader/Footer)`);
480
488
  if (totalAutoDownloaded > 0)
481
- console.log(`[sites-agent] Auto-downloaded ${totalAutoDownloaded} remote images as safety net`);
489
+ log.info(`[sites-agent] Auto-downloaded ${totalAutoDownloaded} remote images as safety net`);
482
490
  // Write to session state
483
491
  for (const doc of pageDocs)
484
492
  setPage(sessionKey, doc);
@@ -523,10 +531,10 @@ export function createSitesAgentMcpServer(options) {
523
531
  }
524
532
  await mkdir(dirname(pagesJsonPath), { recursive: true });
525
533
  await writeFile(pagesJsonPath, JSON.stringify(allPages, null, 2) + "\n", "utf-8");
526
- console.log(`[sites-agent] Wrote ${allPages.length} pages to ${pagesJsonPath}`);
534
+ log.info(`[sites-agent] Wrote ${allPages.length} pages to ${pagesJsonPath}`);
527
535
  }
528
536
  catch (writeErr) {
529
- console.warn(`[sites-agent] Failed to write pages.json:`, writeErr);
537
+ log.warn({ err: writeErr }, "[sites-agent] Failed to write pages.json");
530
538
  }
531
539
  // Persist site config (nav labels, nav groups, logo, name, footer) to content/site-config.json
532
540
  {
@@ -559,25 +567,25 @@ export function createSitesAgentMcpServer(options) {
559
567
  config.footer = { id: "chrome_footer", type: fb.type, props: fb.props };
560
568
  }
561
569
  await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
562
- console.log(`[sites-agent] Wrote site config to ${configPath}`);
570
+ log.info(`[sites-agent] Wrote site config to ${configPath}`);
563
571
  }
564
572
  catch (err) {
565
- console.warn(`[sites-agent] Failed to write site-config.json:`, err);
573
+ log.warn({ err }, "[sites-agent] Failed to write site-config.json");
566
574
  }
567
575
  }
568
576
  // Persist theme overrides to globals.css
569
577
  if (args.themeOverrides && Object.keys(args.themeOverrides).length > 0) {
570
578
  try {
571
579
  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`);
580
+ log.info(`[sites-agent] Updated ${Object.keys(args.themeOverrides).length} theme vars in globals.css`);
573
581
  }
574
582
  catch (cssErr) {
575
- console.warn(`[sites-agent] Failed to update globals.css:`, cssErr);
583
+ log.warn({ err: cssErr }, "[sites-agent] Failed to update globals.css");
576
584
  }
577
585
  }
578
586
  const totalBlocksFinal = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
579
587
  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}`);
588
+ log.info(`[bootstrap_pages] DONE siteId=${args.siteId} pages=${createdPages.length} blocks=${totalBlocksFinal} autoDownloaded=${totalAutoDownloaded}`);
581
589
  return {
582
590
  content: [{
583
591
  type: "text",
@@ -591,7 +599,7 @@ export function createSitesAgentMcpServer(options) {
591
599
  };
592
600
  }
593
601
  catch (e) {
594
- console.error(`[bootstrap_pages] ERROR: ${e instanceof Error ? e.message : String(e)}`);
602
+ log.error(`[bootstrap_pages] ERROR: ${e instanceof Error ? e.message : String(e)}`);
595
603
  return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
596
604
  }
597
605
  }, { annotations: { destructiveHint: false, openWorldHint: false } });
@@ -604,15 +612,15 @@ export function createSitesAgentMcpServer(options) {
604
612
  try {
605
613
  const root = monorepoRoot();
606
614
  const outputDir = join(root, "apps", args.siteId, "public", "images");
607
- console.log(`[download_image] ${args.url.slice(0, 100)}`);
615
+ log.info(`[download_image] ${args.url.slice(0, 100)}`);
608
616
  const result = await downloadImage(args.url, args.alt, outputDir);
609
617
  const localUrl = `/images/${result.fileName}`;
610
- console.log(`[download_image] OK → ${localUrl}`);
618
+ log.info(`[download_image] OK → ${localUrl}`);
611
619
  emitPhaseOutcome?.({ tool: "download_remote_image", data: { fileName: result.fileName } });
612
620
  return { content: [{ type: "text", text: JSON.stringify({ localUrl, fileName: result.fileName }) }] };
613
621
  }
614
622
  catch (e) {
615
- console.warn(`[download_image] FAILED ${args.url.slice(0, 100)} — ${e instanceof Error ? e.message : String(e)}`);
623
+ log.warn(`[download_image] FAILED ${args.url.slice(0, 100)} — ${e instanceof Error ? e.message : String(e)}`);
616
624
  return { content: [{ type: "text", text: JSON.stringify({ localUrl: args.url, error: "Download failed, using original URL" }) }] };
617
625
  }
618
626
  }, { annotations: { destructiveHint: false, openWorldHint: true } });
@@ -644,7 +652,7 @@ export function createSitesAgentMcpServer(options) {
644
652
  }
645
653
  const succeeded = results.filter(r => !r.error).length;
646
654
  emitPhaseOutcome?.({ tool: "download_remote_images", data: { succeeded, total: results.length } });
647
- console.log(`[sites-agent] Batch downloaded ${succeeded}/${results.length} images`);
655
+ log.info(`[sites-agent] Batch downloaded ${succeeded}/${results.length} images`);
648
656
  return { content: [{ type: "text", text: JSON.stringify({ results, succeeded, failed: results.length - succeeded }) }] };
649
657
  }, { annotations: { destructiveHint: false, openWorldHint: true } });
650
658
  // ── BROWSE GOOGLE DRIVE IMAGES ──
@@ -710,7 +718,7 @@ export function createSitesAgentMcpServer(options) {
710
718
  contentBlocks.push({ type: "text", text: `↑ ${r.name} → ${r.localUrl}` });
711
719
  }
712
720
  }
713
- console.log(`[sites-agent] Browsed GDrive: ${manifest.length}/${files.length} images downloaded`);
721
+ log.info(`[sites-agent] Browsed GDrive: ${manifest.length}/${files.length} images downloaded`);
714
722
  return {
715
723
  content: [
716
724
  { type: "text", text: `Found ${manifest.length} images. Thumbnails below — use localUrl paths in block props.\n${JSON.stringify(manifest, null, 2)}` },
@@ -734,7 +742,7 @@ export function createSitesAgentMcpServer(options) {
734
742
  await patchGlobalsCssVars(join(monorepoRoot(), "apps", args.siteId, "app", "globals.css"), args.variables);
735
743
  }
736
744
  catch (err) {
737
- console.warn(`[sites-agent] Failed to persist theme to globals.css:`, err);
745
+ log.warn({ err }, "[sites-agent] Failed to persist theme to globals.css");
738
746
  }
739
747
  return {
740
748
  content: [{
@@ -973,8 +981,8 @@ export function createSitesAgentMcpServer(options) {
973
981
  const { execSync } = await import("node:child_process");
974
982
  const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
975
983
  if (pids) {
976
- console.log(`[integrate_site] Port ${port} in use, finding free port...`);
977
- port = await findAvailablePort(root);
984
+ log.info(`[integrate_site] Port ${port} in use, finding free port...`);
985
+ port = await findAvailablePort(root, log);
978
986
  const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
979
987
  if (pkg.scripts?.dev) {
980
988
  pkg.scripts.dev = pkg.scripts.dev.replace(/-p\s*\d+/, `-p ${port}`);
@@ -992,6 +1000,7 @@ export function createSitesAgentMcpServer(options) {
992
1000
  // Start dev server and wait for ready
993
1001
  const previewUrl = `http://localhost:${port}`;
994
1002
  const { serverReady } = await startAndWaitForDevServer({
1003
+ logger: log,
995
1004
  siteId: args.siteId, port, cwd: projectDir,
996
1005
  });
997
1006
  // Register in editor dashboard (emits site_created SSE event)
@@ -1043,8 +1052,8 @@ export function createSitesAgentMcpServer(options) {
1043
1052
  const { execSync } = await import("node:child_process");
1044
1053
  const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
1045
1054
  if (pids) {
1046
- console.log(`[sites-agent] Port ${port} in use, finding free port...`);
1047
- port = await findAvailablePort(root);
1055
+ log.info(`[sites-agent] Port ${port} in use, finding free port...`);
1056
+ port = await findAvailablePort(root, log);
1048
1057
  // Update dev script with new port
1049
1058
  const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
1050
1059
  if (pkg.scripts?.dev) {
@@ -1063,6 +1072,7 @@ export function createSitesAgentMcpServer(options) {
1063
1072
  // Start dev server and wait for it to be ready
1064
1073
  const previewUrl = `http://localhost:${port}`;
1065
1074
  const { serverReady } = await startAndWaitForDevServer({
1075
+ logger: log,
1066
1076
  siteId: args.siteId, port, cwd: projectDir,
1067
1077
  });
1068
1078
  const siteName = args.name ?? args.siteId;
@@ -1130,6 +1140,7 @@ export function createSitesAgentMcpServer(options) {
1130
1140
  }
1131
1141
  // Start dev server and wait for readiness
1132
1142
  const { serverReady } = await startAndWaitForDevServer({
1143
+ logger: log,
1133
1144
  siteId: args.siteId, port, cwd: root, useFilter: true,
1134
1145
  });
1135
1146
  const siteConfig = {
@@ -1,7 +1,30 @@
1
- const ENABLED_RE = /^(1|true|yes|on)$/i;
1
+ import { envFlag } from "../env-flags.js";
2
+ /**
3
+ * Prompt caching is ON by default. Every Anthropic request from this package —
4
+ * planner, intent parser, agent loop, variation pipeline — ships the same large
5
+ * stable system prefix plus the `submit_edit_plan` tool schema, which is the
6
+ * shape caching exists for. Set `ANTHROPIC_PROMPT_CACHE=0` to opt out.
7
+ *
8
+ * It defaulted to `false` until 2026-09-17, and the default was the whole
9
+ * setting: only this repo's own `.env` ever named the variable, so CI eval and
10
+ * e2e runs, `pnpm eval` (`eval-cli.ts` never loads dotenv), every deployment,
11
+ * and every library-mode integrator billed full price on a prefix that was
12
+ * byte-identical across requests. A default is the value almost everyone runs.
13
+ */
2
14
  function promptCacheEnabled() {
3
- return ENABLED_RE.test((process.env.ANTHROPIC_PROMPT_CACHE ?? "").trim());
15
+ return envFlag("ANTHROPIC_PROMPT_CACHE", true);
4
16
  }
17
+ /**
18
+ * Unset means the 5-minute TTL, and on this traffic that is the cheaper one —
19
+ * do not "upgrade" it to `1h` globally. The 1h entry costs 2x to write against
20
+ * 1.25x for 5m, and it only repays that on requests whose start-to-start gap
21
+ * exceeds 5 minutes. Replaying 651 real planner calls from
22
+ * `.data/chat-telemetry.ndjson` (2026-06 onward): most sit under a minute
23
+ * apart, only 44 fell in the 5-60 minute window the longer TTL rescues, and
24
+ * paying 2x on every other write to buy those back came out 20-32% *more*
25
+ * expensive. Set `ANTHROPIC_PROMPT_CACHE_TTL=1h` per-deployment if a given
26
+ * install's traffic really is that bursty — measure it there first.
27
+ */
5
28
  function promptCacheTtl() {
6
29
  const raw = (process.env.ANTHROPIC_PROMPT_CACHE_TTL ?? "").trim();
7
30
  if (raw === "5m" || raw === "1h")
@@ -7,6 +7,7 @@ import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
7
7
  import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
8
8
  import { getSiteAssets } from "../state/site-assets.js";
9
9
  import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
10
+ import { envFlag } from "../env-flags.js";
10
11
  import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
11
12
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
12
13
  import { clarificationSuggestions, postEditSuggestions, keepDemoExecutable, demoPlanFromMessage, withKeylessNotice, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
@@ -472,7 +473,7 @@ export async function runChatPipeline(ctx, body, options) {
472
473
  // downgrade below. Honors an explicit caller provider/modelKey and falls back
473
474
  // to the prior auto-routing when Anthropic is unavailable. Disable with
474
475
  // CHAT_PLANNER_FORCE_SONNET=0. See CLAUDE.md § Chat Pipeline Flags.
475
- const forceSonnet = /^(1|true|yes|on)$/i.test((process.env.CHAT_PLANNER_FORCE_SONNET ?? "").trim()) &&
476
+ const forceSonnet = envFlag("CHAT_PLANNER_FORCE_SONNET", false) &&
476
477
  !body.provider &&
477
478
  !body.modelKey &&
478
479
  ctx.availableProviders.includes("anthropic");
@@ -697,12 +698,12 @@ export async function runChatPipeline(ctx, body, options) {
697
698
  imageResolutionDurationMs: imageResolutionDurationMs > 0 ? imageResolutionDurationMs : undefined,
698
699
  planningAttempts: planningAttempts > 0 ? planningAttempts : undefined
699
700
  });
700
- const incrementalApplyEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_APPLY ?? "1").trim());
701
+ const incrementalApplyEnabled = envFlag("CHAT_INCREMENTAL_APPLY", true);
701
702
  const streamedApplyMinStepMsRaw = Number(process.env.CHAT_STREAM_APPLY_MIN_STEP_MS ?? 260);
702
703
  const streamedApplyMinStepMs = Number.isFinite(streamedApplyMinStepMsRaw) && streamedApplyMinStepMsRaw > 0
703
704
  ? Math.min(Math.max(Math.trunc(streamedApplyMinStepMsRaw), 1), 2000)
704
705
  : 0;
705
- const incrementalPlanStreamEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_PLAN_STREAM ?? "1").trim());
706
+ const incrementalPlanStreamEnabled = envFlag("CHAT_INCREMENTAL_PLAN_STREAM", true);
706
707
  const withDebugPayload = (payload, extra) => ({
707
708
  ...payload,
708
709
  debug: {
@@ -1056,8 +1057,8 @@ export async function runChatPipeline(ctx, body, options) {
1056
1057
  includeFullProps: translationScope === "page" || contentQuery || requestsPlanFirst(plannerMessage),
1057
1058
  attachments: body.attachments
1058
1059
  });
1059
- const compactContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_COMPACT_CONTEXT_EXPERIMENT ?? "").trim());
1060
- const minimalContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_MINIMAL_CONTEXT_EXPERIMENT ?? "").trim());
1060
+ const compactContextExperimentEnabled = envFlag("CHAT_COMPACT_CONTEXT_EXPERIMENT", false);
1061
+ const minimalContextExperimentEnabled = envFlag("CHAT_MINIMAL_CONTEXT_EXPERIMENT", false);
1061
1062
  const intentComplexity = !contentQuery
1062
1063
  ? classifyMessageComplexity({
1063
1064
  message: plannerMessage,
@@ -1535,7 +1536,7 @@ export async function runChatPipeline(ctx, body, options) {
1535
1536
  // Defer image resolution: apply text/structural ops immediately, then
1536
1537
  // resolve images in the background and patch them in via follow-up events.
1537
1538
  // This avoids blocking the user for 1-15s while images are fetched/generated.
1538
- const deferImageResolution = !/^(0|false|no|off)$/i.test((process.env.CHAT_DEFER_IMAGE_RESOLUTION ?? "1").trim());
1539
+ const deferImageResolution = envFlag("CHAT_DEFER_IMAGE_RESOLUTION", true);
1539
1540
  if (!usedNativeUnsplashTool && !usedNativeImageTool && deferImageResolution) {
1540
1541
  // Tag the plan with deferred image metadata so the apply step can
1541
1542
  // resolve images after text ops are visible.
@@ -3197,7 +3198,7 @@ export async function runChatPipeline(ctx, body, options) {
3197
3198
  return compoundOutcome.response;
3198
3199
  }
3199
3200
  }
3200
- const llmIntentRouterEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_LLM_INTENT_ROUTER ?? "1").trim());
3201
+ const llmIntentRouterEnabled = envFlag("CHAT_LLM_INTENT_ROUTER", true);
3201
3202
  // Skip the intent router for batch-add requests — the router's deterministic
3202
3203
  // compile can't generate meaningful content for "add 3 blocks" without specific
3203
3204
  // block types, so go straight to the full planner which can handle it.
@@ -3228,7 +3229,7 @@ export async function runChatPipeline(ctx, body, options) {
3228
3229
  // full planner concurrently. The router gets a ~200ms head start. If it succeeds
3229
3230
  // and produces a valid plan, we abort the full planner and return immediately.
3230
3231
  // If it fails, the full planner is already running — no wasted time.
3231
- const parallelPlannerEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_PARALLEL_PLANNER ?? "1").trim());
3232
+ const parallelPlannerEnabled = envFlag("CHAT_PARALLEL_PLANNER", true);
3232
3233
  const routerHeadStartMs = Math.max(0, Math.min(Number(process.env.CHAT_ROUTER_HEAD_START_MS ?? 200), 1000));
3233
3234
  // Request-scoped registry. Closures over *Impl vars so test hooks
3234
3235
  // (setGeneratePlanWith*ForTests) still take effect.
@@ -3814,7 +3815,7 @@ export async function runChatPipeline(ctx, body, options) {
3814
3815
  // sees changes at ~800ms intervals instead of waiting for the full plan.
3815
3816
  const streamedPerOpApplyEnabled = incrementalApplyEnabled &&
3816
3817
  incrementalPlanStreamEnabled &&
3817
- !/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
3818
+ envFlag("CHAT_STREAMED_OP_APPLY", true) &&
3818
3819
  Boolean(options?.onOpApplied);
3819
3820
  // Raised when an attempt dies on `max_tokens`. Retrying a truncation with the
3820
3821
  // same budget reproduces it exactly — three identical failures cost ~250s and
@@ -0,0 +1,17 @@
1
+ import type { AIProvider, ModelKey } from "../state/session-state.ts";
2
+ export type ModelLookup = Record<AIProvider, Record<ModelKey, string>>;
3
+ /**
4
+ * Per-provider, per-tier model defaults, each overridable by its own env var.
5
+ *
6
+ * A function rather than a constant because the env is read at call time:
7
+ * `createOrchestrator()` may be constructed before a host has finished loading
8
+ * its `.env`, and tests set these variables per-case.
9
+ */
10
+ export declare const defaultModelLookup: () => ModelLookup;
11
+ /**
12
+ * The providers this process has credentials for, in preference order.
13
+ *
14
+ * Order is load-bearing: the chat pipeline falls back to `availableProviders[0]`
15
+ * when a request names no provider.
16
+ */
17
+ export declare const defaultProviders: () => AIProvider[];
@@ -0,0 +1,58 @@
1
+ // The default model per (provider, tier), and the providers a set of API keys
2
+ // makes available.
3
+ //
4
+ // This lived twice: once in apps/orchestrator/src/index.ts (the Fastify host)
5
+ // and once in handler/create-orchestrator.ts (library mode). Both read the same
6
+ // `OPENAI_MODEL_*` / `ANTHROPIC_MODEL_*` / `GOOGLE_GENAI_MODEL_*` variables, so
7
+ // a deployment that set them got identical behaviour either way and the split
8
+ // was invisible. A deployment that set none — which is every `createOrchestrator()`
9
+ // consumer following the README — got different models depending on which half
10
+ // of the repo was serving it.
11
+ //
12
+ // They drifted the moment that mattered. #42 ("refresh OpenAI + Gemini defaults
13
+ // to current GA models") edited the Fastify copy; the library copy was carved
14
+ // out later in #58 and kept the pre-refresh values, so library mode was still
15
+ // defaulting to `gpt-4o-mini`, `gpt-4o`, `o1`, `o3` and `gemini-2.5-flash` two
16
+ // model generations after the standalone server had moved off them. Nothing
17
+ // failed: the models still answered, just older, slower and worse at planning.
18
+ //
19
+ // One table, one place to edit on the next refresh.
20
+ /**
21
+ * Per-provider, per-tier model defaults, each overridable by its own env var.
22
+ *
23
+ * A function rather than a constant because the env is read at call time:
24
+ * `createOrchestrator()` may be constructed before a host has finished loading
25
+ * its `.env`, and tests set these variables per-case.
26
+ */
27
+ export const defaultModelLookup = () => ({
28
+ openai: {
29
+ fast: process.env.OPENAI_MODEL_FAST ?? "gpt-5.4-nano",
30
+ balanced: process.env.OPENAI_MODEL_BALANCED ?? "gpt-5.6-terra",
31
+ reasoning: process.env.OPENAI_MODEL_REASONING ?? "gpt-5.6-terra",
32
+ codex: process.env.OPENAI_MODEL_CODEX ?? "gpt-5.3-codex"
33
+ },
34
+ anthropic: {
35
+ fast: process.env.ANTHROPIC_MODEL_FAST ?? "claude-haiku-4-5-20251001",
36
+ balanced: process.env.ANTHROPIC_MODEL_BALANCED ?? "claude-sonnet-5",
37
+ reasoning: process.env.ANTHROPIC_MODEL_REASONING ?? "claude-sonnet-5",
38
+ codex: process.env.ANTHROPIC_MODEL_CODEX ?? "claude-opus-4-8"
39
+ },
40
+ gemini: {
41
+ fast: process.env.GOOGLE_GENAI_MODEL_FAST ?? "gemini-3.5-flash-lite",
42
+ balanced: process.env.GOOGLE_GENAI_MODEL_BALANCED ?? "gemini-3.6-flash",
43
+ // No GA Gemini 3 Pro yet (3.1 Pro is preview-only), so the Pro tiers stay on 2.5 Pro.
44
+ reasoning: process.env.GOOGLE_GENAI_MODEL_REASONING ?? "gemini-2.5-pro",
45
+ codex: process.env.GOOGLE_GENAI_MODEL_CODEX ?? "gemini-2.5-pro"
46
+ }
47
+ });
48
+ /**
49
+ * The providers this process has credentials for, in preference order.
50
+ *
51
+ * Order is load-bearing: the chat pipeline falls back to `availableProviders[0]`
52
+ * when a request names no provider.
53
+ */
54
+ export const defaultProviders = () => [
55
+ ...(process.env.OPENAI_API_KEY ? ["openai"] : []),
56
+ ...(process.env.ANTHROPIC_API_KEY ? ["anthropic"] : []),
57
+ ...(process.env.GOOGLE_GENAI_API_KEY ? ["gemini"] : [])
58
+ ];
@@ -23,6 +23,7 @@ import { extractJsonObject, inferBlockTypeFromText, normalizePlanCandidate, repa
23
23
  import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
24
24
  import { intentJsonSchema } from "./plan-json-schema.js";
25
25
  import { PlannerError, isPlannerError } from "../errors.js";
26
+ import { envFlag } from "../env-flags.js";
26
27
  // Re-export for backward compatibility
27
28
  export { PlannerError, isPlannerError };
28
29
  /** @deprecated Use `PlannerError` instead. */
@@ -73,7 +74,7 @@ function extractResponsesOutcome(response) {
73
74
  return { refusal: null, incomplete: false };
74
75
  }
75
76
  export function isChatStrictPrimaryOpMode() {
76
- return /^(1|true|yes|on)$/i.test((process.env.CHAT_STRICT_PRIMARY_OP_MODE ?? "").trim());
77
+ return envFlag("CHAT_STRICT_PRIMARY_OP_MODE", false);
77
78
  }
78
79
  export function isPageWideTranslationRequest(message) {
79
80
  const lower = message.toLowerCase();
@@ -97,10 +98,10 @@ const EDIT_PLAN_SHAPE_HINT = {
97
98
  suggested_next_actions: ["string (optional, 2-4 items)"]
98
99
  };
99
100
  export function isStrictJsonResponseEnabled() {
100
- return /^(1|true|yes|on)$/i.test((process.env.CHAT_STRICT_JSON_RESPONSE ?? "").trim());
101
+ return envFlag("CHAT_STRICT_JSON_RESPONSE", false);
101
102
  }
102
103
  export function isAdaptiveSchemaContextEnabled() {
103
- return /^(1|true|yes|on)$/i.test((process.env.CHAT_ADAPTIVE_SCHEMA_CONTEXT ?? "").trim());
104
+ return envFlag("CHAT_ADAPTIVE_SCHEMA_CONTEXT", false);
104
105
  }
105
106
  function schemaBudgetBytes() {
106
107
  const parsed = Number(process.env.CHAT_SCHEMA_BUDGET_BYTES ?? "9000");
@@ -1,6 +1,26 @@
1
1
  import type { EditPlan, PageDoc } from "@avocadostudio-ai/shared";
2
2
  import type { plannerContextPack } from "../nlp/deterministic-planner.ts";
3
3
  import type { CommonGeneratePlanArgs, GeneratePlanResult } from "./planner-types.ts";
4
+ /**
5
+ * Full-page translation, split into independent per-block-group requests.
6
+ *
7
+ * One request for the whole page is the wrong shape for this job. A translation
8
+ * re-emits every string on the page, so its *output* length scales with the page
9
+ * while a single request's output is strictly serial: an 8 KB page cost 9,139
10
+ * output tokens and 75 seconds of streaming, and a page twice that size costs
11
+ * twice as long. Worse, the whole thing is one all-or-nothing unit — overshoot
12
+ * the token ceiling on the last block and the other six are discarded too.
13
+ *
14
+ * The blocks are independent, though. Nothing in translating the FAQ depends on
15
+ * how the hero was translated, so the work fans out: N smaller requests in
16
+ * parallel, each carrying only its own blocks, merged back into one plan. Wall
17
+ * clock becomes the slowest chunk rather than the sum, each chunk is far from
18
+ * any token ceiling, and a chunk that does fail costs only its own blocks.
19
+ *
20
+ * The tradeoff is input tokens — the schema contract and site context repeat per
21
+ * chunk — so each chunk carries only its own slice of the page outline, and the
22
+ * whole path is gated on the page being big enough to be worth it.
23
+ */
4
24
  export declare function translationChunkingConfig(): {
5
25
  enabled: boolean;
6
26
  /**
@@ -1,5 +1,6 @@
1
1
  import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
2
2
  import { ZERO_USAGE } from "../telemetry/usage.js";
3
+ import { envFlag, envInt } from "../env-flags.js";
3
4
  /**
4
5
  * Full-page translation, split into independent per-block-group requests.
5
6
  *
@@ -23,16 +24,9 @@ import { ZERO_USAGE } from "../telemetry/usage.js";
23
24
  // ---------------------------------------------------------------------------
24
25
  // Configuration
25
26
  // ---------------------------------------------------------------------------
26
- function envFlagOff(raw) {
27
- return /^(0|false|no|off)$/i.test((raw ?? "").trim());
28
- }
29
- function envInt(raw, fallback) {
30
- const parsed = Number((raw ?? "").trim());
31
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
32
- }
33
27
  export function translationChunkingConfig() {
34
28
  return {
35
- enabled: !envFlagOff(process.env.CHAT_TRANSLATION_CHUNKING),
29
+ enabled: envFlag("CHAT_TRANSLATION_CHUNKING", true),
36
30
  /**
37
31
  * Translatable bytes to aim for per chunk.
38
32
  *
@@ -43,12 +37,12 @@ export function translationChunkingConfig() {
43
37
  * cost, which is where splitting further stops buying much and starts paying
44
38
  * another chunk's input tokens for nothing.
45
39
  */
46
- targetBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_BYTES, 900),
40
+ targetBytes: envInt("CHAT_TRANSLATION_CHUNK_BYTES", 900),
47
41
  /** Upper bound on parallel planner calls — one wave, not a stampede. */
48
- maxChunks: envInt(process.env.CHAT_TRANSLATION_MAX_CHUNKS, 6),
42
+ maxChunks: envInt("CHAT_TRANSLATION_MAX_CHUNKS", 6),
49
43
  /** Below these, a single request is already fast and chunking only adds input cost. */
50
- minBlocks: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BLOCKS, 3),
51
- minBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BYTES, 1800)
44
+ minBlocks: envInt("CHAT_TRANSLATION_CHUNK_MIN_BLOCKS", 3),
45
+ minBytes: envInt("CHAT_TRANSLATION_CHUNK_MIN_BYTES", 1800)
52
46
  };
53
47
  }
54
48
  /**
package/dist/demo-mode.js CHANGED
@@ -22,16 +22,10 @@
22
22
  // and costs bounded.
23
23
  import { createHash } from "node:crypto";
24
24
  import { OperationError } from "./errors.js";
25
+ import { envFlag } from "./env-flags.js";
25
26
  // ---------------------------------------------------------------------------
26
27
  // Config — read once at module load, memoized
27
28
  // ---------------------------------------------------------------------------
28
- const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
29
- function envFlag(name, fallback = false) {
30
- const raw = process.env[name];
31
- if (raw === undefined)
32
- return fallback;
33
- return TRUE_VALUES.has(raw.trim().toLowerCase());
34
- }
35
29
  function envList(name, fallback) {
36
30
  const raw = process.env[name];
37
31
  if (!raw)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `process.env` wrappers over the shared flag table.
3
+ *
4
+ * The table itself lives in `@avocadostudio-ai/shared` because the editor reads
5
+ * the same flags out of `import.meta.env` and `localStorage` — see that module's
6
+ * header for why there was more than one of it.
7
+ *
8
+ * All three read the environment at call time, never at module load. A flag
9
+ * captured at import is a flag a test cannot set and a library-mode host cannot
10
+ * configure after its own `.env` has loaded, and several of these already had to
11
+ * be functions for exactly that reason.
12
+ */
13
+ /** `true`/`false` for a flag with a documented default. */
14
+ export declare function envFlag(name: string, fallback: boolean): boolean;
15
+ /**
16
+ * `undefined` when the operator said nothing, `false` when they said anything
17
+ * that is not a recognised "on". Fail-closed — see `parseFlagExplicit`.
18
+ */
19
+ export declare function envFlagExplicit(name: string): boolean | undefined;
20
+ /** A positive integer, or `fallback`. */
21
+ export declare function envInt(name: string, fallback: number): number;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `process.env` wrappers over the shared flag table.
3
+ *
4
+ * The table itself lives in `@avocadostudio-ai/shared` because the editor reads
5
+ * the same flags out of `import.meta.env` and `localStorage` — see that module's
6
+ * header for why there was more than one of it.
7
+ *
8
+ * All three read the environment at call time, never at module load. A flag
9
+ * captured at import is a flag a test cannot set and a library-mode host cannot
10
+ * configure after its own `.env` has loaded, and several of these already had to
11
+ * be functions for exactly that reason.
12
+ */
13
+ import { parseFlag, parseFlagExplicit, parseIntFlag } from "@avocadostudio-ai/shared";
14
+ /** `true`/`false` for a flag with a documented default. */
15
+ export function envFlag(name, fallback) {
16
+ return parseFlag(process.env[name], fallback);
17
+ }
18
+ /**
19
+ * `undefined` when the operator said nothing, `false` when they said anything
20
+ * that is not a recognised "on". Fail-closed — see `parseFlagExplicit`.
21
+ */
22
+ export function envFlagExplicit(name) {
23
+ return parseFlagExplicit(process.env[name]);
24
+ }
25
+ /** A positive integer, or `fallback`. */
26
+ export function envInt(name, fallback) {
27
+ return parseIntFlag(process.env[name], fallback);
28
+ }
@@ -1,4 +1,5 @@
1
- import { type AIProvider, type ModelKey } from "../state/session-state.ts";
1
+ import { type AIProvider } from "../state/session-state.ts";
2
+ import { type ModelLookup } from "../chat/model-defaults.ts";
2
3
  import { type ScreenshotCapture } from "../http/screenshot-actions.ts";
3
4
  import { type Logger } from "../logger.ts";
4
5
  import type { CmsAdapter, CmsCapabilities } from "../cms/adapter.ts";
@@ -8,8 +9,16 @@ export interface CreateOrchestratorConfig {
8
9
  /**
9
10
  * Provider model overrides. Defaults to env vars (OPENAI_MODEL_*, ANTHROPIC_MODEL_*,
10
11
  * GOOGLE_GENAI_MODEL_*). Pass an explicit object to override per-tier model names.
12
+ *
13
+ * To change one tier and keep the rest current, spread the defaults rather
14
+ * than retyping the table — `defaultModelLookup` is exported for this:
15
+ *
16
+ * ```ts
17
+ * const base = defaultModelLookup()
18
+ * modelLookup: { ...base, openai: { ...base.openai, reasoning: "o5" } }
19
+ * ```
11
20
  */
12
- modelLookup?: Record<AIProvider, Record<ModelKey, string>>;
21
+ modelLookup?: ModelLookup;
13
22
  /**
14
23
  * Override available providers. Defaults to whichever API keys are present in
15
24
  * process.env (OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENAI_API_KEY).
@@ -30,7 +30,9 @@ import { ResumableStreamStore, runResumableChatStream, TooManyPendingStreamsErro
30
30
  import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipeline.js";
31
31
  import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
32
32
  import { createToolRuntime } from "../tools/runtime.js";
33
+ import { envFlag } from "../env-flags.js";
33
34
  import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
35
+ import { defaultModelLookup, defaultProviders } from "../chat/model-defaults.js";
34
36
  import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction, historyDiscardAction } from "../http/history-actions.js";
35
37
  import { whoamiAction } from "../http/session-actions.js";
36
38
  import { blocksManifestAction } from "../http/blocks-actions.js";
@@ -55,31 +57,6 @@ import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../h
55
57
  import { declareLibraryMount, observeLibraryMount } from "./library-mount.js";
56
58
  import { checkAuth, resolveAuth, CLOSED_HINT } from "./auth.js";
57
59
  import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
58
- const defaultModelLookup = () => ({
59
- openai: {
60
- fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
61
- balanced: process.env.OPENAI_MODEL_BALANCED ?? "gpt-4o",
62
- reasoning: process.env.OPENAI_MODEL_REASONING ?? "o1",
63
- codex: process.env.OPENAI_MODEL_CODEX ?? "o3"
64
- },
65
- anthropic: {
66
- fast: process.env.ANTHROPIC_MODEL_FAST ?? "claude-haiku-4-5-20251001",
67
- balanced: process.env.ANTHROPIC_MODEL_BALANCED ?? "claude-sonnet-5",
68
- reasoning: process.env.ANTHROPIC_MODEL_REASONING ?? "claude-sonnet-5",
69
- codex: process.env.ANTHROPIC_MODEL_CODEX ?? "claude-opus-4-8"
70
- },
71
- gemini: {
72
- fast: process.env.GOOGLE_GENAI_MODEL_FAST ?? "gemini-2.5-flash",
73
- balanced: process.env.GOOGLE_GENAI_MODEL_BALANCED ?? "gemini-2.5-flash",
74
- reasoning: process.env.GOOGLE_GENAI_MODEL_REASONING ?? "gemini-2.5-pro",
75
- codex: process.env.GOOGLE_GENAI_MODEL_CODEX ?? "gemini-2.5-pro"
76
- }
77
- });
78
- const defaultProviders = () => [
79
- ...(process.env.OPENAI_API_KEY ? ["openai"] : []),
80
- ...(process.env.ANTHROPIC_API_KEY ? ["anthropic"] : []),
81
- ...(process.env.GOOGLE_GENAI_API_KEY ? ["gemini"] : [])
82
- ];
83
60
  async function buildRuntime(config) {
84
61
  const log = config.logger ?? consoleLogger();
85
62
  // Register host-app block schemas BEFORE the planner ever validates ops.
@@ -97,7 +74,7 @@ async function buildRuntime(config) {
97
74
  const chatTelemetry = createChatTelemetryStore({
98
75
  filePath: process.env.CHAT_TELEMETRY_FILE ?? "./.data/chat-telemetry.ndjson",
99
76
  limit: Number(process.env.CHAT_TELEMETRY_LIMIT ?? 500),
100
- persistEnabled: !/^(0|false|no|off)$/i.test((process.env.CHAT_TELEMETRY_PERSIST ?? "1").trim()),
77
+ persistEnabled: envFlag("CHAT_TELEMETRY_PERSIST", true),
101
78
  logger: log
102
79
  });
103
80
  const toolRuntime = await createToolRuntime({ logger: log });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
+ export { envFlag, envFlagExplicit, envInt } from "./env-flags.ts";
4
+ export { defaultModelLookup, defaultProviders, type ModelLookup } from "./chat/model-defaults.ts";
3
5
  export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload, ResolvedCapabilities } from "./cms/adapter.ts";
4
6
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
5
7
  export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
package/dist/index.js CHANGED
@@ -18,6 +18,15 @@
18
18
  //
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
+ // Flag parsing, so a library-mode host reads its own options the way the
22
+ // orchestrator reads its own — one truth table for "1"/"true"/"yes"/"on".
23
+ export { envFlag, envFlagExplicit, envInt } from "./env-flags.js";
24
+ // The model defaults `createOrchestrator()` applies when `config.modelLookup`
25
+ // is omitted. Public because the sensible way to override one tier is to spread
26
+ // the defaults and replace that entry — which a consumer cannot do if the table
27
+ // they are overriding is unreachable, leaving them to retype all twelve models
28
+ // and inherit none of the next refresh.
29
+ export { defaultModelLookup, defaultProviders } from "./chat/model-defaults.js";
21
30
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel } from "./cms/index.js";
22
31
  // The publish-target plugin point. `docs-site/integration/publishing.mdx` has
23
32
  // documented this as the way to publish somewhere we do not ship a target for
@@ -174,50 +174,37 @@ export function postEditSuggestions(args) {
174
174
  return;
175
175
  suggestions.push(value);
176
176
  };
177
- // Detect if this was primarily an image update
178
- const isImageUpdate = plan.change_log.some((entry) => /\b(image|images|unsplash|photo)\b/i.test(entry));
177
+ /*
178
+ * No bare "Edit <thing>" chips.
179
+ *
180
+ * This branch used to emit "Edit heading", "Edit CTA", "Edit Banner",
181
+ * "Edit cards" after every edit. Clicking one sends that literal string to
182
+ * the planner, which knows the field and not the value — so the only possible
183
+ * answer is "edit it to what?". Three clean-room runs measured the same
184
+ * outcome: **zero operations**, and on a keyed plan about $0.012 and five
185
+ * seconds to be asked a question the chip existed to avoid.
186
+ *
187
+ * There is no repair for the shape. A suggestion is a sentence the product is
188
+ * offering to run, and "change this field to something" is not a sentence
189
+ * anybody can run — only the person typing knows the something. The chips
190
+ * that survive below are the ones that name a complete action: add a section,
191
+ * add another item, generate an image.
192
+ *
193
+ * The `isImageUpdate` special-casing went with them; it chose *which*
194
+ * underspecified chip to show, which was never the problem.
195
+ */
179
196
  for (const op of plan.ops) {
180
- if (op.op === "update_props") {
181
- const block = current.blocks.find((b) => b.id === op.blockId);
182
- if (block) {
183
- if (isImageUpdate) {
184
- // After image updates, suggest content edits relevant to the block
185
- const blockType = block.type;
186
- if (blockType === "CardGrid" || blockType === "Card") {
187
- pushUniqueSuggestion("Edit cards");
188
- pushUniqueSuggestion("Update links");
189
- }
190
- else if (blockType === "FeatureGrid") {
191
- pushUniqueSuggestion("Edit features");
192
- }
193
- else if (blockType === "Hero") {
194
- pushUniqueSuggestion("Edit heading");
195
- pushUniqueSuggestion("Edit CTA");
196
- }
197
- else {
198
- pushUniqueSuggestion(`Edit ${blockType}`);
199
- }
200
- }
201
- else {
202
- const patchKeys = Object.keys(op.patch);
203
- const otherKeys = editablePropsFromBlock(block)
204
- .filter((k) => !patchKeys.includes(k) && !NON_CONTENT_PROP_KEYS.has(k));
205
- for (const key of otherKeys.slice(0, 2)) {
206
- pushUniqueSuggestion(promptFromPropKey(key, block.type));
207
- }
208
- }
209
- }
210
- }
211
- else if (op.op === "create_page") {
197
+ if (op.op === "create_page") {
212
198
  pushUniqueSuggestion("Generate image");
213
199
  }
214
200
  else if (op.op === "add_block") {
201
+ // Only the curated, complete follow-ups. A block with none falls through
202
+ // to the forward-looking section nudges below rather than to
203
+ // `Edit <Type>`, which is the same dead end as the branch above.
215
204
  const followups = ADD_BLOCK_FOLLOWUPS[op.block.type];
216
205
  if (followups)
217
206
  for (const f of followups)
218
207
  pushUniqueSuggestion(f);
219
- else
220
- pushUniqueSuggestion(`Edit ${op.block.type}`);
221
208
  }
222
209
  else if (op.op === "remove_block") {
223
210
  if (!hasType("CTA"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.11.8",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -22,8 +22,8 @@
22
22
  "openai": "^4.87.1",
23
23
  "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/migration-sdk": "^0.11.8",
26
- "@avocadostudio-ai/shared": "^0.11.8"
25
+ "@avocadostudio-ai/migration-sdk": "^0.12.0",
26
+ "@avocadostudio-ai/shared": "^0.12.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",