@avocadostudio-ai/orchestrator-core 0.8.0 → 0.10.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.
@@ -55,6 +55,7 @@ export declare function compactPlannerContextPack(args: {
55
55
  }>;
56
56
  }[];
57
57
  pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
58
+ pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
58
59
  pageIntent: string;
59
60
  recentSuccessfulEdits: {
60
61
  at: string;
@@ -132,6 +133,7 @@ export declare function minimalPlannerContextPack(args: {
132
133
  }>;
133
134
  }[];
134
135
  pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
136
+ pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
135
137
  pageIntent: string;
136
138
  recentSuccessfulEdits: {
137
139
  at: string;
@@ -1,14 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { blockManifestSchema } from "@avocadostudio-ai/shared";
3
3
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
4
- import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
4
+ import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
5
5
  import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
6
6
  import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
7
  import { getSiteAssets } from "../state/site-assets.js";
8
8
  import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
9
9
  import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
10
10
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
11
- import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
11
+ import { clarificationSuggestions, postEditSuggestions, keepDemoExecutable, demoPlanFromMessage, withKeylessNotice, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
12
12
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
13
13
  import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
14
14
  import { isCancelError as _isCancelError, OperationError, OrchestrationError } from "../errors.js";
@@ -52,7 +52,7 @@ import { planTranslationChunks, shouldChunkTranslation, generateChunkedTranslati
52
52
  import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
53
53
  import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
54
54
  import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
55
- import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource } from "./provider-routing.js";
55
+ import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource, DEMO_MODEL_LABEL } from "./provider-routing.js";
56
56
  import { runVariationPipeline, getCachedVariations } from "./variation-pipeline.js";
57
57
  import { validateAndStripHallucinatedProps } from "./hallucination-validator.js";
58
58
  import { validateChangelogCoverage } from "./changelog-coverage-validator.js";
@@ -183,6 +183,19 @@ let generatePlanWithAnthropicImpl = generatePlanWithAnthropic;
183
183
  export function setGeneratePlanWithAnthropicForTests(fn) {
184
184
  generatePlanWithAnthropicImpl = fn ?? generatePlanWithAnthropic;
185
185
  }
186
+ /**
187
+ * Suggestions, narrowed to what will answer when there is no key.
188
+ *
189
+ * A no-op the moment any provider is configured — with a model behind the chat,
190
+ * every phrasing these generators produce is answerable, which is what they
191
+ * were written against. Without one the answer comes from a substring matcher
192
+ * that knows a handful of literal phrases, and the rest are dead buttons.
193
+ */
194
+ function usableSuggestions(suggestions, source, slug, body) {
195
+ if (source !== "demo")
196
+ return suggestions;
197
+ return keepDemoExecutable(suggestions, slug, body.activeBlockId, body.activeBlockType);
198
+ }
186
199
  let demoPlanFromMessageImpl = demoPlanFromMessage;
187
200
  export function setDemoPlanFromMessageForTests(fn) {
188
201
  demoPlanFromMessageImpl = fn ?? demoPlanFromMessage;
@@ -275,7 +288,9 @@ export async function runChatPipeline(ctx, body, options) {
275
288
  changes: [],
276
289
  previewVersion: versions.get(body.session) ?? 0,
277
290
  plannerSource: ctx.availableProviders.length > 0 ? ctx.availableProviders[0] : "demo",
278
- modelUsed: ctx.modelLookup[defaultProvider][defaultModelKey],
291
+ modelUsed: ctx.availableProviders.length > 0
292
+ ? ctx.modelLookup[defaultProvider][defaultModelKey]
293
+ : DEMO_MODEL_LABEL,
279
294
  modelKey: defaultModelKey
280
295
  }
281
296
  };
@@ -481,8 +496,23 @@ export async function runChatPipeline(ctx, body, options) {
481
496
  shouldPreferFastModelForMessage(plannerMessage)
482
497
  ? "fast"
483
498
  : baseModelKey;
484
- const modelUsed = ctx.modelLookup[provider][modelKey];
485
499
  const plannerSource = resolvePlannerSource(provider);
500
+ /*
501
+ * What actually answered, not what would have.
502
+ *
503
+ * `plannerSource === "demo"` means no provider key of any kind is
504
+ * configured, so no model can run — yet this reported the configured
505
+ * lookup's default anyway, and every keyless reply came back
506
+ * `"modelUsed":"gpt-4o"`. The editor renders a per-message model chip from
507
+ * this, so someone who had deliberately not signed up for anything watched
508
+ * an OpenAI model take credit for nine words of regex output.
509
+ *
510
+ * Narrower than it looks: a *deterministic* plan produced while a key IS
511
+ * configured keeps naming the model, because that model would genuinely have
512
+ * run had the rules not matched first — see `plannerTier` below. The case
513
+ * being corrected is the one where there is nothing to name.
514
+ */
515
+ const modelUsed = plannerSource === "demo" ? DEMO_MODEL_LABEL : ctx.modelLookup[provider][modelKey];
486
516
  // Auto-escalate to extended thinking for complex/ambiguous Anthropic prompts.
487
517
  // Only applies when provider = anthropic (other providers ignore `thinking`).
488
518
  // User can disable with CHAT_AUTO_REASONING=0.
@@ -919,11 +949,17 @@ export async function runChatPipeline(ctx, body, options) {
919
949
  const info = infoResponse({ body, current, plannerSource, modelUsed, modelKey });
920
950
  return { code: info.code, payload: withDebugPayload(info.payload, { outcome: "info" }) };
921
951
  }
922
- if (body.message && isAdviceQuery(body.message) && !isBatchAddRequest(body.message)) {
923
- activePlannerTier = "deterministic";
924
- const advice = adviceResponse({ body, current, plannerSource, modelUsed, modelKey });
925
- return { code: advice.code, payload: withDebugPayload(advice.payload, { outcome: "advice" }) };
926
- }
952
+ /*
953
+ * There is no deterministic branch for "review this page" any more.
954
+ *
955
+ * There was, and it answered in twelve milliseconds from a template — which
956
+ * is why a page review read like a linter. The planner prompt already has the
957
+ * rule (PAGE FEEDBACK -> content_answer, "specific, reasoned recommendations
958
+ * based on the page topic and content, not a generic checklist"); it was
959
+ * never reached, because two detectors claimed the same message and the
960
+ * canned one was tested first. What survives is `pageObservations`, which the
961
+ * context pack carries so the model reviews with the checked facts in hand.
962
+ */
927
963
  if (body.message && isPageListQuery(body.message)) {
928
964
  const directory = buildPageDirectory(body.session);
929
965
  const draft = getSessionDraft(body.session);
@@ -1089,7 +1125,7 @@ export async function runChatPipeline(ctx, body, options) {
1089
1125
  summary: "I need one more detail before applying this safely.",
1090
1126
  changes: [],
1091
1127
  mentionedSlugs: [effectiveSlug],
1092
- suggestions: clarificationSuggestions({ body, current, selected }),
1128
+ suggestions: usableSuggestions(clarificationSuggestions({ body, current, selected }), args.source, effectiveSlug, body),
1093
1129
  previewVersion: versions.get(body.session) ?? 0,
1094
1130
  plannerSource: args.source,
1095
1131
  modelUsed,
@@ -1713,7 +1749,7 @@ export async function runChatPipeline(ctx, body, options) {
1713
1749
  summary: resolvedPlan.summary_for_user,
1714
1750
  changes: resolvedPlan.change_log,
1715
1751
  mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
1716
- suggestions: resolvedPlan.suggested_next_actions ?? clarificationSuggestions({ body, current, selected }),
1752
+ suggestions: usableSuggestions(resolvedPlan.suggested_next_actions ?? clarificationSuggestions({ body, current, selected }), source, effectiveSlug, body),
1717
1753
  previewVersion: versions.get(body.session) ?? 0,
1718
1754
  plannerSource: source,
1719
1755
  modelUsed,
@@ -2617,7 +2653,7 @@ export async function runChatPipeline(ctx, body, options) {
2617
2653
  summary: futureToPastTense(resolvedPlan.summary_for_user),
2618
2654
  changes: [...opChangeLogEntries, ...metaChangeLogEntries, ...aiInsightChanges, ...skippedSummary],
2619
2655
  mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, updatedSlug ?? effectiveSlug),
2620
- suggestions: resolvedPlan.suggested_next_actions ?? postEditSuggestions({ plan: resolvedPlan, current, body }),
2656
+ suggestions: usableSuggestions(resolvedPlan.suggested_next_actions ?? postEditSuggestions({ plan: resolvedPlan, current, body }), plannerSource, updatedSlug ?? effectiveSlug, body),
2621
2657
  previewVersion,
2622
2658
  focusBlockId,
2623
2659
  updatedSlug,
@@ -2961,7 +2997,7 @@ export async function runChatPipeline(ctx, body, options) {
2961
2997
  markPlanningStart();
2962
2998
  try {
2963
2999
  emitStatusTone("planning");
2964
- const demoPlan = demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType);
3000
+ const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
2965
3001
  markPlanningFinish();
2966
3002
  const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
2967
3003
  if (outcome.done)
@@ -134,7 +134,7 @@ function buildLightweightPlannerPrompt(opts) {
134
134
  RULE_ICON_FORMAT,
135
135
  "Use future tense in summary_for_user and change_log — your output streams to the user while the plan is still being generated, before any ops have been applied. Say 'Will update the heading…' or 'Will replace the Hero image…', never 'Updated…' or 'Updating…'. The system flips to past tense automatically once ops are applied.",
136
136
  "For edit_plan: summary_for_user must be ONE short sentence (max ~20 words).",
137
- "After planning ops, include suggested_next_actions: 2-4 short imperative phrases the user could type next (max 6 words each). Every suggestion must be an action the user can perform inside this editor (editing content, adding/removing sections, changing images, rewriting copy) — restricted to the block types listed in the block catalogue provided in context (Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, TwoColumn, Banner, Carousel, Embed, Footer, Gallery, Quote, SiteHeader, Stats, Table, Tabs, Video). NEVER suggest unsupported features like forms, email capture, contact forms, subscribe boxes, newsletter signups, popups, modals, or anything requiring custom code. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy. When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). Remaining suggestions can target neighboring fields or blocks.",
137
+ "After planning ops, include suggested_next_actions: 2-4 short imperative phrases the user could type next (max 6 words each). Every suggestion must be an action the user can perform inside this editor (editing content, adding/removing sections, changing images, rewriting copy) — restricted to the block types listed in the block catalogue provided in context (Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, TwoColumn, Banner, Carousel, Embed, Footer, Gallery, Quote, SiteHeader, Stats, Table, Tabs, Video). NEVER suggest unsupported features like forms, email capture, contact forms, subscribe boxes, newsletter signups, popups, modals, or anything requiring custom code. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy. When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Use a warmer opening'). Remaining suggestions can target neighboring fields or blocks. NEVER suggest undoing, reverting, or restoring earlier content. You cannot read the undo history, so such a suggestion resolves to either a refusal or an invented replacement presented as the original — the editor's own Undo button is the affordance for that, and it already works.",
138
138
  opts.selectedBlockId.length > 0
139
139
  ? `Selected block is ${opts.selectedBlockId}. Target only this block in ops when the request edits the current page. IGNORE this selection when the request operates on a different scope — creating, duplicating, renaming, removing, or moving a page; editing site config; or naming a different page — and emit ops only for the requested scope. Never add bonus ops on the selected block to satisfy this rule.`
140
140
  : "Respect explicit user target references when present.",
@@ -250,7 +250,7 @@ function sectionVoice(opts, hasNativeTools) {
250
250
  if (hasNativeTools) {
251
251
  lines.push("For edit_plan intent: summary_for_user must be ONE short sentence (max ~20 words) describing what the plan will do. Do NOT elaborate, explain why, or describe the content being added — let change_log carry the detail. Bad: 'Updated the hero heading with a punchier tone.' Good: 'Will add a **text section** about blueberry varieties after the features grid.'", "change_log coverage is MANDATORY: emit exactly one change_log entry per op, in the same order as ops[], describing what that specific op does. If ops has N entries, change_log must have N entries — never cluster multiple ops into one entry, never skip an op, never leave an op undescribed. The user reads change_log to decide whether to approve; a missing entry is a silent bait-and-switch.", "change_log entries should add specific detail NOT already in summary_for_user — e.g. list the actual content, items, or values being set. Do not paraphrase the summary.");
252
252
  }
253
- lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types listed in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits. Never suggest adding a section this site has no block for; the catalogue is the whole list, not a sample of a larger one. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy.");
253
+ lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Use a warmer opening'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types listed in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits. Never suggest adding a section this site has no block for; the catalogue is the whole list, not a sample of a larger one. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy. NEVER suggest undoing, reverting, or restoring earlier content. You cannot read the undo history, so such a suggestion resolves to either a refusal or an invented replacement presented as the original — the editor's own Undo button is the affordance for that, and it already works.");
254
254
  return lines;
255
255
  }
256
256
  /*
@@ -12,3 +12,14 @@ export declare function resolveModelKeyForProvider(args: {
12
12
  defaultModelKey?: ModelKey;
13
13
  }): ModelKey;
14
14
  export declare function resolvePlannerSource(provider: AIProvider): PlannerSource;
15
+ /**
16
+ * What to report as the model when `resolvePlannerSource` said `"demo"`.
17
+ *
18
+ * A string rather than `undefined` because every consumer of `modelUsed` — the
19
+ * editor's per-message chip, the telemetry row, the debug panel — already
20
+ * treats absence as "unknown" and renders nothing. "Unknown" is not what this
21
+ * is. The reply came from a named thing that is not a model, and saying so is
22
+ * the whole point: it is the one place a keyless session is told, in passing,
23
+ * what it is running on.
24
+ */
25
+ export declare const DEMO_MODEL_LABEL = "demo planner (no API key)";
@@ -25,3 +25,14 @@ export function resolvePlannerSource(provider) {
25
25
  ? "gemini"
26
26
  : "demo";
27
27
  }
28
+ /**
29
+ * What to report as the model when `resolvePlannerSource` said `"demo"`.
30
+ *
31
+ * A string rather than `undefined` because every consumer of `modelUsed` — the
32
+ * editor's per-message chip, the telemetry row, the debug panel — already
33
+ * treats absence as "unknown" and renders nothing. "Unknown" is not what this
34
+ * is. The reply came from a named thing that is not a model, and saying so is
35
+ * the whole point: it is the one place a keyless session is told, in passing,
36
+ * what it is running on.
37
+ */
38
+ export const DEMO_MODEL_LABEL = "demo planner (no API key)";
@@ -10,7 +10,7 @@ import { openAIChatOptionsForModel } from "./planner.js";
10
10
  import { extractJsonObject } from "../nlp/plan-normalizer.js";
11
11
  import { extractUsage, estimateUsd } from "../telemetry/usage.js";
12
12
  import { anthropicSystemPromptWithCache } from "./anthropic-cache.js";
13
- import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource } from "./provider-routing.js";
13
+ import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource, DEMO_MODEL_LABEL } from "./provider-routing.js";
14
14
  import { deriveVariationImageIntent, buildVariationImageQuery, buildVariationImagePrompt, generateVariationImageWithOpenAI, generateVariationImageWithGemini, resolveUnsplashImage } from "../image/image-helpers.js";
15
15
  import { firstUrlFromText, resolveEffectiveSlug } from "./chat-pipeline.js";
16
16
  import { resolveDistinctUnsplashImage } from "../variation-images.js";
@@ -656,9 +656,11 @@ async function prepareTextVariations(ctx, body) {
656
656
  modelLookup: ctx.modelLookup,
657
657
  defaultModelKey: process.env.OPENAI_MODEL_KEY ?? "balanced"
658
658
  });
659
- const modelUsed = ctx.modelLookup[provider][modelKey];
660
659
  const count = requestedVariationCount(contextualMessage);
661
660
  const plannerSource = resolvePlannerSource(provider);
661
+ /* Same correction as the chat pipeline: with no key, nothing in the lookup
662
+ * ran, so naming one credits a model for a deterministic result. */
663
+ const modelUsed = plannerSource === "demo" ? DEMO_MODEL_LABEL : ctx.modelLookup[provider][modelKey];
662
664
  let variations = [];
663
665
  let generatorUsage;
664
666
  if (plannerSource === "anthropic") {
@@ -59,8 +59,28 @@ export declare function hasConfiguredCredential(): boolean;
59
59
  * captured at module load is a value from before the host configured anything.
60
60
  */
61
61
  export declare function resolveAuth(auth: OrchestratorAuth | undefined): ResolvedAuth;
62
- /** The one body shape the editor's fetch shim recognises as "re-prompt". */
63
- export declare function unauthorizedResponse(cors: Record<string, string>): Response;
62
+ /**
63
+ * The reason a `closed` mount refuses, in the 401 itself.
64
+ *
65
+ * Refusing anonymously is right when a credential exists and the caller did
66
+ * not present it — "which header did I get wrong" is not the server's to
67
+ * answer. `closed` is not that case. Nobody holds a credential because none was
68
+ * configured, so there is no attacker to withhold this from: the only person
69
+ * who can act on it is the operator, staring at a 401 on their own deployment.
70
+ *
71
+ * Withholding it cost a clean-room run its whole production evaluation. Every
72
+ * route answered `{"error":"unauthorized"}`, `/auth/status` answered
73
+ * `gateEnabled: false`, and nothing anywhere named an environment variable.
74
+ */
75
+ export declare const CLOSED_HINT: string;
76
+ /**
77
+ * The one body shape the editor's fetch shim recognises as "re-prompt".
78
+ *
79
+ * `reason` is additive and present only for a misconfiguration the caller
80
+ * cannot have caused; `error` keeps its exact value either way, because that
81
+ * is the field the shim matches on.
82
+ */
83
+ export declare function unauthorizedResponse(cors: Record<string, string>, reason?: string): Response;
64
84
  export interface AuthCheckResult {
65
85
  /** Non-null means refuse and return this. */
66
86
  response: Response | null;
@@ -76,9 +76,30 @@ export function resolveAuth(auth) {
76
76
  }
77
77
  return { mode: "open-dev", reason: "no credential configured and NODE_ENV is not production — open" };
78
78
  }
79
- /** The one body shape the editor's fetch shim recognises as "re-prompt". */
80
- export function unauthorizedResponse(cors) {
81
- return new Response(JSON.stringify({ error: "unauthorized" }), {
79
+ /**
80
+ * The reason a `closed` mount refuses, in the 401 itself.
81
+ *
82
+ * Refusing anonymously is right when a credential exists and the caller did
83
+ * not present it — "which header did I get wrong" is not the server's to
84
+ * answer. `closed` is not that case. Nobody holds a credential because none was
85
+ * configured, so there is no attacker to withhold this from: the only person
86
+ * who can act on it is the operator, staring at a 401 on their own deployment.
87
+ *
88
+ * Withholding it cost a clean-room run its whole production evaluation. Every
89
+ * route answered `{"error":"unauthorized"}`, `/auth/status` answered
90
+ * `gateEnabled: false`, and nothing anywhere named an environment variable.
91
+ */
92
+ export const CLOSED_HINT = "This orchestrator is running with NODE_ENV=production and no credential, so every request is refused. " +
93
+ "Set ACCESS_PASSWORD_HASH or ORCHESTRATOR_ACCESS_TOKEN, or pass config.auth to createOrchestrator().";
94
+ /**
95
+ * The one body shape the editor's fetch shim recognises as "re-prompt".
96
+ *
97
+ * `reason` is additive and present only for a misconfiguration the caller
98
+ * cannot have caused; `error` keeps its exact value either way, because that
99
+ * is the field the shim matches on.
100
+ */
101
+ export function unauthorizedResponse(cors, reason) {
102
+ return new Response(JSON.stringify({ error: "unauthorized", ...(reason ? { reason } : {}) }), {
82
103
  status: 401,
83
104
  headers: { "content-type": "application/json", ...cors }
84
105
  });
@@ -95,7 +116,7 @@ export async function checkAuth(args) {
95
116
  return { response: null, context: null };
96
117
  if (resolved.mode === "closed") {
97
118
  log.error({ path }, `[auth] refused — ${resolved.reason}`);
98
- return { response: unauthorizedResponse(cors), context: null };
119
+ return { response: unauthorizedResponse(cors, CLOSED_HINT), context: null };
99
120
  }
100
121
  if (resolved.mode === "token") {
101
122
  if (isValidAccessToken(extractAccessToken(request)))
@@ -97,6 +97,27 @@ export interface CreateOrchestratorConfig {
97
97
  * - no adapter: explicit `siteId` wins; otherwise `body.siteId` is used.
98
98
  */
99
99
  siteId?: string;
100
+ /**
101
+ * Human-readable name for this site, reported on `/status/planner`.
102
+ *
103
+ * The editor otherwise title-cases the site id, so a project scaffolded into
104
+ * `my-shop/` is greeted as "My Shop". That is a reasonable guess and a poor
105
+ * one for anything whose directory name is not its name.
106
+ */
107
+ siteName?: string;
108
+ /**
109
+ * Whether this mount is serving the shipped Avocado Hub demo content rather
110
+ * than a real site's, reported on `/status/planner`.
111
+ *
112
+ * The editor's curated first-run suggestions — the ones written against the
113
+ * demo's actual pages — used to be keyed on a hardcoded allowlist of site
114
+ * ids that only ever matched sites inside this monorepo. A scaffolded
115
+ * project's id is whatever the user called their directory, so the new front
116
+ * door was structurally excluded from the onboarding written for it. The
117
+ * question the editor was really asking is "is this the demo content?", so
118
+ * that is the question the orchestrator now answers.
119
+ */
120
+ demoContent?: boolean;
100
121
  /**
101
122
  * Register the site's block schemas before the orchestrator's first chat
102
123
  * request. Use this instead of relying on side-effect import order: in
@@ -150,7 +171,7 @@ export interface CreateOrchestratorConfig {
150
171
  * The route this site renders orchestrator drafts on. Either a prefix the
151
172
  * page slug is appended to (`/avocado`) or a template naming where the slug
152
173
  * goes (`/preview/{slug}/draft`). Defaults to `/preview-draft`, which is what
153
- * `create-ai-site-editor` scaffolds.
174
+ * `create-avocado-site` scaffolds.
154
175
  *
155
176
  * A site that wired Avocado into its own app almost certainly has its own
156
177
  * route here — declare it, or every draft screenshot photographs a 404.
@@ -52,7 +52,7 @@ import { resolveCapabilities } from "../cms/adapter.js";
52
52
  import { mediaSourceFromUnknown } from "../cms/media-sources.js";
53
53
  import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
54
54
  import { declareLibraryMount, observeLibraryMount } from "./library-mount.js";
55
- import { checkAuth, resolveAuth } from "./auth.js";
55
+ import { checkAuth, resolveAuth, CLOSED_HINT } from "./auth.js";
56
56
  import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
57
57
  const defaultModelLookup = () => ({
58
58
  openai: {
@@ -533,8 +533,27 @@ export function createOrchestrator(config = {}) {
533
533
  * editor to prompt for one it cannot verify would trap the user in a form
534
534
  * that never succeeds. Such a host authenticates the browser its own way,
535
535
  * before the editor ever loads.
536
+ *
537
+ * `mode` answers the question `gateEnabled` was being made to stand in
538
+ * for, and could not: "will my requests be accepted?". The two come apart
539
+ * in exactly one state, and it is the worst one. A `closed` mount — built
540
+ * for production with no credential — has no password gate, so
541
+ * `gateEnabled` is false and the editor opens without prompting, while
542
+ * every route behind this one answers 401. The editor then renders itself
543
+ * around a site it cannot read, with no password box and no way to reach
544
+ * one; a clean-room run hit exactly that and had nothing to go on.
545
+ *
546
+ * So the state is named. `/auth/status` is public precisely so a client
547
+ * can ask before it holds a credential, and `closed` is a misconfiguration
548
+ * rather than a secret — nobody holds a credential for this mount, so
549
+ * there is no one to withhold it from.
536
550
  */
537
- return jsonResponse({ gateEnabled: isAccessGateEnabled() }, { cors });
551
+ const resolvedAuth = resolveAuth(config.auth);
552
+ return jsonResponse({
553
+ gateEnabled: isAccessGateEnabled(),
554
+ mode: resolvedAuth.mode,
555
+ ...(resolvedAuth.mode === "closed" ? { reason: CLOSED_HINT } : {})
556
+ }, { cors });
538
557
  }
539
558
  if (request.method === "POST" && path === "/auth/verify") {
540
559
  if (!isAccessGateEnabled()) {
@@ -1054,6 +1073,17 @@ export function createOrchestrator(config = {}) {
1054
1073
  return jsonResponse({
1055
1074
  plannerSource: providers[0] ?? "demo",
1056
1075
  availableProviders: providers,
1076
+ /*
1077
+ * Identity, so the editor's onboarding copy can be keyed on what
1078
+ * this mount actually is rather than on a list of ids it was
1079
+ * compiled with. `demoContent` is what `create-avocado-site` sets on
1080
+ * a scaffolded project.
1081
+ */
1082
+ site: {
1083
+ ...(config.siteId ? { id: config.siteId } : {}),
1084
+ ...(config.siteName ? { name: config.siteName } : {}),
1085
+ demoContent: config.demoContent === true
1086
+ },
1057
1087
  features: {
1058
1088
  googleDrive: false,
1059
1089
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
@@ -27,7 +27,7 @@ import { randomUUID } from "node:crypto";
27
27
  import { mkdir, writeFile } from "node:fs/promises";
28
28
  import { resolve } from "node:path";
29
29
  import OpenAI from "openai";
30
- import { getGeminiClient, getGeminiImageModel, saveGeneratedImage, GEMINI_ASPECT_RATIOS } from "../image/image-helpers.js";
30
+ import { getGeminiClient, getGeminiImageModel, resolveImageProvider, saveGeneratedImage, GEMINI_ASPECT_RATIOS } from "../image/image-helpers.js";
31
31
  import { openAIChatOptionsForModel } from "../chat/planner.js";
32
32
  import { toErrorDetail } from "../errors.js";
33
33
  /**
@@ -120,12 +120,14 @@ export async function generateImageAction(body, deps) {
120
120
  return { code: 400, body: { error: "prompt is required" } };
121
121
  const requestedProvider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
122
122
  const requestedModel = typeof body.model === "string" ? body.model.trim() : "";
123
- const envProvider = process.env.IMAGE_GEN_PROVIDER?.trim().toLowerCase() || "openai";
124
- const provider = requestedProvider || envProvider;
123
+ /* Resolved against the keys this process holds, not just against what was
124
+ * asked for — see resolveImageProvider. `hasOpenAI || hasGemini` is checked
125
+ * above, so this is non-null here. */
126
+ const provider = resolveImageProvider(requestedProvider) ?? "openai";
125
127
  const alt = prompt.slice(0, 200);
126
128
  const store = storeOf(deps);
127
129
  try {
128
- if (provider === "gemini" && hasGemini) {
130
+ if (provider === "gemini") {
129
131
  const url = await generateWithGemini({ prompt, aspectRatio: body.aspectRatio, model: requestedModel || undefined }, deps, store);
130
132
  if (!url)
131
133
  return { code: 502, body: { error: "Gemini image generation returned no data" } };
@@ -17,7 +17,7 @@
17
17
  * instead (useful for before/after comparisons).
18
18
  *
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
- * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
20
+ * meant this route worked on sites `create-avocado-site` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
22
  * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
@@ -17,7 +17,7 @@
17
17
  * instead (useful for before/after comparisons).
18
18
  *
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
- * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
20
+ * meant this route worked on sites `create-avocado-site` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
22
  * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
@@ -73,6 +73,23 @@ export declare function saveGeminiInlineImage(parts: Array<{
73
73
  } | null>;
74
74
  export declare const GEMINI_ASPECT_RATIOS: Record<string, string>;
75
75
  export declare function getGeminiClient(): Promise<any>;
76
+ export type ImageProvider = "openai" | "gemini";
77
+ /**
78
+ * Which image backend this process can actually reach, given the keys it holds.
79
+ *
80
+ * `IMAGE_GEN_PROVIDER` (and a per-request hint) says which backend is *wanted*;
81
+ * this says which one can answer. The two were previously conflated, and the
82
+ * fallback ran in one direction only — a gemini request without a Google key
83
+ * fell through to OpenAI, but an OpenAI request without an OpenAI key did not
84
+ * fall through to Gemini. Since `IMAGE_GEN_PROVIDER` defaults to `openai`, a
85
+ * deployment funding only `GOOGLE_GENAI_API_KEY` took that second branch on
86
+ * every request: `/status/planner` advertised `imageGenerate: true` (it is true
87
+ * for *either* key), the editor showed the Generate tab, and every generation
88
+ * came back "OPENAI_API_KEY not configured".
89
+ *
90
+ * Returns null when neither key is set, which is the caller's 503.
91
+ */
92
+ export declare function resolveImageProvider(requested?: string): ImageProvider | null;
76
93
  export declare function getGeminiImageModel(): string;
77
94
  export declare function generateVariationImageWithGemini(args: {
78
95
  prompt: string;
@@ -310,6 +310,31 @@ export async function getGeminiClient() {
310
310
  _geminiClient = new GoogleGenAI({ apiKey: process.env.GOOGLE_GENAI_API_KEY });
311
311
  return _geminiClient;
312
312
  }
313
+ /**
314
+ * Which image backend this process can actually reach, given the keys it holds.
315
+ *
316
+ * `IMAGE_GEN_PROVIDER` (and a per-request hint) says which backend is *wanted*;
317
+ * this says which one can answer. The two were previously conflated, and the
318
+ * fallback ran in one direction only — a gemini request without a Google key
319
+ * fell through to OpenAI, but an OpenAI request without an OpenAI key did not
320
+ * fall through to Gemini. Since `IMAGE_GEN_PROVIDER` defaults to `openai`, a
321
+ * deployment funding only `GOOGLE_GENAI_API_KEY` took that second branch on
322
+ * every request: `/status/planner` advertised `imageGenerate: true` (it is true
323
+ * for *either* key), the editor showed the Generate tab, and every generation
324
+ * came back "OPENAI_API_KEY not configured".
325
+ *
326
+ * Returns null when neither key is set, which is the caller's 503.
327
+ */
328
+ export function resolveImageProvider(requested) {
329
+ const hasOpenAI = Boolean(process.env.OPENAI_API_KEY);
330
+ const hasGemini = Boolean(process.env.GOOGLE_GENAI_API_KEY);
331
+ if (!hasOpenAI && !hasGemini)
332
+ return null;
333
+ const want = (requested?.trim().toLowerCase() || process.env.IMAGE_GEN_PROVIDER?.trim().toLowerCase() || "openai");
334
+ if (want === "gemini")
335
+ return hasGemini ? "gemini" : "openai";
336
+ return hasOpenAI ? "openai" : "gemini";
337
+ }
313
338
  export function getGeminiImageModel() {
314
339
  return process.env.GOOGLE_GENAI_IMAGE_MODEL?.trim() || "gemini-3.1-flash-lite-image";
315
340
  }
@@ -133,6 +133,7 @@ export declare function plannerContextPack(args: {
133
133
  }>;
134
134
  }[];
135
135
  pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
136
+ pageObservations: import("./intent-detection.ts").PageObservation[];
136
137
  pageIntent: string;
137
138
  recentSuccessfulEdits: {
138
139
  at: string;
@@ -1,6 +1,7 @@
1
1
  import { getSessionDraft, getRecentEdits, getSiteConfig, orderSlugsHomeFirst } from "../state/session-state.js";
2
2
  import { getLibraryMount } from "../handler/library-mount.js";
3
3
  import { resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
4
+ import { pageObservations } from "./intent-detection.js";
4
5
  // ---------------------------------------------------------------------------
5
6
  // Path traversal
6
7
  // ---------------------------------------------------------------------------
@@ -411,6 +412,13 @@ export function plannerContextPack(args) {
411
412
  return { id: b.id, type: b.type, props: textHints, arrayProps: arrProps };
412
413
  }),
413
414
  pageMeta: currentPage.meta ?? null,
415
+ /*
416
+ * Facts about this page that reading props does not reveal: a declared
417
+ * image field with nothing in it, a page nothing links out of, a missing
418
+ * meta description. Cheap to compute and impossible to infer from the
419
+ * outline above, so a review is grounded in them rather than guessing.
420
+ */
421
+ pageObservations: pageObservations(currentPage),
414
422
  pageIntent: pageIntentSummary({ slug, currentPage }),
415
423
  recentSuccessfulEdits: getRecentEdits(session, slug),
416
424
  resolvedReferences: resolveReferencesFromMessage({ message, currentPage, activeBlockId }),
@@ -56,4 +56,45 @@ type BlockContract = {
56
56
  * get a contract derived from their JSON schema.
57
57
  */
58
58
  export declare function blockContractsSummary(manifest?: BlockManifest): Record<string, BlockContract>;
59
+ /**
60
+ * With no provider key in the environment `resolvePlannerSource` answers
61
+ * "demo", and every request is planned by the rule-based planner above. It
62
+ * handles a useful slice of literal edits — `change the hero headline to "X"` —
63
+ * and answers everything else with a clarifying question.
64
+ *
65
+ * That question was the defect. Someone who scaffolds the demo, types "Rewrite
66
+ * the hero headline" and is asked "what section should I change and what
67
+ * exactly should be updated?" reads it as a planner that cannot understand
68
+ * plain English — not as a planner that was never given a key. Shipping the
69
+ * demo keyless is deliberate, so that you can look around before spending
70
+ * anything; nothing in the product ever said so, which turned the most likely
71
+ * first action into a dead end.
72
+ *
73
+ * So when the rules produce no operations, say why. The suggestions are kept:
74
+ * they are the phrasings this planner *can* execute without a key.
75
+ */
76
+ export declare const KEYLESS_PLANNER_NOTICE: string;
77
+ export declare function withKeylessNotice(plan: EditPlan): EditPlan;
78
+ /**
79
+ * Drop the suggestions this planner could not act on.
80
+ *
81
+ * `postEditSuggestions` and `clarificationSuggestions` are written against the
82
+ * product — "Edit Banner", "Move FAQ", "Add author photos" — because with a key
83
+ * configured a model answers them, and it can. With no key the answer comes
84
+ * from `demoPlanFromMessage`, a short list of English substring matchers, and
85
+ * almost none of those phrasings is on it. A clean-room first run clicked six
86
+ * follow-up chips against a keyless scaffold; five produced zero operations and
87
+ * the same paragraph about adding a key.
88
+ *
89
+ * A suggestion chip is sent verbatim on click, so an unanswerable one is a
90
+ * button that does nothing. The filter is the matcher itself rather than a
91
+ * second list of blessed phrasings — a list would be one more thing to drift,
92
+ * and this cannot: if `demoPlanFromMessage` stops answering something, the chip
93
+ * for it stops being offered in the same commit.
94
+ *
95
+ * Returning fewer chips, or none, is the intended outcome. Three real ones beat
96
+ * six of which one works, and no chips at all leaves the keyless notice to say
97
+ * the thing that actually needs saying.
98
+ */
99
+ export declare function keepDemoExecutable(suggestions: string[], slug: string, activeBlockId?: string, activeBlockType?: string): string[];
59
100
  export {};
@@ -393,13 +393,18 @@ export function demoPlanFromMessage(message, slug, activeBlockId, activeBlockTyp
393
393
  if (lower.includes("add testimonials")) {
394
394
  return {
395
395
  intent: "edit_plan",
396
- summary_for_user: "Added a testimonials section below the hero.",
397
- change_log: ["Inserted Testimonials block after the hero section."],
396
+ summary_for_user: "Added a testimonials section.",
397
+ change_log: ["Inserted a Testimonials block at the end of the page."],
398
398
  ops: [
399
399
  {
400
400
  op: "add_block",
401
401
  pageSlug: slug,
402
- afterBlockId: "b_hero_home",
402
+ /* No `afterBlockId`. It used to name `b_hero_home`, a block id that
403
+ * exists on no page in the shipped demo — the home hero is
404
+ * `b_hero_1772138902220_copy` — so the anchor silently missed and the
405
+ * block landed whereever the fallback put it, while the summary
406
+ * promised "below the hero". Appending is the honest version of what
407
+ * it already did, and it cannot point at a block that is not there. */
403
408
  block: {
404
409
  id: `b_testimonials_${Date.now()}`,
405
410
  type: "Testimonials",
@@ -603,3 +608,58 @@ export function blockContractsSummary(manifest) {
603
608
  }
604
609
  return result;
605
610
  }
611
+ /**
612
+ * With no provider key in the environment `resolvePlannerSource` answers
613
+ * "demo", and every request is planned by the rule-based planner above. It
614
+ * handles a useful slice of literal edits — `change the hero headline to "X"` —
615
+ * and answers everything else with a clarifying question.
616
+ *
617
+ * That question was the defect. Someone who scaffolds the demo, types "Rewrite
618
+ * the hero headline" and is asked "what section should I change and what
619
+ * exactly should be updated?" reads it as a planner that cannot understand
620
+ * plain English — not as a planner that was never given a key. Shipping the
621
+ * demo keyless is deliberate, so that you can look around before spending
622
+ * anything; nothing in the product ever said so, which turned the most likely
623
+ * first action into a dead end.
624
+ *
625
+ * So when the rules produce no operations, say why. The suggestions are kept:
626
+ * they are the phrasings this planner *can* execute without a key.
627
+ */
628
+ export const KEYLESS_PLANNER_NOTICE = "There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
629
+ "Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
630
+ export function withKeylessNotice(plan) {
631
+ if (plan.ops.length > 0)
632
+ return plan;
633
+ return { ...plan, summary_for_user: KEYLESS_PLANNER_NOTICE };
634
+ }
635
+ /**
636
+ * Drop the suggestions this planner could not act on.
637
+ *
638
+ * `postEditSuggestions` and `clarificationSuggestions` are written against the
639
+ * product — "Edit Banner", "Move FAQ", "Add author photos" — because with a key
640
+ * configured a model answers them, and it can. With no key the answer comes
641
+ * from `demoPlanFromMessage`, a short list of English substring matchers, and
642
+ * almost none of those phrasings is on it. A clean-room first run clicked six
643
+ * follow-up chips against a keyless scaffold; five produced zero operations and
644
+ * the same paragraph about adding a key.
645
+ *
646
+ * A suggestion chip is sent verbatim on click, so an unanswerable one is a
647
+ * button that does nothing. The filter is the matcher itself rather than a
648
+ * second list of blessed phrasings — a list would be one more thing to drift,
649
+ * and this cannot: if `demoPlanFromMessage` stops answering something, the chip
650
+ * for it stops being offered in the same commit.
651
+ *
652
+ * Returning fewer chips, or none, is the intended outcome. Three real ones beat
653
+ * six of which one works, and no chips at all leaves the keyless notice to say
654
+ * the thing that actually needs saying.
655
+ */
656
+ export function keepDemoExecutable(suggestions, slug, activeBlockId, activeBlockType) {
657
+ return suggestions.filter((suggestion) => {
658
+ try {
659
+ return demoPlanFromMessage(suggestion, slug, activeBlockId, activeBlockType).ops.length > 0;
660
+ }
661
+ catch {
662
+ return false;
663
+ }
664
+ });
665
+ }
@@ -78,7 +78,7 @@ export declare function compileDeterministicPlan(args: {
78
78
  locale?: string;
79
79
  }): EditPlan | null;
80
80
  export { extractAudienceTarget, extractAudienceTargets, titleCaseWords, addAudienceSuffix, audiencePatchForBlock, coercePatchForBlock, parseIndexedPath, inferSimpleFieldPatchFromMessage, isRewriteRequest, isTranslationRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, rewriteFromExisting, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.ts";
81
- export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.ts";
81
+ export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, keepDemoExecutable, withKeylessNotice, KEYLESS_PLANNER_NOTICE, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.ts";
82
82
  export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.ts";
83
83
  export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.ts";
84
84
  export type { ResolvedLlmAttachment } from "./deterministic-planner-context.ts";
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { allowedBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
2
+ import { allowedBlockTypes, catalogueBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
3
3
  import { extractRouteMentions, normalizeRouteCandidate, parseCreatePageRequest, requestsContentGeneration, toSeedSlug } from "./intent-helpers.js";
4
4
  import { isBatchAddRequest, isFieldContentUpdateRequest, stripSiteContextEnvelope, stripPositionalAnchorPhrase, extractMentionedBlockTypes, hasCrossPageScope } from "./intent-detection.js";
5
5
  import { defaultPropsForType, inferBlockTypeFromText, nextBlockId } from "./plan-normalizer.js";
@@ -1521,7 +1521,10 @@ export function compileDeterministicPlan(args) {
1521
1521
  }
1522
1522
  return {
1523
1523
  intent: "needs_clarification",
1524
- summary_for_user: st(args.locale, "add.specifyType", { types: allowedBlockTypes.map(blockLabel).join(", ") }),
1524
+ // The types this site can draw, not every type registered in the process —
1525
+ // the built-ins register transitively, so `allowedBlockTypes` would ask a
1526
+ // person to pick from blocks their site has no renderer for.
1527
+ summary_for_user: st(args.locale, "add.specifyType", { types: catalogueBlockTypes().map(blockLabel).join(", ") }),
1525
1528
  change_log: assumptions,
1526
1529
  ops: []
1527
1530
  };
@@ -1625,7 +1628,7 @@ export function compileDeterministicPlan(args) {
1625
1628
  // Re-exports from split files (preserve public API)
1626
1629
  // ---------------------------------------------------------------------------
1627
1630
  export { extractAudienceTarget, extractAudienceTargets, titleCaseWords, addAudienceSuffix, audiencePatchForBlock, coercePatchForBlock, parseIndexedPath, inferSimpleFieldPatchFromMessage, isRewriteRequest, isTranslationRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, rewriteFromExisting, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.js";
1628
- export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.js";
1631
+ export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, keepDemoExecutable, withKeylessNotice, KEYLESS_PLANNER_NOTICE, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.js";
1629
1632
  export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
1630
1633
  export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.js";
1631
1634
  export { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveByDescriptor, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
@@ -269,17 +269,13 @@ export declare function isPageListQuery(message: string): boolean;
269
269
  */
270
270
  export declare function hasCrossPageScope(message: string): boolean;
271
271
  export declare function isContentQuery(message: string): boolean;
272
- export declare function isAdviceQuery(message: string): boolean;
273
- export declare function adviceResponse(args: {
274
- body: ChatRequestBody;
275
- current: PageDoc;
276
- plannerSource: "openai" | "anthropic" | "gemini" | "demo";
277
- modelUsed: string;
278
- modelKey: ModelKey;
279
- }): {
280
- code: number;
281
- payload: ChatResult;
272
+ export type PageObservation = {
273
+ /** What is true, stated so a reader can check it. */
274
+ fact: string;
275
+ /** The edit that would resolve it, phrased as a command the planner can run. */
276
+ fix: string;
282
277
  };
278
+ export declare function pageObservations(current: PageDoc): PageObservation[];
283
279
  export declare function plannerMessageWithPendingContext(session: string, message: string): string;
284
280
  /** Build the site context lines without wrapping in a message. Returns null if empty. */
285
281
  /** How many documents ride along in a planner request. */
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { allowedBlockTypes, getBlockMeta, getPropDisplayName } from "@avocadostudio-ai/shared";
2
+ import { catalogueBlockTypes, getBlockMeta, getPropDisplayName } from "@avocadostudio-ai/shared";
3
3
  import { isLikelyClarificationFollowUp, isStandalonePageOperation } from "./intent-helpers.js";
4
4
  import { versions, pendingClarificationBySession } from "../state/session-state.js";
5
5
  import { UNIT, BLOCK_CATALOG_PATTERNS, BATCH_ADD_PATTERNS, BATCH_UPDATE_PATTERNS, BATCH_PAGE_CREATE_PATTERNS, COUNTED_MULTI_BLOCK_ADD_PATTERN, ADD_ACTION_PATTERN, BLOCK_TYPE_KEYWORDS, KEYWORD_TO_BLOCK_TYPE, EACH_BLOCK_TYPE_PATTERN, PAGE_WIDE_REWRITE_PATTERNS, BATCH_REORDER_PATTERNS, PAGE_LIST_PATTERNS, CONTENT_QUERY_PATTERNS, FIELD_CONTENT_UPDATE_PATTERN, CROSS_PAGE_SCOPE_PATTERN } from "./intent-patterns.js";
@@ -297,151 +297,143 @@ export function isContentQuery(message) {
297
297
  return false;
298
298
  return CONTENT_QUERY_PATTERNS.some((re) => re.test(m));
299
299
  }
300
- export function isAdviceQuery(message) {
301
- const m = normalizeForIntent(message);
302
- // Exclude structural action requests that happen to contain "should we/I"
303
- if (/\b(reorder|reorganize|restructure|rearrange|sort|move)\b/.test(m) && /\bpages?\b/.test(m))
304
- return false;
305
- // Exclude explicit edit verbs — "review copy for clarity" is an edit, not advice
306
- // But allow "should we/I add X?" phrasing — those are advice, not commands
307
- if (!/\bshould\s+(we|i)\b/.test(m) && /\b(?:add|create|remove|delete|update|change|replace|move|rewrite|translate|rename)\b/.test(m))
308
- return false;
309
- return (/\b(is it good|is this good|should (we|i)|do you recommend|would you recommend)\b/.test(m) ||
310
- /\bwhat do you think\b/.test(m) ||
311
- /\bwhat (can|should) be improved\b/.test(m) ||
312
- /\bhow can (this|the) page be improved\b/.test(m) ||
313
- /\bhow (can|should) i improve (this|the) page\b/.test(m) ||
314
- /\bimprovements?\b/.test(m) ||
315
- /\bis faq\b/.test(m) ||
316
- /\bshould .*faq\b/.test(m) ||
317
- /\bgood idea\b/.test(m) ||
318
- /\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+)?(?:this\s+)?(?:page|site|content|copy|text)\b/.test(m));
319
- }
320
- export function adviceResponse(args) {
321
- const { body, current, plannerSource, modelUsed, modelKey } = args;
322
- const message = (body.message ?? "").toLowerCase();
323
- const pageLabel = current.slug === "/" ? "this home page" : `this page (${current.slug})`;
324
- const hasFaq = current.blocks.some((block) => block.type === "FAQAccordion");
325
- const hasHero = current.blocks.some((block) => block.type === "Hero");
326
- const hasCta = current.blocks.some((block) => block.type === "CTA");
327
- if (/\bfaq\b/.test(message)) {
328
- const summary = hasFaq
329
- ? `Yes, FAQ can work on ${pageLabel}, but keep it concise and near the bottom so it supports decisions without distracting from the main content.`
330
- : `FAQ is usually a good fit on ${pageLabel} when visitors may have objections (pricing, process, trust, support).`;
331
- const changes = hasFaq
332
- ? ["Current state: FAQ already exists on this page.", "Recommendation: keep 3-6 high-intent questions."]
333
- : ["Current state: no FAQ block detected on this page.", "Recommendation: add a compact FAQ section near the bottom."];
334
- return {
335
- code: 200,
336
- payload: {
337
- status: "advice",
338
- summary,
339
- changes,
340
- suggestions: hasFaq
341
- ? ["Move FAQ to bottom", "Rewrite FAQ questions for this audience", "Keep FAQ, but reduce to 4 questions"]
342
- : ["Add FAQ section with 4 questions at the bottom", "Add FAQ below testimonials", "Skip FAQ on this page"],
343
- mentionedSlugs: [current.slug],
344
- previewVersion: versions.get(body.session ?? "dev") ?? 0,
345
- plannerSource,
346
- modelUsed,
347
- modelKey
348
- }
349
- };
350
- }
351
- // Analyze existing blocks on the page
352
- const existingTypes = new Set(current.blocks.map((b) => b.type));
353
- const presentList = current.blocks.map((b) => {
354
- const meta = getBlockMeta(b.type);
355
- return meta ? `${meta.displayName}` : b.type;
356
- });
357
- const missingTypes = allowedBlockTypes.filter((t) => !existingTypes.has(t));
358
- const changes = [];
359
- changes.push(`Current blocks (${current.blocks.length}): ${presentList.join(", ") || "none"}.`);
360
- if (!hasHero)
361
- changes.push("Missing: Hero — consider adding a headline section at the top.");
362
- if (!hasCta)
363
- changes.push("Missing: CTA — add a call-to-action to drive conversions.");
364
- if (!existingTypes.has("Testimonials") && !existingTypes.has("Stats"))
365
- changes.push("Missing: social proof (Testimonials or Stats) — builds trust.");
366
- if (!existingTypes.has("FAQAccordion"))
367
- changes.push("Missing: FAQ — addresses objections and improves SEO.");
368
- // Build contextual suggestions based on what's actually missing
369
- const suggestions = [];
370
- const suggestionPriority = [
371
- { type: "Hero", label: "Add a Hero section at the top" },
372
- { type: "CTA", label: "Add a CTA section to drive conversions" },
373
- { type: "Testimonials", label: "Add Testimonials for social proof" },
374
- { type: "Stats", label: "Add Stats to highlight key numbers" },
375
- { type: "FAQAccordion", label: "Add FAQ at the bottom" },
376
- { type: "FeatureGrid", label: "Add a Feature Grid to list benefits" },
377
- { type: "CardGrid", label: "Add a Card Grid for related content" },
378
- { type: "ContactForm", label: "Add a Contact Form" },
379
- { type: "Footer", label: "Add a Footer with links" }
380
- ];
381
- for (const item of suggestionPriority) {
382
- if (!existingTypes.has(item.type) && suggestions.length < 4)
383
- suggestions.push(item.label);
384
- }
385
- // If page has all common blocks, suggest content improvements based on actual props
386
- if (suggestions.length === 0) {
387
- for (const block of current.blocks) {
388
- if (suggestions.length >= 4)
389
- break;
390
- const p = block.props;
391
- if (!p)
300
+ /** `imageUrl` -> `image url`, for a field whose site never gave it a label. */
301
+ function humanizePropKey(key) {
302
+ return key
303
+ .replace(/\[\]\./g, " ")
304
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
305
+ .replace(/[_-]+/g, " ")
306
+ .trim()
307
+ .toLowerCase();
308
+ }
309
+ export function pageObservations(current) {
310
+ const observations = [];
311
+ if (current.blocks.length === 0)
312
+ return observations;
313
+ const metaFor = (type) => getBlockMeta(type);
314
+ const nameOf = (type) => metaFor(type)?.displayName ?? type;
315
+ const categoryOf = (type) => metaFor(type)?.category;
316
+ const presentTypes = new Set(current.blocks.map((block) => block.type));
317
+ const addableTypes = catalogueBlockTypes().filter((type) => !presentTypes.has(type) && !metaFor(type)?.chrome);
318
+ /** Every declared field value on a block, list items included. */
319
+ const eachField = (block, visit) => {
320
+ const meta = metaFor(block.type);
321
+ if (!meta)
322
+ return;
323
+ const props = (block.props ?? {});
324
+ for (const [key, field] of Object.entries(meta.fields)) {
325
+ if (!field.internal)
326
+ visit(key, field, props[key]);
327
+ }
328
+ for (const [listKey, listMeta] of Object.entries(meta.listFields ?? {})) {
329
+ const items = props[listKey];
330
+ if (!Array.isArray(items))
392
331
  continue;
393
- if (block.type === "Hero") {
394
- const heading = (p.heading ?? p.title ?? "");
395
- if (!p.imageUrl && !p.backgroundImage) {
396
- suggestions.push("Add a hero image");
397
- }
398
- else if (heading && heading.length > 60) {
399
- suggestions.push("Shorten the hero headline to under 60 characters");
400
- }
401
- else if (heading) {
402
- suggestions.push("Rewrite the hero headline to be more action-oriented");
403
- }
404
- }
405
- if (block.type === "Stats") {
406
- const items = (p.items ?? p.stats);
407
- if (items?.some((it) => (it.label ?? it.description ?? "").length > 30)) {
408
- suggestions.push("Rewrite the stats labels to be shorter and number-driven");
332
+ for (const item of items) {
333
+ if (!item || typeof item !== "object")
334
+ continue;
335
+ const row = item;
336
+ const branch = listMeta.discriminator
337
+ ? listMeta.itemFieldsByType?.[String(row[listMeta.discriminator])] ?? listMeta.itemFields
338
+ : listMeta.itemFields;
339
+ for (const [key, field] of Object.entries(branch)) {
340
+ if (!field.internal)
341
+ visit(`${listKey}[].${key}`, field, row[key]);
409
342
  }
410
343
  }
411
- if (block.type === "CTA") {
412
- const btn = (p.buttonText ?? p.ctaText ?? "");
413
- if (btn.length > 20) {
414
- suggestions.push("Shorten the CTA button text");
415
- }
344
+ }
345
+ };
346
+ const filled = (value) => typeof value === "string" && value.trim().length > 0;
347
+ let hasImageAnywhere = false;
348
+ let hasLinkAnywhere = false;
349
+ /*
350
+ * Keyed by block type + field rather than pushed per occurrence: an empty
351
+ * avatar on every row of a testimonials list is one finding about one field,
352
+ * and the first version of this reported it once per row.
353
+ */
354
+ const emptyImages = new Map();
355
+ for (const block of current.blocks) {
356
+ eachField(block, (key, field, value) => {
357
+ // `getPropDisplayName` takes (blockType, propKey) in that order, and falls
358
+ // back to the raw key when a field declares no label — so a site that
359
+ // never labelled its props would otherwise be told about "imageUrl".
360
+ const fieldLabel = field.label ?? humanizePropKey(getPropDisplayName(block.type, key));
361
+ if (field.kind === "image" || field.kind === "imageList") {
362
+ if (filled(value) || (Array.isArray(value) && value.length > 0))
363
+ hasImageAnywhere = true;
416
364
  else {
417
- suggestions.push("Strengthen the CTA copy to create urgency");
365
+ const id = `${block.type}:${key}`;
366
+ const seen = emptyImages.get(id);
367
+ if (seen)
368
+ seen.count += 1;
369
+ else
370
+ emptyImages.set(id, { block: nameOf(block.type), label: fieldLabel, count: 1 });
418
371
  }
419
372
  }
420
- }
421
- // Fallback if no content-specific suggestions were derived
422
- if (suggestions.length === 0) {
423
- if (hasHero)
424
- suggestions.push("Rewrite the hero headline");
425
- if (hasCta)
426
- suggestions.push("Strengthen the CTA copy");
427
- suggestions.push("Tighten the copy across all sections");
428
- }
373
+ if ((field.kind === "url" || field.kind === "link") && filled(value))
374
+ hasLinkAnywhere = true;
375
+ });
429
376
  }
430
- const summary = `For ${pageLabel} with ${current.blocks.length} block${current.blocks.length === 1 ? "" : "s"}: ${missingTypes.length > 0 ? `consider adding ${missingTypes.slice(0, 3).join(", ")}` : "all major sections are covered — focus on refining content"}.`;
431
- return {
432
- code: 200,
433
- payload: {
434
- status: "advice",
435
- summary,
436
- changes,
437
- suggestions,
438
- mentionedSlugs: [current.slug],
439
- previewVersion: versions.get(body.session ?? "dev") ?? 0,
440
- plannerSource,
441
- modelUsed,
442
- modelKey
443
- }
444
- };
377
+ /*
378
+ * A structural gap is only worth raising when the page really lacks the thing
379
+ * *and* this site has something to fill it with. The filler types are
380
+ * alternatives, so they are offered as a choice.
381
+ *
382
+ * The category is read off the *catalogue*, never off the page: an earlier
383
+ * version asked "is any block on this page of category media" and told a home
384
+ * page carrying a hero with a photograph that it was all text. A block's
385
+ * category says what kind of block it is. Only the value says what is on the
386
+ * page.
387
+ */
388
+ const addableIn = (category) => addableTypes.filter((type) => categoryOf(type) === category);
389
+ const orList = (types) => types.slice(0, 3).map(nameOf).join(" or ");
390
+ if (!hasImageAnywhere) {
391
+ const media = addableIn("media");
392
+ observations.push({
393
+ fact: media.length > 0
394
+ ? `No image anywhere on this page — every block is text. This site can add: ${orList(media)}.`
395
+ : "No image anywhere on this page — every block is text.",
396
+ fix: media.length > 0 ? `Add ${nameOf(media[0])}` : "Add an image to the first section"
397
+ });
398
+ }
399
+ if (!hasLinkAnywhere) {
400
+ const conversion = addableIn("conversion");
401
+ observations.push({
402
+ fact: conversion.length > 0
403
+ ? `Nothing on this page links anywhere — no button or link has a destination. This site can add: ${orList(conversion)}.`
404
+ : "Nothing on this page links anywhere — no button or link has a destination.",
405
+ fix: conversion.length > 0 ? `Add ${nameOf(conversion[0])}` : "Give the buttons a destination"
406
+ });
407
+ }
408
+ /*
409
+ * Only worth saying when the page has images elsewhere. On a page with none,
410
+ * "no image anywhere" above is the finding, and one "this block's image is
411
+ * empty" per block underneath it is the same fact restated per row.
412
+ */
413
+ for (const hole of hasImageAnywhere ? [...emptyImages.values()].slice(0, 3) : []) {
414
+ const many = hole.count > 1 ? ` (${hole.count} of them)` : "";
415
+ observations.push({
416
+ fact: `${hole.block} declares ${hole.label.toLowerCase()} and it is empty${many}.`,
417
+ fix: `Add ${hole.label.toLowerCase()} to ${hole.block}`
418
+ });
419
+ }
420
+ const meta = current.meta;
421
+ if (!filled(meta?.description)) {
422
+ observations.push({
423
+ fact: "This page has no meta description — search results and link previews fall back to whatever text comes first.",
424
+ fix: "Write a meta description for this page"
425
+ });
426
+ }
427
+ /*
428
+ * No length check, and there was one for an afternoon. It flagged any
429
+ * single-line `text` field over sixty characters as an overlong headline, and
430
+ * the first thing it found on a real page was the hero's *subheading* at 160
431
+ * characters — which is prose, and fine. Nothing in the manifest says which
432
+ * text field is a headline and which is a paragraph, so the check asserted a
433
+ * role it could not know. Judging whether a sentence is too long is the
434
+ * model's job, and the model can now see the sentence.
435
+ */
436
+ return observations;
445
437
  }
446
438
  // A pending clarification only contextualizes the answer that immediately follows it.
447
439
  // One left unanswered for longer than this is stale (a prior session's leftover, or the
@@ -676,19 +668,37 @@ export function infoResponse(args) {
676
668
  };
677
669
  }
678
670
  if (isBlockCatalogQuery(body.message ?? "")) {
671
+ /*
672
+ * "What can I add?" is a question about this site, and it used to be
673
+ * answered with `allowedBlockTypes` — every type registered in the process,
674
+ * which importing `@avocadostudio-ai/shared` at all fills with the
675
+ * built-ins. A site rendering its own types was handed a list of thirty-two
676
+ * and five worked examples naming blocks it does not have, down to the
677
+ * placement anchors ("add Testimonials below Hero" on a page with neither).
678
+ * Every name here now comes from the catalogue, and the anchor from a block
679
+ * that is actually on the page.
680
+ */
681
+ const catalogue = catalogueBlockTypes().filter((type) => !getBlockMeta(type)?.chrome);
682
+ const nameOf = (type) => getBlockMeta(type)?.displayName ?? type;
683
+ const presentTypes = new Set(current.blocks.map((block) => block.type));
684
+ const addable = catalogue.filter((type) => !presentTypes.has(type));
685
+ // A real block on this page, to anchor an example against. Undefined on an
686
+ // empty page, where "at the end" is the only placement that means anything.
687
+ const anchor = current.blocks.length > 0 ? nameOf(current.blocks[current.blocks.length - 1].type) : undefined;
688
+ const examples = (addable.length > 0 ? addable : catalogue).slice(0, 5).map((type, i) => anchor && i % 2 === 1 ? `Add ${nameOf(type)} below ${anchor}` : `Add ${nameOf(type)} at the end`);
679
689
  return {
680
690
  code: 200,
681
691
  payload: {
682
692
  status: "info",
683
- summary: `You can add these block types: ${allowedBlockTypes.join(", ")}.`,
684
- changes: ["Tip: specify position, e.g. \u201cadd Testimonials below Hero\u201d."],
685
- suggestions: [
686
- "Add Testimonials below Hero",
687
- "Add CardGrid at the end",
688
- "Add FeatureGrid after Hero",
689
- "Add FAQAccordion before CTA",
690
- "Add CTA at the end"
693
+ summary: catalogue.length > 0
694
+ ? `You can add these block types: ${catalogue.map(nameOf).join(", ")}.`
695
+ : "This site has not registered any block types yet, so there is nothing to add.",
696
+ changes: [
697
+ anchor
698
+ ? `Tip: specify position, e.g. \u201cadd ${nameOf((addable[0] ?? catalogue[0]))} below ${anchor}\u201d.`
699
+ : "Tip: specify position, e.g. \u201cadd it at the end\u201d."
691
700
  ],
701
+ suggestions: examples,
692
702
  previewVersion: versions.get(body.session ?? "dev") ?? 0,
693
703
  plannerSource,
694
704
  modelUsed,
@@ -210,7 +210,18 @@ export const CONTENT_QUERY_PATTERNS = [
210
210
  /\bsummarize\s+(?:this|the)\s+(?:page|content)\b/,
211
211
  /\bwhat\s+(?:blocks?|sections?)\s+(?:are|is)\s+(?:on|in)\s+(?:this|the)\s+page\b/,
212
212
  /\b(?:list|show|tell me)\b.*\b(?:all|every)\b.*\b(?:blocks?|sections?|components?)\b.*\b(?:on|in)\b/,
213
- /\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+)?(?:page|content|copy|text|links?|images?|buttons?|hero|cta|testimonials?|faq|feature\s*grid|card\s*grid|stats?|footer|contact\s*form|rich\s*text|two\s*column|carousel|gallery|tabs?|table|quote|video|embed|banner|section|block)\b/,
213
+ /\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+|this\s+|my\s+)?(?:page|site|content|copy|text|links?|images?|buttons?|hero|cta|testimonials?|faq|feature\s*grid|card\s*grid|stats?|footer|contact\s*form|rich\s*text|two\s*column|carousel|gallery|tabs?|table|quote|video|embed|banner|section|block)\b/,
214
+ /*
215
+ * Page-feedback questions. These used to be intercepted by `isAdviceQuery`
216
+ * and answered from a template; they are judgement questions, so they belong
217
+ * to the model, and matching them here is what gets the model the full props
218
+ * it needs to answer about actual copy rather than about block names.
219
+ */
220
+ /\b(?:what do you think|any (?:thoughts|feedback|suggestions)|is (?:it|this) good)\b/,
221
+ /\bwhat\s+(?:can|should)\s+(?:be\s+)?improve/,
222
+ /\bhow\s+(?:can|should)\s+(?:i|we)?\s*improve\b/,
223
+ /\bimprovements?\b/,
224
+ /\b(?:do|would)\s+you\s+recommend\b/,
214
225
  /\bdescribe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration|page|site|content|section)\b/,
215
226
  ];
216
227
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- import { generateVariationImageWithOpenAI, generateVariationImageWithGemini, recordImageGenDuration, estimatedImageGenMs } from "../../image/image-helpers.js";
1
+ import { generateVariationImageWithOpenAI, generateVariationImageWithGemini, recordImageGenDuration, estimatedImageGenMs, resolveImageProvider } from "../../image/image-helpers.js";
2
2
  import { getPage } from "../../state/session-state.js";
3
3
  const ASPECT_RATIO_TO_SIZE = {
4
4
  landscape: "1536x1024",
@@ -170,7 +170,9 @@ export const imageGenerateHandler = async ({ input, context, signal }) => {
170
170
  const pct = Math.min(Math.round(cur.pct + stageProgress * (next.pct - cur.pct)), 95);
171
171
  onProgress({ percent: pct, stage: cur.label });
172
172
  }, 500) : null;
173
- const provider = (process.env.IMAGE_GEN_PROVIDER?.trim().toLowerCase()) || "openai";
173
+ /* Key-aware, not just env-aware: a deployment holding only
174
+ * GOOGLE_GENAI_API_KEY used to land on the OpenAI branch here and fail. */
175
+ const provider = resolveImageProvider();
174
176
  const genStart = Date.now();
175
177
  let result = null;
176
178
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -20,10 +20,10 @@
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
21
21
  "better-sqlite3": "^12.9.0",
22
22
  "openai": "^4.87.1",
23
- "sharp": "^0.34.5",
23
+ "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/shared": "^0.8.0",
26
- "@avocadostudio-ai/migration-sdk": "^0.8.0"
25
+ "@avocadostudio-ai/shared": "^0.10.0",
26
+ "@avocadostudio-ai/migration-sdk": "^0.10.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",