@avocadostudio-ai/orchestrator-core 0.8.0 → 0.9.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, 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";
@@ -919,11 +919,17 @@ export async function runChatPipeline(ctx, body, options) {
919
919
  const info = infoResponse({ body, current, plannerSource, modelUsed, modelKey });
920
920
  return { code: info.code, payload: withDebugPayload(info.payload, { outcome: "info" }) };
921
921
  }
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
- }
922
+ /*
923
+ * There is no deterministic branch for "review this page" any more.
924
+ *
925
+ * There was, and it answered in twelve milliseconds from a template — which
926
+ * is why a page review read like a linter. The planner prompt already has the
927
+ * rule (PAGE FEEDBACK -> content_answer, "specific, reasoned recommendations
928
+ * based on the page topic and content, not a generic checklist"); it was
929
+ * never reached, because two detectors claimed the same message and the
930
+ * canned one was tested first. What survives is `pageObservations`, which the
931
+ * context pack carries so the model reviews with the checked facts in hand.
932
+ */
927
933
  if (body.message && isPageListQuery(body.message)) {
928
934
  const directory = buildPageDirectory(body.session);
929
935
  const draft = getSessionDraft(body.session);
@@ -2961,7 +2967,7 @@ export async function runChatPipeline(ctx, body, options) {
2961
2967
  markPlanningStart();
2962
2968
  try {
2963
2969
  emitStatusTone("planning");
2964
- const demoPlan = demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType);
2970
+ const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
2965
2971
  markPlanningFinish();
2966
2972
  const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
2967
2973
  if (outcome.done)
@@ -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.
@@ -1054,6 +1054,17 @@ export function createOrchestrator(config = {}) {
1054
1054
  return jsonResponse({
1055
1055
  plannerSource: providers[0] ?? "demo",
1056
1056
  availableProviders: providers,
1057
+ /*
1058
+ * Identity, so the editor's onboarding copy can be keyed on what
1059
+ * this mount actually is rather than on a list of ids it was
1060
+ * compiled with. `demoContent` is what `create-avocado-site` sets on
1061
+ * a scaffolded project.
1062
+ */
1063
+ site: {
1064
+ ...(config.siteId ? { id: config.siteId } : {}),
1065
+ ...(config.siteName ? { name: config.siteName } : {}),
1066
+ demoContent: config.demoContent === true
1067
+ },
1057
1068
  features: {
1058
1069
  googleDrive: false,
1059
1070
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
@@ -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
@@ -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,23 @@ 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;
59
78
  export {};
@@ -603,3 +603,27 @@ export function blockContractsSummary(manifest) {
603
603
  }
604
604
  return result;
605
605
  }
606
+ /**
607
+ * With no provider key in the environment `resolvePlannerSource` answers
608
+ * "demo", and every request is planned by the rule-based planner above. It
609
+ * handles a useful slice of literal edits — `change the hero headline to "X"` —
610
+ * and answers everything else with a clarifying question.
611
+ *
612
+ * That question was the defect. Someone who scaffolds the demo, types "Rewrite
613
+ * the hero headline" and is asked "what section should I change and what
614
+ * exactly should be updated?" reads it as a planner that cannot understand
615
+ * plain English — not as a planner that was never given a key. Shipping the
616
+ * demo keyless is deliberate, so that you can look around before spending
617
+ * anything; nothing in the product ever said so, which turned the most likely
618
+ * first action into a dead end.
619
+ *
620
+ * So when the rules produce no operations, say why. The suggestions are kept:
621
+ * they are the phrasings this planner *can* execute without a key.
622
+ */
623
+ 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. " +
624
+ "Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
625
+ export function withKeylessNotice(plan) {
626
+ if (plan.ops.length > 0)
627
+ return plan;
628
+ return { ...plan, summary_for_user: KEYLESS_PLANNER_NOTICE };
629
+ }
@@ -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, 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, 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
  // ---------------------------------------------------------------------------
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.9.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.9.0",
26
+ "@avocadostudio-ai/migration-sdk": "^0.9.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",