@avocadostudio-ai/orchestrator-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,1631 @@
1
+ import { z } from "zod";
2
+ import { allowedBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
3
+ import { extractRouteMentions, normalizeRouteCandidate, parseCreatePageRequest, requestsContentGeneration, toSeedSlug } from "./intent-helpers.js";
4
+ import { isBatchAddRequest, isFieldContentUpdateRequest, stripSiteContextEnvelope, stripPositionalAnchorPhrase, extractMentionedBlockTypes, hasCrossPageScope } from "./intent-detection.js";
5
+ import { defaultPropsForType, inferBlockTypeFromText, nextBlockId } from "./plan-normalizer.js";
6
+ import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
7
+ import { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
8
+ import { EXCEPT_PATTERN, THIS_ONE_PATTERN } from "./intent-patterns.js";
9
+ import { extractAudienceTargets, titleCaseWords, audiencePatchForBlock, coercePatchForBlock, inferSimpleFieldPatchFromMessage, isRewriteRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.js";
10
+ import { userFacingPropNames } from "./deterministic-planner-suggestions.js";
11
+ import { st } from "../chat/locale-strings.js";
12
+ import { buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
13
+ const CONTAINER_BLOCK_TYPES = /\b(carousel|slideshow|slider|cardgrid|card\s*grid|faqaccordion|faq|testimonials?|featuregrid|feature\s*grid|gallery|tabs?|table)\b/i;
14
+ // User-facing block label ("FAQ Accordion", not the raw "FAQAccordion" type).
15
+ // change_log/summary text ships on the MCP + HTTP API and persists into chat
16
+ // history, so it must read cleanly. Mirrors describeOp in the changelog validator.
17
+ // (Raw block IDs interpolated here used to be blanked by the id-stripper, leaving
18
+ // "Removed ." / "Appended item to ." — issue #14a; raw types leaked as
19
+ // "FAQAccordion" — #15a.)
20
+ function blockLabel(type) {
21
+ return getBlockMeta(type)?.displayName ?? type;
22
+ }
23
+ // "tab"/"slide"/"row" are LIST ITEMS that only exist inside a container block, so
24
+ // "add a tab …" / "add a slide …" map to the container's type. Without this the
25
+ // generic block-type scan picks an incidental label keyword ("a tab called
26
+ // Pricing" → CardGrid) instead of Tabs (#22). "stat" is covered by the existing
27
+ // Stats keyword. Anchored to "add … <item>" so it doesn't fire on prose mentions.
28
+ const ITEM_WORD_TO_CONTAINER = [
29
+ { test: /\b(?:add|insert)\b[^.]*?\btabs?\b/, type: "Tabs" },
30
+ { test: /\b(?:add|insert)\b[^.]*?\bslides?\b/, type: "Carousel" },
31
+ { test: /\b(?:add|insert)\b[^.]*?\brows?\b/, type: "Table" }
32
+ ];
33
+ function inferItemContainerType(message) {
34
+ const lower = message.toLowerCase();
35
+ return ITEM_WORD_TO_CONTAINER.find((e) => e.test.test(lower))?.type;
36
+ }
37
+ // Container nouns that inferBlockTypeFromText doesn't recognise on their own ("the
38
+ // classes grid" → CardGrid, "the faq accordion" → FAQAccordion). Used when resolving
39
+ // the TARGET of an "add X to the <container>" phrase (#28).
40
+ const CONTAINER_NOUN_TO_TYPE = [
41
+ { test: /\bgrids?\b/, type: "CardGrid" },
42
+ { test: /\baccordion\b/, type: "FAQAccordion" },
43
+ { test: /\bcarousel\b|\bsliders?\b/, type: "Carousel" },
44
+ { test: /\bgaller(?:y|ies)\b/, type: "Gallery" },
45
+ { test: /\btabs?\b/, type: "Tabs" },
46
+ { test: /\btables?\b/, type: "Table" }
47
+ ];
48
+ function inferContainerTypeFromNoun(phrase) {
49
+ const lower = phrase.toLowerCase();
50
+ return CONTAINER_NOUN_TO_TYPE.find((e) => e.test.test(lower))?.type;
51
+ }
52
+ /** Detects "remove X from/in [container]" or "remove X from slides/items/cards" — intra-block, not page-level */
53
+ function isContainerScopedRemove(message) {
54
+ const lower = message.toLowerCase();
55
+ if (/\b(?:from|in|within|inside)\b/.test(lower) && CONTAINER_BLOCK_TYPES.test(lower))
56
+ return true;
57
+ if (/\b(?:from|in|within)\s+(?:all\s+)?(?:slides?|items?|cards?|questions?|entries?|rows?|features?)\b/.test(lower))
58
+ return true;
59
+ return false;
60
+ }
61
+ export const blockTypeEnum = z.enum(allowedBlockTypes);
62
+ export const intentSchema = z.object({
63
+ action: z.enum(["add", "move", "update", "remove", "info", "clarify"]),
64
+ target_block_ref: z.string().min(1).nullish(),
65
+ target_block_type: blockTypeEnum.nullish(),
66
+ new_block_type: blockTypeEnum.nullish(),
67
+ position: z.enum(["top", "bottom", "before", "after"]).nullish(),
68
+ anchor_block_ref: z.string().min(1).nullish(),
69
+ patch: z.record(z.string(), z.unknown()).nullish(),
70
+ summary: z.string().min(1).nullish(),
71
+ assumption: z.string().min(1).nullish(),
72
+ complexity: z.enum(["simple", "standard", "complex"]).nullish()
73
+ });
74
+ /** Returns true when an add-block message carries content direction beyond the block type name. */
75
+ function hasContentDirective(message) {
76
+ const lower = message.toLowerCase();
77
+ // Phrases that signal the user wants specific content, not just a default block
78
+ return /\b(about|featuring|directing|promoting|highlighting|explaining|describing|marketing|showcasing|illustrating|linking to|that\s+(links?|points?|directs?|promotes?|describes?|shows?|explains?))\b/i.test(lower)
79
+ || /\bfor\b.*\b(audience|users?|visitors?|customers?|readers?)\b/i.test(lower)
80
+ || /\bwith\b.*\b(content|text|copy|images?|questions?|items?|cards?|testimonials?|features?)\b/i.test(lower)
81
+ || /\b(proper|real|good|relevant|meaningful|appropriate|custom|tailored|compelling|engaging)\b/i.test(lower);
82
+ }
83
+ function isHeroLayoutRequest(message) {
84
+ const lower = message.toLowerCase();
85
+ if (!/\b(hero|banner)\b/.test(lower))
86
+ return false;
87
+ const mentionsVisual = /\b(image|photo|picture|text|copy|content)\b/.test(lower);
88
+ const mentionsSide = /\b(left|right|left-side|right-side)\b/.test(lower);
89
+ const mentionsSwap = /\b(swap|flip|reverse)\b/.test(lower) && /\b(image|photo|picture)\b/.test(lower) && /\b(text|copy|content)\b/.test(lower);
90
+ return (mentionsVisual && mentionsSide) || mentionsSwap;
91
+ }
92
+ function hasQuotedConstraintOnlyText(message) {
93
+ if (!quotedText(message))
94
+ return false;
95
+ return (/\b(?:avoid|without|exclude|ban|forbid|never use|do not use|don't use)\b[^.\n]{0,140}["'][^"']+["']/i.test(message) ||
96
+ /\b(?:avoid|without|exclude)\b[^.\n]{0,140}\b(?:cliche|cliches|buzzword|buzzwords)\b[^.\n]{0,140}["'][^"']+["']/i.test(message));
97
+ }
98
+ function hasQuotedReplacementDirective(message) {
99
+ return /\b(?:set|change|update|edit|replace|rewrite|reword|rephrase)\b[^.\n]{0,160}\b(?:to|as|with)\b[^.\n]{0,40}["'][^"']+["']/i.test(message);
100
+ }
101
+ // Whole-section/block nouns that make "hide X" mean "remove the block" (#31).
102
+ const HIDE_BLOCK_NOUN = /\b(sections?|blocks?|grids?|card\s*grids?|feature\s*grids?|testimonials?|hero|cta|faq|accordion|carousel|gallery|banner|two[\s-]?columns?|tabs?|table)\b/;
103
+ // A field noun means "hide X" targets something INSIDE a block (its image, copy,
104
+ // CTA, …), not the block itself — must NOT route to the destructive block delete.
105
+ const HIDE_FIELD_NOUN = /\b(image|images|photo|photos|picture|pictures|logo|logos|icon|icons|heading|headline|subheading|subtitle|caption|captions|label|labels|price|prices|paragraph|paragraphs|button|buttons|link|links|field|fields)\b/;
106
+ // A block noun used as the object of a locative preposition ("… on/in the hero")
107
+ // is the CONTAINER of the real target, not the target — defer.
108
+ const HIDE_FIELD_IN_BLOCK = /\b(?:on|in|of|from|within|inside)\s+(?:the\s+)?(?:sections?|blocks?|grids?|card\s*grids?|feature\s*grids?|testimonials?|hero|cta|faq|accordion|carousel|gallery|banner|two[\s-]?columns?|tabs?|table)\b/;
109
+ function inferActionFromMessage(message) {
110
+ const lower = message.toLowerCase();
111
+ const hasPageCreateCue = Boolean(parseCreatePageRequest(message));
112
+ if (isHeroLayoutRequest(lower))
113
+ return "update";
114
+ if (hasPageCreateCue)
115
+ return "add";
116
+ // "add X in/to [the Y] heading/subheading/copy/..." → update to a field, not add.
117
+ // Must run before the generic verb-position pass so "add" does not win.
118
+ if (isFieldContentUpdateRequest(message))
119
+ return "update";
120
+ // "hide the third section" / "hide the testimonials block" — the editor has no
121
+ // visibility toggle, so hiding a whole section means removing it. Route "hide" to
122
+ // remove ONLY when it targets a WHOLE section/block — never a field inside one.
123
+ // "hide the hero image" / "hide the hero subheading" / "hide the cta on the hero"
124
+ // all name a block noun but mean "clear a field", and routing them here deletes
125
+ // the entire block at high confidence (auto-applied). When a field noun appears,
126
+ // or a block noun is the object of a locative preposition ("… on/in the hero"),
127
+ // the target is something INSIDE the block, so defer to the LLM instead of the
128
+ // destructive block-removal path. (#31, and the field-hide regression.)
129
+ if (/\bhide\b/.test(lower) && HIDE_BLOCK_NOUN.test(lower) && !HIDE_FIELD_NOUN.test(lower) && !HIDE_FIELD_IN_BLOCK.test(lower)) {
130
+ return "remove";
131
+ }
132
+ // Use earliest-verb-position to resolve ambiguity (e.g., "add a delete button" → "add")
133
+ const verbPatterns = [
134
+ { action: "remove", pattern: /\b(remove|delete)\b/ },
135
+ { action: "move", pattern: /\b(move|reorder|re-arrange|rearrange)\b/ },
136
+ { action: "add", pattern: /\b(add|insert|create|include|put)\b/ },
137
+ { action: "update", pattern: /\b(update|change|edit|set|rewrit\w*|reword\w*|rephras\w*|replace|improve|shorten|reduc\w*|trim|cut|polish\w*|refin\w*|refresh\w*|tighten\w*|clarif\w*|rename)\b/ },
138
+ ];
139
+ let earliest = null;
140
+ for (const { action, pattern } of verbPatterns) {
141
+ const match = pattern.exec(lower);
142
+ if (match && (earliest === null || match.index < earliest.index)) {
143
+ earliest = { action, index: match.index };
144
+ }
145
+ }
146
+ if (earliest)
147
+ return earliest.action;
148
+ if (/\bad\b/.test(lower) && inferBlockTypeFromText(lower))
149
+ return "add";
150
+ return null;
151
+ }
152
+ function inferPatchFromMessage(args) {
153
+ const { message, action, targetBlock, activeEditablePath } = args;
154
+ if (action !== "update")
155
+ return undefined;
156
+ const heroImagePositionPatch = inferHeroImagePositionPatch(message, targetBlock);
157
+ if (heroImagePositionPatch)
158
+ return heroImagePositionPatch;
159
+ const directPatch = inferSimpleFieldPatchFromMessage(message);
160
+ if (directPatch && Object.keys(directPatch).length > 0)
161
+ return directPatch;
162
+ const quoted = quotedText(message);
163
+ if (!quoted)
164
+ return undefined;
165
+ if (activeEditablePath && /^[a-zA-Z0-9_]+$/.test(activeEditablePath)) {
166
+ return { [activeEditablePath]: quoted };
167
+ }
168
+ const block = targetBlock;
169
+ if (!block)
170
+ return undefined;
171
+ const allowedKeys = Object.keys(block.props);
172
+ if (allowedKeys.length === 0)
173
+ return undefined;
174
+ const hintedKey = inferFieldHintFromMessage(message, allowedKeys);
175
+ if (!hintedKey)
176
+ return undefined;
177
+ return { [hintedKey]: quoted };
178
+ }
179
+ function inferHeroImagePositionPatch(message, targetBlock) {
180
+ if (!targetBlock || targetBlock.type !== "Hero")
181
+ return null;
182
+ const lower = message.toLowerCase();
183
+ const asksSwapImageAndText = (/\bswap\b/.test(lower) || /\bflip\b/.test(lower) || /\breverse\b/.test(lower)) &&
184
+ /\b(image|photo|picture)\b/.test(lower) &&
185
+ /\b(text|copy|content)\b/.test(lower);
186
+ if (asksSwapImageAndText) {
187
+ const current = String(targetBlock.props.imagePosition ?? "right");
188
+ return { imagePosition: current === "left" ? "right" : "left" };
189
+ }
190
+ if (/\b(image|photo|picture)\b[\s\w-]*\b(?:on|to)\s+the\s+left\b/.test(lower) ||
191
+ /\b(?:left|left-side)\s+(?:image|photo|picture)\b/.test(lower) ||
192
+ /\b(?:image|photo|picture)\s+(?:left|left-side)\b/.test(lower)) {
193
+ return { imagePosition: "left" };
194
+ }
195
+ if (/\b(image|photo|picture)\b[\s\w-]*\b(?:on|to)\s+the\s+right\b/.test(lower) ||
196
+ /\b(?:right|right-side)\s+(?:image|photo|picture)\b/.test(lower) ||
197
+ /\b(?:image|photo|picture)\s+(?:right|right-side)\b/.test(lower)) {
198
+ return { imagePosition: "right" };
199
+ }
200
+ if (/\b(text|copy|content)\b[\s\w-]*\b(?:on|to)\s+the\s+left\b/.test(lower) ||
201
+ /\b(?:left|left-side)\s+(?:text|copy|content)\b/.test(lower) ||
202
+ /\b(?:text|copy|content)\s+(?:left|left-side)\b/.test(lower)) {
203
+ return { imagePosition: "right" };
204
+ }
205
+ if (/\b(text|copy|content)\b[\s\w-]*\b(?:on|to)\s+the\s+right\b/.test(lower) ||
206
+ /\b(?:right|right-side)\s+(?:text|copy|content)\b/.test(lower) ||
207
+ /\b(?:text|copy|content)\s+(?:right|right-side)\b/.test(lower)) {
208
+ return { imagePosition: "left" };
209
+ }
210
+ return null;
211
+ }
212
+ export function inferDeterministicIntent(args) {
213
+ const raw = stripSiteContextEnvelope(args.message).trim();
214
+ if (!raw)
215
+ return null;
216
+ let action = inferActionFromMessage(raw);
217
+ if (!action)
218
+ return null;
219
+ // "remove all except hero" / "delete everything but CTA" — handle deterministically for remove
220
+ if (action === "remove" && EXCEPT_PATTERN.test(raw)) {
221
+ // If message scopes the remove to a specific container block (e.g. "from slides in Carousel"),
222
+ // this is an intra-block item operation — defer to LLM.
223
+ if (isContainerScopedRemove(raw))
224
+ return null;
225
+ let keepType = inferBlockTypeFromText(raw);
226
+ // "this one" / "this block" / "the selected" → resolve from activeBlockId
227
+ if (!keepType && args.activeBlockId && THIS_ONE_PATTERN.test(raw)) {
228
+ const activeBlock = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
229
+ if (activeBlock)
230
+ keepType = activeBlock.type;
231
+ }
232
+ if (keepType && args.currentPage.blocks.some((b) => b.type === keepType)) {
233
+ return {
234
+ action: "remove",
235
+ target_block_type: keepType,
236
+ summary: `Removed all blocks except ${keepType}.`
237
+ };
238
+ }
239
+ return null;
240
+ }
241
+ // "add all except ..." — needs LLM
242
+ if (action === "add" && EXCEPT_PATTERN.test(raw)) {
243
+ return null;
244
+ }
245
+ // In focused inline-edit mode, "add image/photo" should update the selected image field,
246
+ // not add a new block.
247
+ if (action === "add" &&
248
+ typeof args.activeEditablePath === "string" &&
249
+ args.activeEditablePath.trim() === "imageUrl" &&
250
+ /\b(image|photo|picture)\b/i.test(raw)) {
251
+ action = "update";
252
+ }
253
+ const refs = resolveReferencesFromMessage({
254
+ message: raw,
255
+ currentPage: args.currentPage,
256
+ activeBlockId: args.activeBlockId
257
+ });
258
+ let targetBlock = refs.target
259
+ ? args.currentPage.blocks.find((block) => block.id === refs.target?.id) ?? null
260
+ : null;
261
+ if (!targetBlock && args.activeBlockId) {
262
+ targetBlock = args.currentPage.blocks.find((block) => block.id === args.activeBlockId) ?? null;
263
+ }
264
+ const inferred = { action };
265
+ if (targetBlock) {
266
+ inferred.target_block_ref = targetBlock.id;
267
+ inferred.target_block_type = targetBlock.type;
268
+ }
269
+ else {
270
+ // For an add, the trailing "below/after the <Type>" names the placement
271
+ // anchor — not a target to act on — so scan the anchor-stripped text. Otherwise
272
+ // target_block_type latches onto the anchor's type and resurfaces as the block
273
+ // to create via the compile fallback chain (#18).
274
+ const scanText = action === "add" ? stripPositionalAnchorPhrase(raw) : raw;
275
+ const typeFromMessage = inferBlockTypeFromText(scanText);
276
+ if (typeFromMessage)
277
+ inferred.target_block_type = typeFromMessage;
278
+ if (typeFromMessage) {
279
+ const byType = args.currentPage.blocks.filter((block) => block.type === typeFromMessage);
280
+ if (byType.length === 1) {
281
+ targetBlock = byType[0] ?? null;
282
+ if (targetBlock)
283
+ inferred.target_block_ref = targetBlock.id;
284
+ }
285
+ }
286
+ }
287
+ if (action === "add") {
288
+ // Infer the block to ADD from the message with any trailing positional anchor
289
+ // removed ("add a gallery after the testimonials" → "add a gallery"), so the
290
+ // anchor's own type isn't picked up as the new block (#18). Fall back to the
291
+ // ACTIVELY-SELECTED block's type only — never to a targetBlock resolved from a
292
+ // free-text scan, which above (lines ~303-312) latches onto the anchor's type.
293
+ const addText = stripPositionalAnchorPhrase(raw);
294
+ const selectedType = targetBlock && targetBlock.id === args.activeBlockId ? targetBlock.type : undefined;
295
+ // Item words that only exist INSIDE a container map to that container's type,
296
+ // so "add a tab called Pricing" resolves to Tabs — not CardGrid (via the
297
+ // incidental "Pricing") or FAQAccordion (via a "FAQ" label) (#22). Checked
298
+ // first so the generic keyword scan can't latch onto label noise.
299
+ const itemContainerType = inferItemContainerType(addText);
300
+ const inferredAddType = itemContainerType ??
301
+ inferAddedBlockTypeFromMessage(addText) ??
302
+ inferBlockTypeFromText(addText) ??
303
+ selectedType;
304
+ if (inferredAddType)
305
+ inferred.new_block_type = inferredAddType;
306
+ }
307
+ if (action === "move" || action === "add") {
308
+ const lower = raw.toLowerCase();
309
+ if (/\b(top|first|start|beginning)\b/.test(lower))
310
+ inferred.position = "top";
311
+ else if (/\b(bottom|last|end)\b/.test(lower))
312
+ inferred.position = "bottom";
313
+ else if (/\b(before|above)\b/.test(lower))
314
+ inferred.position = "before";
315
+ else if (/\b(after|below|under)\b/.test(lower))
316
+ inferred.position = "after";
317
+ if (refs.anchor?.id)
318
+ inferred.anchor_block_ref = refs.anchor.id;
319
+ }
320
+ const patch = inferPatchFromMessage({
321
+ message: raw,
322
+ action,
323
+ targetBlock: targetBlock ?? undefined,
324
+ activeEditablePath: args.activeEditablePath
325
+ });
326
+ if (patch && Object.keys(patch).length > 0)
327
+ inferred.patch = patch;
328
+ return inferred;
329
+ }
330
+ /**
331
+ * Returns true when the message mentions a specific block type or block-synonymous
332
+ * term, indicating the user is talking about a block — not about the page itself.
333
+ * Used to guard page-level operations from misclassifying block-level requests.
334
+ *
335
+ * Strips noise that causes false positives:
336
+ * - Text after "this/the page" (page name refs like "move this page before Pricing")
337
+ * - Route slugs ("/pricing", "/about-us") — these are page refs, not block types
338
+ */
339
+ function messageRefersToBlock(message, activeBlockId) {
340
+ // When the message explicitly says "this/the page", "this" refers to the page, not the selected block
341
+ if (activeBlockId && /\b(this|the|current)\s+page\b/i.test(message))
342
+ return false;
343
+ if (activeBlockId)
344
+ return true;
345
+ // Strip text after "this/the/current page" (page name references)
346
+ // and strip route slugs (/pricing, /about) that could false-match block keywords
347
+ const cleaned = message
348
+ .replace(/\b(?:this|the|current)\s+page\b.*/i, "")
349
+ .replace(/\/[a-z0-9_-]+/gi, "");
350
+ if (inferBlockTypeFromText(cleaned))
351
+ return true;
352
+ // "the/this section", "a component", "the block" — block-synonymous terms with article
353
+ if (/\b(?:the|this|a|that)\s+(?:section|component|block|element|widget)\b/i.test(cleaned))
354
+ return true;
355
+ return false;
356
+ }
357
+ /**
358
+ * Detects "move (this) page before/after X" — page nav move requests.
359
+ * Matches both route-based ("move page after /pricing") and natural language
360
+ * ("move this page before About us") anchor references.
361
+ * Requires "move" directly adjacent to "this/the/current page" to avoid matching
362
+ * block positioning phrases like "move CTA to the top of the page".
363
+ */
364
+ function isNavMoveRequest(message) {
365
+ const lower = message.toLowerCase();
366
+ const movesThisPage = /\bmove\s+(?:this|the|current)\s+page\b/.test(lower);
367
+ const hasPlacement = /\b(before|after|above|below|first|last|top|bottom|start|end|beginning)\b/.test(lower);
368
+ return movesThisPage && hasPlacement;
369
+ }
370
+ /**
371
+ * Resolves a natural language page name (e.g. "About us") to a slug by matching
372
+ * against page titles in the session draft. Returns undefined if no match.
373
+ */
374
+ function resolvePageSlugByTitle(sessionDraft, name) {
375
+ const lower = name.toLowerCase().trim();
376
+ if (!lower)
377
+ return undefined;
378
+ for (const [slug, page] of sessionDraft) {
379
+ if (page.title.toLowerCase() === lower)
380
+ return slug;
381
+ }
382
+ // Fuzzy: check if name is contained in title or vice versa
383
+ for (const [slug, page] of sessionDraft) {
384
+ const titleLower = page.title.toLowerCase();
385
+ if (titleLower.includes(lower) || lower.includes(titleLower))
386
+ return slug;
387
+ }
388
+ return undefined;
389
+ }
390
+ // Conjunctions that separate independent clauses (", and …", "… then …", "; …").
391
+ // Bare commas are intentionally excluded so a value/list comma doesn't over-split.
392
+ const CLAUSE_CONJUNCTION = /\s*(?:;|,?\s+(?:and|then|also|plus)\s+)/i;
393
+ // A broad set of edit verbs, INCLUDING natural-language ones the verb-position
394
+ // classifier ignores ("make", "put", "take out", "rename", "reflect").
395
+ const CLAUSE_ACTION_VERB = /\b(?:add|insert|create|include|put|remove|delete|clear|take\s+out|get\s+rid|move|reorder|rearrange|swap|update|change|edit|set|replace|rewrite|reword|rephrase|make|rename|shorten|tighten|reflect|turn|hide|show|sort|soften|expand|highlight)\b/i;
396
+ /**
397
+ * True when the message carries more than one independent edit intent ("add a
398
+ * testimonials section … and make the CTA …", "delete the 2nd and 3rd cards … and
399
+ * rename the first"). The deterministic path models a SINGLE simple intent, so it
400
+ * would handle only the first clause and silently drop the rest — these must defer to
401
+ * the LLM, which plans every clause. A single intent with an incidental conjunction
402
+ * inside a quoted value or an adjective list ("'Health and Wellness'", "bigger and
403
+ * bolder") has only ONE action-bearing clause and stays deterministic. (#32)
404
+ */
405
+ /** Blank out quoted values so a conjunction/verb inside a replacement string
406
+ * ("… to 'Cut costs and move faster'") can't be read as a clause boundary. */
407
+ function stripQuotedValues(raw) {
408
+ return raw
409
+ .replace(/"[^"]*"/g, " ")
410
+ .replace(/'[^']*'/g, " ")
411
+ .replace(/“[^”]*”/g, " ")
412
+ .replace(/‘[^’]*’/g, " ");
413
+ }
414
+ export function isMultiIntentRequest(raw) {
415
+ // Strip quoted values first: "change the heading to 'Cut costs and move faster'"
416
+ // is ONE intent, but the "and" inside the value would otherwise split it into two
417
+ // pseudo-clauses that each look action-bearing → false multi-intent deferral.
418
+ const clauses = stripQuotedValues(raw).split(CLAUSE_CONJUNCTION).filter((c) => c.trim().length > 0);
419
+ if (clauses.length < 2)
420
+ return false;
421
+ return clauses.filter((c) => CLAUSE_ACTION_VERB.test(c)).length >= 2;
422
+ }
423
+ /**
424
+ * Returns true only when UI context makes the intent completely unambiguous,
425
+ * so the deterministic planner can handle it without an LLM call.
426
+ */
427
+ export function isHighConfidenceDeterministicCase(args) {
428
+ const raw = stripSiteContextEnvelope(args.message).trim();
429
+ if (!raw)
430
+ return false;
431
+ // Case 1: Inline edit — user clicked a field, typed a quoted value
432
+ if (args.activeBlockId && args.activeEditablePath && quotedText(raw))
433
+ return true;
434
+ // Case 2: Inline list append — "add another item" with block selected
435
+ if (args.activeBlockId) {
436
+ const block = args.currentPage.blocks.find(b => b.id === args.activeBlockId);
437
+ if (block && /\b(add|insert)\b/i.test(raw) && /\b(another|more|new)\b/i.test(raw)) {
438
+ if (buildListAppendPatch(block, raw))
439
+ return true;
440
+ }
441
+ }
442
+ // Case 3: Simple add/remove with clear block type — no LLM needed
443
+ // Bail out for compound requests ("remove X and add Y") — these need the LLM
444
+ const hasCompoundAction = /\b(remove|delete|clear)\b.+\band\b.+\b(add|insert|create)\b/i.test(raw)
445
+ || /\b(add|insert|create)\b.+\band\b.+\b(remove|delete|clear)\b/i.test(raw);
446
+ if (hasCompoundAction)
447
+ return false;
448
+ // Bail out for multi-intent requests the single-intent deterministic path can't model
449
+ // ("add a testimonials section ... and make the CTA ...") — it would handle only the
450
+ // first clause and silently drop the rest. The compound decomposer / LLM take over. (#32)
451
+ if (isMultiIntentRequest(raw))
452
+ return false;
453
+ const hasExceptionModifier = EXCEPT_PATTERN.test(raw);
454
+ if (hasExceptionModifier) {
455
+ // "remove all except X" can be handled deterministically when X resolves to a block type on the page
456
+ const action = inferActionFromMessage(raw);
457
+ if (action === "remove") {
458
+ if (isContainerScopedRemove(raw))
459
+ return false; // intra-block item op — defer to LLM
460
+ let keepType = inferBlockTypeFromText(raw);
461
+ if (!keepType && args.activeBlockId && THIS_ONE_PATTERN.test(raw)) {
462
+ const activeBlock = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
463
+ if (activeBlock)
464
+ keepType = activeBlock.type;
465
+ }
466
+ if (keepType && args.currentPage.blocks.some((b) => b.type === keepType))
467
+ return true;
468
+ }
469
+ return false;
470
+ }
471
+ const action = inferActionFromMessage(raw);
472
+ // Cross-page scope ("update the CTA on all pages", "change X site-wide") can't be
473
+ // honored by the single-page deterministic path — it would silently narrow the edit
474
+ // to the current page only. Defer to the LLM, which can fan out across pages (ops
475
+ // carry pageSlug) or clarify. Create-page requests are exempt — they legitimately
476
+ // target one new page, not the whole site (issue #29).
477
+ if (hasCrossPageScope(raw) && !parseCreatePageRequest(raw))
478
+ return false;
479
+ // Case: create_page — "create a new /about page", "create a new test page"
480
+ // Defer to LLM when user wants content generation (topic + block types enumerated)
481
+ if (action === "add" && parseCreatePageRequest(raw) && !requestsContentGeneration(raw))
482
+ return true;
483
+ // "add X to each/every card" = bulk item update — needs LLM for content generation
484
+ if (action === "add" && /\b(each|every)\b/i.test(raw))
485
+ return false;
486
+ // Counted multi-block add without enough named types → needs LLM for content generation
487
+ if (action === "add" && isBatchAddRequest(raw) && extractMentionedBlockTypes(raw).length < 2)
488
+ return false;
489
+ // Case: page rename — "rename page to Our community", "rename this page to /plans"
490
+ if (action === "update" && isPageRouteRenameRequest(raw))
491
+ return true;
492
+ // Case: page nav move — "move this page before About us", "move page after /pricing"
493
+ // Guard: if message mentions a block type, it's a block move, not a nav move
494
+ if (action === "move" && isNavMoveRequest(raw) && !messageRefersToBlock(raw, args.activeBlockId))
495
+ return true;
496
+ // Case: page-level delete — "delete this page", "remove the page"
497
+ if (action === "remove" && /\b(delete|remove)\b.*\bpage\b/i.test(raw) && !messageRefersToBlock(raw, args.activeBlockId))
498
+ return true;
499
+ // Multiple distinct removal targets ("remove the testimonials, the CTA, and both two-column sections")
500
+ // — the deterministic path infers only the FIRST block type and emits a single remove_block,
501
+ // silently dropping the rest. Defer to the LLM so every requested target is handled (issue #17B).
502
+ if (action === "remove" && extractMentionedBlockTypes(raw).length >= 2)
503
+ return false;
504
+ const removeType = inferBlockTypeFromText(raw);
505
+ if (action === "remove" && removeType) {
506
+ if (args.currentPage.blocks.filter((b) => b.type === removeType).length > 1)
507
+ return false;
508
+ return true;
509
+ }
510
+ if (action === "remove" && args.activeBlockId && /\b(this|selected|it)\b/i.test(raw))
511
+ return true;
512
+ // "add a hero image of X" when Hero already exists = image update, not block add — defer to LLM
513
+ const addBlockType = action === "add" ? inferBlockTypeFromText(raw) : null;
514
+ if (action === "add" && addBlockType && /\b(image|photo|picture|icon)\b/i.test(raw) && args.currentPage.blocks.some(b => b.type === addBlockType))
515
+ return false;
516
+ // "add icons to the stats / features" — `icon` is a per-item FIELD, never a
517
+ // block or a list item, and "stats"/"features" don't resolve to a block type,
518
+ // so the guard above misses and the add-item path inserts a junk placeholder
519
+ // row ("100+ / New metric") instead of icons. Any "add … icon …" request is a
520
+ // field edit, so defer to the LLM (it sets the icon field via update_props).
521
+ if (action === "add" && /\bicons?\b/i.test(raw))
522
+ return false;
523
+ // "add CTA directing to recipes" = needs LLM for content-aware props
524
+ if (action === "add" && inferBlockTypeFromText(raw) && hasContentDirective(raw))
525
+ return false;
526
+ // Defer to LLM when topic + multiple block types are enumerated (needs content generation)
527
+ if (action === "add" && inferBlockTypeFromText(raw) && !requestsContentGeneration(raw))
528
+ return true;
529
+ // Compound multi-block update ("change the hero heading to 'X' and the CTA button
530
+ // to 'Y'") — the deterministic update path infers only the FIRST block type and
531
+ // emits a single update_props, silently dropping the other clause. When the message
532
+ // names ≥2 distinct block types joined by a conjunction (counted OUTSIDE quoted
533
+ // values so block words inside the replacement text don't inflate the count), defer
534
+ // to the LLM so every targeted block is updated (issue #29).
535
+ if (action === "update") {
536
+ const outsideQuotes = stripQuotedValues(raw);
537
+ if (/\b(?:and|plus)\b|&|,/.test(outsideQuotes) && extractMentionedBlockTypes(outsideQuotes).length >= 2) {
538
+ return false;
539
+ }
540
+ }
541
+ // Case 4: Simple update with clear block type reference and quoted value
542
+ if (action === "update" && inferBlockTypeFromText(raw) && quotedText(raw)) {
543
+ if (hasQuotedConstraintOnlyText(raw))
544
+ return false;
545
+ if (isRewriteRequest(raw) && !hasQuotedReplacementDirective(raw))
546
+ return false;
547
+ return true;
548
+ }
549
+ return false;
550
+ }
551
+ /**
552
+ * Splits a compound message ("remove the hero and add a CTA") into sub-parts
553
+ * at conjunction boundaries, but only when the parts contain verbs from
554
+ * different action categories (add vs remove, etc.).
555
+ *
556
+ * Returns null when the message is not a decomposable compound request.
557
+ */
558
+ function splitCompoundMessage(raw) {
559
+ // Bail when 3+ distinct action verbs are present — too complex for 2-way split
560
+ const lower = raw.toLowerCase();
561
+ const verbCategories = [
562
+ /\b(?:add|insert|create|include)\b/,
563
+ /\b(?:remove|delete|clear)\b/,
564
+ /\b(?:move|reorder|rearrange)\b/,
565
+ /\b(?:update|change|edit|set|replace)\b/,
566
+ ];
567
+ const matchedCategories = verbCategories.filter((p) => p.test(lower)).length;
568
+ if (matchedCategories >= 3)
569
+ return null;
570
+ // Only split when two different action-category verbs are connected by a conjunction.
571
+ // Pattern: <verb-group-A> ... <and|then|,> ... <verb-group-B>
572
+ const addVerbs = String.raw `(?:add|insert|create|include)`;
573
+ const removeVerbs = String.raw `(?:remove|delete|clear)`;
574
+ const conjunction = String.raw `(?:\s+and\s+|\s+then\s+|\s*,\s*(?:and\s+)?(?:then\s+)?)`;
575
+ // Try add...conjunction...remove
576
+ const arPattern = new RegExp(String.raw `(.*\b${addVerbs}\b[^,]*?)${conjunction}(\b${removeVerbs}\b.*)`, "i");
577
+ const arMatch = raw.match(arPattern);
578
+ if (arMatch?.[1] && arMatch?.[2])
579
+ return [arMatch[1].trim(), arMatch[2].trim()];
580
+ // Try remove...conjunction...add
581
+ const raPattern = new RegExp(String.raw `(.*\b${removeVerbs}\b[^,]*?)${conjunction}(\b${addVerbs}\b.*)`, "i");
582
+ const raMatch = raw.match(raPattern);
583
+ if (raMatch?.[1] && raMatch?.[2])
584
+ return [raMatch[1].trim(), raMatch[2].trim()];
585
+ // Try move...conjunction...add/remove
586
+ const moveVerbs = String.raw `(?:move|reorder|rearrange)`;
587
+ const updateVerbs = String.raw `(?:update|change|edit|set|replace)`;
588
+ const pairs = [
589
+ [moveVerbs, addVerbs],
590
+ [moveVerbs, removeVerbs],
591
+ [addVerbs, updateVerbs],
592
+ [removeVerbs, updateVerbs],
593
+ [updateVerbs, addVerbs],
594
+ [updateVerbs, removeVerbs],
595
+ ];
596
+ for (const [a, b] of pairs) {
597
+ const pattern = new RegExp(String.raw `(.*\b${a}\b[^,]*?)${conjunction}(\b${b}\b.*)`, "i");
598
+ const match = raw.match(pattern);
599
+ if (match?.[1] && match?.[2])
600
+ return [match[1].trim(), match[2].trim()];
601
+ }
602
+ return null;
603
+ }
604
+ /**
605
+ * Attempts to handle a compound message ("remove the hero and add a CTA")
606
+ * by decomposing it into sub-intents and running each through the
607
+ * deterministic planner. Returns null if any sub-intent can't be handled
608
+ * deterministically.
609
+ */
610
+ export function tryCompoundDeterministicPlan(args) {
611
+ const raw = stripSiteContextEnvelope(args.message).trim();
612
+ if (!raw)
613
+ return null;
614
+ const parts = splitCompoundMessage(raw);
615
+ if (!parts)
616
+ return null;
617
+ const [firstMsg, secondMsg] = parts;
618
+ // Check both parts individually for high-confidence
619
+ for (const msg of [firstMsg, secondMsg]) {
620
+ if (!isHighConfidenceDeterministicCase({
621
+ message: msg,
622
+ currentPage: args.currentPage,
623
+ activeBlockId: args.activeBlockId,
624
+ activeEditablePath: args.activeEditablePath
625
+ }))
626
+ return null;
627
+ }
628
+ // Build intent + plan for first part
629
+ const firstIntent = inferDeterministicIntent({
630
+ message: firstMsg,
631
+ currentPage: args.currentPage,
632
+ activeBlockId: args.activeBlockId,
633
+ activeEditablePath: args.activeEditablePath
634
+ });
635
+ if (!firstIntent)
636
+ return null;
637
+ const firstPlan = compileDeterministicPlan({
638
+ session: args.session,
639
+ intent: firstIntent,
640
+ message: firstMsg,
641
+ slug: args.slug,
642
+ currentPage: args.currentPage,
643
+ activeBlockId: args.activeBlockId,
644
+ activeEditablePath: args.activeEditablePath
645
+ });
646
+ if (!firstPlan || firstPlan.intent !== "edit_plan" || firstPlan.ops.length === 0)
647
+ return null;
648
+ // Apply first plan's ops to a cloned page so second plan sees updated state
649
+ // (e.g., removed blocks are no longer visible, added blocks can be referenced)
650
+ let updatedPage = args.currentPage;
651
+ for (const op of firstPlan.ops) {
652
+ if (op.op === "remove_block") {
653
+ updatedPage = { ...updatedPage, blocks: updatedPage.blocks.filter((b) => b.id !== op.blockId) };
654
+ }
655
+ else if (op.op === "add_block") {
656
+ updatedPage = { ...updatedPage, blocks: [...updatedPage.blocks, op.block] };
657
+ }
658
+ }
659
+ // Build intent + plan for second part
660
+ const secondIntent = inferDeterministicIntent({
661
+ message: secondMsg,
662
+ currentPage: updatedPage,
663
+ activeBlockId: args.activeBlockId,
664
+ activeEditablePath: args.activeEditablePath
665
+ });
666
+ if (!secondIntent)
667
+ return null;
668
+ const secondPlan = compileDeterministicPlan({
669
+ session: args.session,
670
+ intent: secondIntent,
671
+ message: secondMsg,
672
+ slug: args.slug,
673
+ currentPage: updatedPage,
674
+ activeBlockId: args.activeBlockId,
675
+ activeEditablePath: args.activeEditablePath
676
+ });
677
+ if (!secondPlan || secondPlan.intent !== "edit_plan" || secondPlan.ops.length === 0)
678
+ return null;
679
+ // Merge: removes first (so IDs are valid), then adds
680
+ const removeOps = [...firstPlan.ops, ...secondPlan.ops].filter(op => op.op === "remove_block" || op.op === "remove_page");
681
+ const otherOps = [...firstPlan.ops, ...secondPlan.ops].filter(op => op.op !== "remove_block" && op.op !== "remove_page");
682
+ return {
683
+ intent: "edit_plan",
684
+ summary_for_user: [firstPlan.summary_for_user, secondPlan.summary_for_user].join(" "),
685
+ change_log: [...firstPlan.change_log, ...secondPlan.change_log],
686
+ ops: [...removeOps, ...otherOps]
687
+ };
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // Header / site-config shortcut
691
+ // ---------------------------------------------------------------------------
692
+ const SMART_QUOTES_RE = /[‘’“”]/g;
693
+ function normalizeQuotes(value) {
694
+ return value.replace(SMART_QUOTES_RE, (ch) => (ch === "‘" || ch === "’" ? "'" : '"'));
695
+ }
696
+ function stripWrappingQuotes(value) {
697
+ const trimmed = value.trim().replace(/[.!?;:]+$/, "").trim();
698
+ const m = trimmed.match(/^["'](.+)["']$/);
699
+ return (m ? m[1] : trimmed).trim();
700
+ }
701
+ /**
702
+ * Matches high-confidence requests targeting the SiteHeader / siteConfig.
703
+ * Returns a one-op `update_site_config` plan or null if no clear match.
704
+ */
705
+ function tryHeaderConfigPlan(args) {
706
+ const { session, message, assumptions } = args;
707
+ const lower = message.toLowerCase();
708
+ const draft = getSessionDraft(session);
709
+ const config = getSiteConfig(session);
710
+ // ── Logo ──────────────────────────────────────────────────────────────
711
+ const messageNorm = normalizeQuotes(message);
712
+ const mentionsLogo = /\b(logo)\b/.test(lower);
713
+ if (mentionsLogo) {
714
+ // "use URL as the logo" / "set logo to URL" / "change the logo to URL"
715
+ const useAs = messageNorm.match(/\buse\s+["']?(\S+?)["']?\s+as\s+(?:the\s+)?(?:site\s+)?logo\b/i);
716
+ const setTo = messageNorm.match(/\b(?:change|set|update|make)\s+(?:the\s+)?(?:site\s+)?logo\s+(?:to|=)\s+(.+?)$/i);
717
+ const raw = useAs ? useAs[1] : setTo ? setTo[1] : null;
718
+ if (raw) {
719
+ const url = stripWrappingQuotes(raw);
720
+ if (/^(https?:\/\/|\/)/.test(url) && !/\s/.test(url)) {
721
+ return {
722
+ intent: "edit_plan",
723
+ summary_for_user: `Will update the **site logo**.`,
724
+ change_log: [...assumptions, `Set site logo to ${url}.`],
725
+ ops: [{ op: "update_site_config", patch: { logo: url } }]
726
+ };
727
+ }
728
+ }
729
+ }
730
+ // ── Site name ────────────────────────────────────────────────────────
731
+ const mentionsSiteName = /\bsite\s+name\b/.test(lower) ||
732
+ /\brename\s+the\s+site\b/.test(lower) ||
733
+ /\bchange\s+the\s+site\s+to\b/.test(lower);
734
+ if (mentionsSiteName) {
735
+ const fromSiteName = messageNorm.match(/\bsite\s+name\s+(?:to|=|:)\s+(.+?)$/i);
736
+ const renameSite = messageNorm.match(/\brename\s+the\s+site\s+to\s+(.+)$/i);
737
+ const changeSite = messageNorm.match(/\bchange\s+the\s+site\s+to\s+(.+)$/i);
738
+ const raw = (fromSiteName ? stripWrappingQuotes(fromSiteName[1]) : null)
739
+ ?? (renameSite ? stripWrappingQuotes(renameSite[1]) : null)
740
+ ?? (changeSite ? stripWrappingQuotes(changeSite[1]) : null);
741
+ if (raw && raw.length > 0 && raw.length <= 80) {
742
+ return {
743
+ intent: "edit_plan",
744
+ summary_for_user: `Will rename the site to **${raw}**.`,
745
+ change_log: [...assumptions, `Set site name to "${raw}".`],
746
+ ops: [{ op: "update_site_config", patch: { name: raw } }]
747
+ };
748
+ }
749
+ }
750
+ // ── Nav label rename ─────────────────────────────────────────────────
751
+ // Patterns: "rename the X link to Y", "rename X in the nav to Y",
752
+ // "relabel the X tab to Y", "change the X menu item to Y"
753
+ const navTokens = "(?:link|tab|menu(?:\\s+item)?|nav(?:igation)?(?:\\s+item)?|item|entry)";
754
+ const navContextRe = new RegExp(`\\b(?:rename|relabel|change|update|set)\\s+(?:the\\s+)?["']?([^"']+?)["']?\\s+${navTokens}\\s+to\\s+(.+)$`, "i");
755
+ const inNavRe = new RegExp(`\\b(?:rename|relabel|change|update|set)\\s+(?:the\\s+)?["']?([^"']+?)["']?\\s+(?:in|on)\\s+(?:the\\s+)?(?:nav(?:igation)?|menu|header)\\s+to\\s+(.+)$`, "i");
756
+ const navMatch = messageNorm.match(navContextRe) ?? messageNorm.match(inNavRe);
757
+ if (navMatch) {
758
+ const fromLabelRaw = stripWrappingQuotes(navMatch[1]);
759
+ const toLabel = stripWrappingQuotes(navMatch[2]);
760
+ if (fromLabelRaw && toLabel && toLabel.length <= 80) {
761
+ const slug = resolveNavSlugByLabel(fromLabelRaw, config.navLabels ?? {}, draft);
762
+ if (slug) {
763
+ const mergedLabels = { ...(config.navLabels ?? {}), [slug]: toLabel };
764
+ return {
765
+ intent: "edit_plan",
766
+ summary_for_user: `Will rename the **${fromLabelRaw}** nav link to **${toLabel}**.`,
767
+ change_log: [...assumptions, `Renamed nav label ${slug} from "${fromLabelRaw}" to "${toLabel}".`],
768
+ ops: [{ op: "update_site_config", patch: { navLabels: mergedLabels } }]
769
+ };
770
+ }
771
+ }
772
+ }
773
+ return null;
774
+ }
775
+ function resolveNavSlugByLabel(label, navLabels, draft) {
776
+ const lower = label.toLowerCase().trim();
777
+ if (!lower)
778
+ return undefined;
779
+ // 1. Existing override matches exactly
780
+ for (const [slug, override] of Object.entries(navLabels)) {
781
+ if (typeof override === "string" && override.toLowerCase() === lower)
782
+ return slug;
783
+ }
784
+ // 2. Page title matches (uses the same fuzzy matcher as page rename)
785
+ const byTitle = resolvePageSlugByTitle(draft, label);
786
+ if (byTitle)
787
+ return byTitle;
788
+ // 3. Slug last segment matches (e.g. "about" → /about, "our team" → /our-team)
789
+ const seed = toSeedSlug(label);
790
+ if (seed) {
791
+ const candidate = `/${seed}`;
792
+ if (draft.has(candidate))
793
+ return candidate;
794
+ }
795
+ return undefined;
796
+ }
797
+ export function compileDeterministicPlan(args) {
798
+ const { session, intent, message, slug, currentPage, activeBlockId, activeEditablePath } = args;
799
+ const cleanMessage = stripSiteContextEnvelope(message);
800
+ const lowerMessage = message.toLowerCase();
801
+ // Header / site-config requests bypass intent inference and resolve straight
802
+ // to update_site_config. The SiteHeader is not a page block, so phrasing like
803
+ // "rename the About link" or "change the logo" must NOT fall into update_props.
804
+ const headerPlan = tryHeaderConfigPlan({ session, message: cleanMessage, assumptions: intent.assumption ? [intent.assumption] : [] });
805
+ if (headerPlan)
806
+ return headerPlan;
807
+ const routeMentions = extractRouteMentions(cleanMessage);
808
+ const assumptions = [];
809
+ if (intent.assumption)
810
+ assumptions.push(intent.assumption);
811
+ const hasStructuralVerb = /\b(move|reorder|re-arrange|rearrange|add|remove|delete|create|duplicate|rename)\b/.test(lowerMessage);
812
+ if (process.env.OPENAI_API_KEY &&
813
+ (intent.action === "update" || intent.action === "clarify") &&
814
+ !hasStructuralVerb &&
815
+ isRewriteRequest(message) &&
816
+ (!hasQuotedReplacementDirective(message) || hasQuotedConstraintOnlyText(message)))
817
+ return null;
818
+ const hasConditionalQualifier = /\bif\s+(required|needed|necessary)\b/.test(lowerMessage);
819
+ const asksSectionReorder = /\b(reorder|re-order|rearrange|re-organize|reorganize)\b/.test(lowerMessage) &&
820
+ /\b(section|sections|block|blocks|content|layout|flow|readability)\b/.test(lowerMessage);
821
+ const hasExplicitPlacementCue = /\b(top|bottom|first|last|before|after|above|below|under|between)\b/.test(lowerMessage);
822
+ const hasExplicitBlockMentionInMessage = /\bb_[a-z0-9_]+\b/.test(lowerMessage) ||
823
+ /\b(hero|feature grid|features|testimonials?|faq|cta|card grid|cards?|rich[\s-]?text)\b/.test(lowerMessage);
824
+ const selectedBlock = activeBlockId ? currentPage.blocks.find((b) => b.id === activeBlockId) ?? null : null;
825
+ const secondaryButtonMentioned = lowerMessage.includes("secondary cta") ||
826
+ lowerMessage.includes("secondary button") ||
827
+ lowerMessage.includes("second cta") ||
828
+ lowerMessage.includes("second button") ||
829
+ /sec\w*nd\w*ry\s+(cta|button)/.test(lowerMessage);
830
+ const asksSecondaryCtaAdd = secondaryButtonMentioned &&
831
+ (lowerMessage.includes("add") || lowerMessage.includes("create") || lowerMessage.includes("insert") || lowerMessage.includes("include"));
832
+ const asksPageRename = isPageRouteRenameRequest(message);
833
+ if ((intent.action === "update" || intent.action === "move" || intent.action === "clarify") && asksPageRename) {
834
+ const mentionsCurrentPage = /\b(this|current|the)\s+page\b/.test(lowerMessage);
835
+ let fromSlug = routeMentions[0] ?? slug;
836
+ let toSlug = routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined;
837
+ if (!toSlug && routeMentions.length === 1 && mentionsCurrentPage) {
838
+ toSlug = routeMentions[0];
839
+ fromSlug = slug;
840
+ }
841
+ // Extract natural language name after "to" when no route mentions resolve
842
+ let newTitle;
843
+ if (!toSlug || toSlug === fromSlug) {
844
+ // Strip clarification history and site context before extracting the target name
845
+ const msgForRename = message.replace(/\nClarification from user:[\s\S]*/i, "").replace(/\s*\[site context\][\s\S]*$/i, "").trim();
846
+ const nameMatch = msgForRename.match(/\brename\b.*\bpage\b.*?\bto\s+(.+)$/i)
847
+ ?? msgForRename.match(/\brename\b.*?\bto\s+(.+)$/i);
848
+ if (nameMatch) {
849
+ const rawName = nameMatch[1].replace(/[.!?]+$/, "").trim();
850
+ if (rawName) {
851
+ const seed = toSeedSlug(rawName);
852
+ if (seed) {
853
+ toSlug = `/${seed}`;
854
+ fromSlug = slug;
855
+ newTitle = rawName.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
856
+ }
857
+ }
858
+ }
859
+ }
860
+ if (!toSlug || toSlug === fromSlug) {
861
+ return {
862
+ intent: "needs_clarification",
863
+ summary_for_user: st(args.locale, "rename.needsPath"),
864
+ change_log: assumptions,
865
+ ops: []
866
+ };
867
+ }
868
+ const renameOp = { op: "rename_page", pageSlug: fromSlug, newPageSlug: toSlug, ...(newTitle ? { newTitle } : {}) };
869
+ return {
870
+ intent: "edit_plan",
871
+ summary_for_user: st(args.locale, "rename.done", { from: fromSlug, to: toSlug }),
872
+ change_log: [...assumptions, `Renamed page ${fromSlug} -> ${toSlug}.`],
873
+ ops: [renameOp]
874
+ };
875
+ }
876
+ const asksPageDelete = /\b(delete|remove)\b.*\bpage\b/.test(lowerMessage) && !messageRefersToBlock(cleanMessage, args.activeBlockId);
877
+ if ((intent.action === "remove" || intent.action === "clarify") && asksPageDelete) {
878
+ const targetSlug = routeMentions[0] ?? slug;
879
+ if (targetSlug === "/") {
880
+ return {
881
+ intent: "needs_clarification",
882
+ summary_for_user: st(args.locale, "delete.homeBlocked"),
883
+ change_log: assumptions,
884
+ ops: []
885
+ };
886
+ }
887
+ return {
888
+ intent: "edit_plan",
889
+ summary_for_user: st(args.locale, "delete.done", { slug: targetSlug }),
890
+ change_log: [...assumptions, `Removed page ${targetSlug}.`],
891
+ ops: [{ op: "remove_page", pageSlug: targetSlug }]
892
+ };
893
+ }
894
+ const requestedCreateSlug = parseCreatePageRequest(message);
895
+ // When the user mentions "template", skip deterministic page creation
896
+ // and let the LLM planner use the template definitions from site context.
897
+ const mentionsTemplate = /\btemplate\b/i.test(cleanMessage);
898
+ if ((intent.action === "add" || intent.action === "clarify" || intent.action === "update") && requestedCreateSlug) {
899
+ if (mentionsTemplate)
900
+ return null; // defer to LLM planner
901
+ const createPlan = buildCreatePagePlan({ session, requestedSlug: requestedCreateSlug, assumptions, userMessage: message });
902
+ if (createPlan)
903
+ return createPlan;
904
+ }
905
+ const hasNavContext = /\b(nav|navigation|menu|first|last|position)\b/.test(lowerMessage) || routeMentions.length >= 2;
906
+ const asksNavMove = !messageRefersToBlock(cleanMessage, args.activeBlockId) && (/\b(nav|navigation|menu|tabs?|tab order|page order)\b/.test(lowerMessage) ||
907
+ /\bmove\b.*\btab\b/.test(lowerMessage) ||
908
+ (/\bmove\b.*\bpage\b/.test(lowerMessage) && hasNavContext) ||
909
+ /\breorder\b.*\b(page|nav|menu|tabs?)\b/.test(lowerMessage) ||
910
+ isNavMoveRequest(cleanMessage));
911
+ if ((intent.action === "move" || intent.action === "clarify") && asksNavMove) {
912
+ const sessionDraft = getSessionDraft(session);
913
+ const slugsRaw = Array.from(sessionDraft.keys());
914
+ const ordered = slugsRaw.includes("/") ? ["/", ...slugsRaw.filter((route) => route !== "/")] : slugsRaw;
915
+ const movedSlug = routeMentions[0] ?? slug;
916
+ if (!ordered.includes(movedSlug)) {
917
+ return {
918
+ intent: "needs_clarification",
919
+ summary_for_user: st(args.locale, "move.notFound", { slug: movedSlug }),
920
+ change_log: assumptions,
921
+ ops: []
922
+ };
923
+ }
924
+ if (movedSlug === "/") {
925
+ return {
926
+ intent: "needs_clarification",
927
+ summary_for_user: st(args.locale, "move.homeFixed"),
928
+ change_log: assumptions,
929
+ ops: []
930
+ };
931
+ }
932
+ // Extract anchor page name from natural language when no route mentions available
933
+ const resolveAnchorSlug = () => {
934
+ if (routeMentions.length >= 2)
935
+ return routeMentions[1];
936
+ // "move page before About us" → extract "About us"
937
+ const anchorMatch = cleanMessage.match(/\b(?:before|after|above|below|under)\s+(.+)$/i);
938
+ if (anchorMatch) {
939
+ const name = anchorMatch[1].replace(/\s*\[site context\][\s\S]*$/i, "").replace(/[.!?]+$/, "").trim();
940
+ return resolvePageSlugByTitle(sessionDraft, name);
941
+ }
942
+ return undefined;
943
+ };
944
+ let afterPageSlug;
945
+ if (/\b(top|first|start|beginning)\b/.test(lowerMessage)) {
946
+ afterPageSlug = undefined;
947
+ }
948
+ else if (/\b(bottom|last|end)\b/.test(lowerMessage)) {
949
+ const tail = [...ordered].reverse().find((route) => route !== movedSlug);
950
+ afterPageSlug = tail === "/" ? "/" : tail;
951
+ }
952
+ else if (/\b(after|below|under)\b/.test(lowerMessage)) {
953
+ const anchor = resolveAnchorSlug();
954
+ if (!anchor) {
955
+ return {
956
+ intent: "needs_clarification",
957
+ summary_for_user: st(args.locale, "move.specifyWhere"),
958
+ change_log: assumptions,
959
+ ops: []
960
+ };
961
+ }
962
+ afterPageSlug = anchor;
963
+ }
964
+ else if (/\b(before|above)\b/.test(lowerMessage)) {
965
+ const anchor = resolveAnchorSlug();
966
+ if (!anchor) {
967
+ return {
968
+ intent: "needs_clarification",
969
+ summary_for_user: st(args.locale, "move.specifyWhere"),
970
+ change_log: assumptions,
971
+ ops: []
972
+ };
973
+ }
974
+ if (anchor === "/")
975
+ afterPageSlug = undefined;
976
+ else {
977
+ const index = ordered.findIndex((route) => route === anchor);
978
+ if (index === -1) {
979
+ return {
980
+ intent: "needs_clarification",
981
+ summary_for_user: st(args.locale, "move.anchorNotFound", { slug: anchor }),
982
+ change_log: assumptions,
983
+ ops: []
984
+ };
985
+ }
986
+ const previous = ordered.slice(0, index).reverse().find((route) => route !== movedSlug);
987
+ afterPageSlug = previous === "/" ? "/" : previous;
988
+ }
989
+ }
990
+ else if (routeMentions.length >= 2) {
991
+ afterPageSlug = routeMentions[1];
992
+ }
993
+ else {
994
+ return {
995
+ intent: "needs_clarification",
996
+ summary_for_user: st(args.locale, "move.specifyWhere"),
997
+ change_log: assumptions,
998
+ ops: []
999
+ };
1000
+ }
1001
+ return {
1002
+ intent: "edit_plan",
1003
+ summary_for_user: afterPageSlug === undefined
1004
+ ? `Moved ${movedSlug} to the first nav position (after Home).`
1005
+ : `Moved ${movedSlug} after ${afterPageSlug}.`,
1006
+ change_log: [...assumptions, `Reordered nav: ${movedSlug}`],
1007
+ ops: [{ op: "move_page", pageSlug: movedSlug, afterPageSlug }]
1008
+ };
1009
+ }
1010
+ const audiences = extractAudienceTargets(message);
1011
+ const audience = audiences[0];
1012
+ const asksAudienceCreatePage = audiences.length > 0 &&
1013
+ /\b(create|generate|build|make|draft)\b/.test(lowerMessage) &&
1014
+ /\b(pages?|landing pages?)\b/.test(lowerMessage);
1015
+ if (asksAudienceCreatePage && audience) {
1016
+ if (process.env.OPENAI_API_KEY)
1017
+ return null;
1018
+ const now = new Date().toISOString();
1019
+ const reservedSlugs = new Set(getSessionDraft(session).keys());
1020
+ const allocateSlug = (requested) => {
1021
+ let candidate = requested;
1022
+ if (!reservedSlugs.has(candidate)) {
1023
+ reservedSlugs.add(candidate);
1024
+ return candidate;
1025
+ }
1026
+ let idx = 2;
1027
+ while (reservedSlugs.has(`${requested}-${idx}`))
1028
+ idx += 1;
1029
+ candidate = `${requested}-${idx}`;
1030
+ reservedSlugs.add(candidate);
1031
+ return candidate;
1032
+ };
1033
+ const pages = audiences.map((aud, index) => {
1034
+ const seed = toSeedSlug(aud) || `audience-${index + 1}`;
1035
+ const requestedSlug = routeMentions[index] ?? `/for-${seed}`;
1036
+ const normalizedRequested = normalizeRouteCandidate(requestedSlug) ?? `/for-${seed}`;
1037
+ const newSlug = allocateSlug(normalizedRequested);
1038
+ const label = titleCaseWords(aud);
1039
+ const page = {
1040
+ id: `p_for_${seed}`,
1041
+ slug: newSlug,
1042
+ title: `For ${label}`,
1043
+ updatedAt: now,
1044
+ blocks: [
1045
+ {
1046
+ id: `b_hero_${seed}`,
1047
+ type: "Hero",
1048
+ props: {
1049
+ heading: `Built for ${label}`,
1050
+ subheading: `Everything on this page is tailored for ${aud}.`,
1051
+ ctaText: "Get Started",
1052
+ ctaHref: "/",
1053
+ imageUrl: IMAGE_PLACEHOLDER,
1054
+ imageAlt: `Audience-focused hero image for ${label}`
1055
+ }
1056
+ },
1057
+ {
1058
+ id: `b_features_${seed}`,
1059
+ type: "FeatureGrid",
1060
+ props: {
1061
+ title: `Why ${label} choose this`,
1062
+ features: [
1063
+ { title: "Relevant messaging", description: `Copy aligned to ${aud} needs and language.` },
1064
+ { title: "Clear outcomes", description: "Benefits are framed around practical results." },
1065
+ { title: "Focused next step", description: "CTA is tuned for this audience journey." }
1066
+ ]
1067
+ }
1068
+ },
1069
+ {
1070
+ id: `b_faq_${seed}`,
1071
+ type: "FAQAccordion",
1072
+ props: {
1073
+ title: `FAQ for ${label}`,
1074
+ items: [
1075
+ { q: `Is this suitable for ${aud}?`, a: `Yes, this page is tailored for ${aud}.` },
1076
+ { q: "How quickly can I start?", a: "Most visitors can get started in minutes." },
1077
+ { q: "Can I customize later?", a: "Yes, content and sections can be updated anytime." }
1078
+ ]
1079
+ }
1080
+ },
1081
+ {
1082
+ id: `b_cta_${seed}`,
1083
+ type: "CTA",
1084
+ props: {
1085
+ title: `Start with a plan for ${label}`,
1086
+ description: "Take the next step with content designed for your audience.",
1087
+ ctaText: "Start now",
1088
+ ctaHref: "/"
1089
+ }
1090
+ }
1091
+ ]
1092
+ };
1093
+ return { page, audience: aud };
1094
+ });
1095
+ const createdAudienceLabels = pages.map((entry) => entry.audience);
1096
+ const summaryAudienceList = createdAudienceLabels.join(", ");
1097
+ return {
1098
+ intent: "edit_plan",
1099
+ summary_for_user: pages.length === 1
1100
+ ? `Created a new page tailored for ${audience}.`
1101
+ : `Created ${pages.length} new pages tailored for ${summaryAudienceList}.`,
1102
+ change_log: [
1103
+ ...assumptions,
1104
+ ...pages.map((entry) => `Created page ${entry.page.slug} for audience: ${entry.audience}.`)
1105
+ ],
1106
+ ops: pages.map((entry) => ({ op: "create_page", page: entry.page }))
1107
+ };
1108
+ }
1109
+ const asksAudienceRetarget = Boolean(audience) &&
1110
+ !asksAudienceCreatePage &&
1111
+ (/\bfor\b/.test(lowerMessage) || /\baudience\b/.test(lowerMessage) || /\btarget\b/.test(lowerMessage));
1112
+ if (asksAudienceRetarget && audience) {
1113
+ const targets = selectedBlock
1114
+ ? [selectedBlock]
1115
+ : currentPage.blocks.filter((block) => block.type === "Hero" || block.type === "CTA" || block.type === "RichText").slice(0, 3);
1116
+ if (process.env.OPENAI_API_KEY && !selectedBlock)
1117
+ return null;
1118
+ const ops = [];
1119
+ for (const block of targets) {
1120
+ const patch = audiencePatchForBlock(block, audience);
1121
+ if (Object.keys(patch).length === 0)
1122
+ continue;
1123
+ ops.push({ op: "update_props", pageSlug: slug, blockId: block.id, patch });
1124
+ }
1125
+ if (ops.length > 0) {
1126
+ return {
1127
+ intent: "edit_plan",
1128
+ summary_for_user: st(args.locale, "audience.done", { audience }),
1129
+ change_log: [...assumptions, `Retargeted copy for audience: ${audience}.`],
1130
+ ops
1131
+ };
1132
+ }
1133
+ }
1134
+ if (intent.action === "move" && hasConditionalQualifier && asksSectionReorder && !hasExplicitPlacementCue && !hasExplicitBlockMentionInMessage) {
1135
+ return {
1136
+ intent: "needs_clarification",
1137
+ summary_for_user: st(args.locale, "reorder.specify"),
1138
+ change_log: [...assumptions, "Skipped ambiguous conditional reorder request without explicit section or placement."],
1139
+ ops: []
1140
+ };
1141
+ }
1142
+ if (selectedBlock?.type === "Hero" &&
1143
+ asksSecondaryCtaAdd &&
1144
+ (intent.action === "add" || intent.action === "clarify" || intent.action === "update")) {
1145
+ const heroProps = selectedBlock.props;
1146
+ const existingText = typeof heroProps.secondaryCtaText === "string" ? heroProps.secondaryCtaText.trim() : "";
1147
+ const existingHref = typeof heroProps.secondaryCtaHref === "string" ? heroProps.secondaryCtaHref.trim() : "";
1148
+ const quoted = quotedText(message);
1149
+ const patch = {
1150
+ secondaryCtaText: quoted ?? (existingText.length > 0 ? existingText : "Learn more"),
1151
+ secondaryCtaHref: existingHref.length > 0 ? existingHref : "/"
1152
+ };
1153
+ return {
1154
+ intent: "edit_plan",
1155
+ summary_for_user: st(args.locale, "hero.secondaryCta"),
1156
+ change_log: [...assumptions, `Updated ${selectedBlock.id}: secondaryCtaText, secondaryCtaHref`],
1157
+ ops: [{ op: "update_props", pageSlug: slug, blockId: selectedBlock.id, patch }]
1158
+ };
1159
+ }
1160
+ // "each/every/all" signals a bulk update across items — needs LLM, not inline append
1161
+ const asksBulkUpdate = /\b(each|every|all)\b/.test(lowerMessage);
1162
+ const asksInlineAdd = !asksBulkUpdate &&
1163
+ lowerMessage.includes("add") &&
1164
+ (lowerMessage.includes("inside") ||
1165
+ lowerMessage.includes("within") ||
1166
+ lowerMessage.includes("current") ||
1167
+ lowerMessage.includes("this one") ||
1168
+ lowerMessage.includes("more") ||
1169
+ lowerMessage.includes("another"));
1170
+ if ((intent.action === "add" || intent.action === "clarify") && asksInlineAdd) {
1171
+ let inlineTarget = selectedBlock;
1172
+ let ambiguousContainerType = null;
1173
+ if (!inlineTarget) {
1174
+ const typeMap = [
1175
+ { test: /\btestimonial/, type: "Testimonials" },
1176
+ { test: /\b(faq|question)/, type: "FAQAccordion" },
1177
+ { test: /\bfeature/, type: "FeatureGrid" },
1178
+ { test: /\bcard/, type: "CardGrid" },
1179
+ { test: /\btab/, type: "Tabs" },
1180
+ { test: /\bslide/, type: "Carousel" },
1181
+ { test: /\bstat/, type: "Stats" },
1182
+ { test: /\brow/, type: "Table" }
1183
+ ];
1184
+ for (const entry of typeMap) {
1185
+ if (entry.test.test(lowerMessage)) {
1186
+ const matches = currentPage.blocks.filter((b) => b.type === entry.type);
1187
+ // Keep scanning when this item word names a container that isn't on the
1188
+ // page: "add another card to the features section" mentions "feature"
1189
+ // (no FeatureGrid here) but the cards live in a CardGrid, matched by the
1190
+ // later "card" entry — breaking on the first match stranded it (#19).
1191
+ if (matches.length === 1) {
1192
+ inlineTarget = matches[0];
1193
+ break;
1194
+ }
1195
+ // Referenced type exists but isn't unique (e.g. 3 CardGrids) — remember it
1196
+ // so we can defer rather than fabricate a new container below.
1197
+ if (matches.length > 1 && !ambiguousContainerType)
1198
+ ambiguousContainerType = entry.type;
1199
+ }
1200
+ }
1201
+ }
1202
+ if (inlineTarget) {
1203
+ const patch = buildListAppendPatch(inlineTarget, message);
1204
+ if (patch) {
1205
+ return {
1206
+ intent: "edit_plan",
1207
+ summary_for_user: st(args.locale, "update.done", { type: blockLabel(inlineTarget.type) }),
1208
+ change_log: [...assumptions, `Added one entry to ${blockLabel(inlineTarget.type)}.`],
1209
+ ops: [{ op: "update_props", pageSlug: slug, blockId: inlineTarget.id, patch }]
1210
+ };
1211
+ }
1212
+ }
1213
+ // Clear "append another item to the <X>" intent, but the target container can't
1214
+ // be resolved deterministically (multiple of that type) — defer to the LLM to
1215
+ // pick the right block by heading instead of falling through and adding a phantom
1216
+ // empty container (#19). Without this the request creates a junk Card/FeatureGrid.
1217
+ if (!inlineTarget && ambiguousContainerType)
1218
+ return null;
1219
+ }
1220
+ if (intent.action === "info") {
1221
+ // Let the full planner handle info queries — it produces detailed
1222
+ // block descriptions via the submit_edit_plan tool.
1223
+ return null;
1224
+ }
1225
+ if (intent.action === "clarify" && !activeEditablePath) {
1226
+ return {
1227
+ intent: "needs_clarification",
1228
+ summary_for_user: intent.summary ?? st(args.locale, "update.specify"),
1229
+ change_log: assumptions,
1230
+ ops: []
1231
+ };
1232
+ }
1233
+ // "remove all except X" — generate remove ops for every block NOT matching the kept type
1234
+ if (intent.action === "remove" && EXCEPT_PATTERN.test(cleanMessage)) {
1235
+ const keepType = intent.target_block_type;
1236
+ if (keepType) {
1237
+ const toRemove = currentPage.blocks.filter((b) => b.type !== keepType);
1238
+ if (toRemove.length === 0) {
1239
+ return {
1240
+ intent: "edit_plan",
1241
+ summary_for_user: st(args.locale, "remove.allAlready", { type: blockLabel(keepType) }),
1242
+ change_log: assumptions,
1243
+ ops: []
1244
+ };
1245
+ }
1246
+ return {
1247
+ intent: "edit_plan",
1248
+ summary_for_user: intent.summary ?? `Removed all blocks except ${keepType}.`,
1249
+ change_log: [
1250
+ ...assumptions,
1251
+ ...toRemove.map((b) => `Removed ${blockLabel(b.type)}.`)
1252
+ ],
1253
+ ops: toRemove.map((b) => ({ op: "remove_block", pageSlug: slug, blockId: b.id }))
1254
+ };
1255
+ }
1256
+ }
1257
+ if (intent.action === "remove") {
1258
+ let target = resolveBlockRef({
1259
+ ref: intent.target_block_ref,
1260
+ currentPage,
1261
+ activeBlockId,
1262
+ fallbackType: intent.target_block_type
1263
+ });
1264
+ if (!target && !activeBlockId) {
1265
+ const inferredType = inferBlockTypeFromText(cleanMessage);
1266
+ if (inferredType) {
1267
+ const matches = currentPage.blocks.filter((b) => b.type === inferredType);
1268
+ if (matches.length === 1)
1269
+ target = matches[0];
1270
+ }
1271
+ }
1272
+ if (!target) {
1273
+ return {
1274
+ intent: "needs_clarification",
1275
+ summary_for_user: st(args.locale, "remove.needsBlock"),
1276
+ change_log: assumptions,
1277
+ ops: []
1278
+ };
1279
+ }
1280
+ // Detect item-level removal (e.g., "remove the first question from the FAQ")
1281
+ // Skip when the user wants to remove image *properties* (e.g. "remove images from cards")
1282
+ const asksRemoveImageProps = /\bremove\b.*\b(images?|photos?|pictures?)\b.*\b(from|in|on)\b/i.test(lowerMessage);
1283
+ // The ambiguous triggers below ("card", "feature", ordinals) ALSO appear when the
1284
+ // user is selecting among sibling CONTAINER BLOCKS ("delete the second card grid",
1285
+ // "remove the first feature grid", "hide the third section"). Removing an item there
1286
+ // is destructive and wrong — it deletes one card and shifts the array while the
1287
+ // summary claims the whole block is gone (#31). Treat it as a block removal when the
1288
+ // message names a container block as the target and isn't scoped INTO the container
1289
+ // ("the last card IN the grid" / "a question FROM the faq" stay item ops).
1290
+ const namesContainerAsTarget = /\b(?:card\s*grids?|feature\s*grids?|grids?|two[\s-]?columns?|carousels?|galler(?:y|ies)|accordions?|sections?|blocks?)\b/i.test(lowerMessage);
1291
+ const itemScopedIntoContainer = /\b(?:cards?|questions?|items?|entries|entry|testimonials?|features?|slides?|rows?|stats?|tabs?|points?|photos?|images?)\s+(?:in|inside|within|from|of|on)\b/i.test(lowerMessage) ||
1292
+ isContainerScopedRemove(lowerMessage);
1293
+ const removesWholeContainer = namesContainerAsTarget && !itemScopedIntoContainer;
1294
+ // "remove all the card grids" / "delete every feature grid" — every block of the
1295
+ // target's type, not one item. Emit a remove_block per matching block (#31).
1296
+ if (removesWholeContainer && /\b(?:all|every|each)\b/i.test(lowerMessage)) {
1297
+ const matches = currentPage.blocks.filter((b) => b.type === target.type);
1298
+ if (matches.length > 0) {
1299
+ return {
1300
+ intent: "edit_plan",
1301
+ summary_for_user: intent.summary ?? st(args.locale, "remove.done", { type: blockLabel(target.type) }),
1302
+ change_log: [...assumptions, ...matches.map(() => `Removed ${blockLabel(target.type)}.`)],
1303
+ ops: matches.map((b) => ({ op: "remove_block", pageSlug: slug, blockId: b.id }))
1304
+ };
1305
+ }
1306
+ }
1307
+ const asksRemoveItem = !asksRemoveImageProps && !removesWholeContainer && /\b(question|item|entry|testimonial|card|feature|first|second|third|last)\b/i.test(lowerMessage);
1308
+ if (asksRemoveItem) {
1309
+ const bProps = target.props;
1310
+ if (bProps) {
1311
+ for (const [key, val] of Object.entries(bProps)) {
1312
+ if (!Array.isArray(val) || val.length === 0)
1313
+ continue;
1314
+ // Use ordinalToIndex for consistent ordinal parsing
1315
+ const ordMatch = lowerMessage.match(/\b(first|second|third|fourth|fifth|last|1st|2nd|3rd|4th|5th)\b/i);
1316
+ const parsedIdx = ordMatch ? ordinalToIndex(ordMatch[1]) : 0;
1317
+ if (parsedIdx === null) {
1318
+ return {
1319
+ intent: "needs_clarification",
1320
+ summary_for_user: st(args.locale, "remove.specifyItem"),
1321
+ change_log: assumptions,
1322
+ ops: []
1323
+ };
1324
+ }
1325
+ const idx = parsedIdx === -1 ? val.length - 1 : parsedIdx;
1326
+ if (idx >= val.length) {
1327
+ return {
1328
+ intent: "needs_clarification",
1329
+ summary_for_user: st(args.locale, "remove.outOfRange", { count: String(val.length), type: blockLabel(target.type), index: String(idx + 1) }),
1330
+ change_log: assumptions,
1331
+ ops: []
1332
+ };
1333
+ }
1334
+ return {
1335
+ intent: "edit_plan",
1336
+ summary_for_user: intent.summary ?? st(args.locale, "remove.itemDone", { type: blockLabel(target.type) }),
1337
+ change_log: [...assumptions, `Removed item ${idx + 1} from ${blockLabel(target.type)}.`],
1338
+ ops: [{ op: "remove_item", pageSlug: slug, blockId: target.id, listKey: key, index: idx }]
1339
+ };
1340
+ }
1341
+ }
1342
+ }
1343
+ return {
1344
+ intent: "edit_plan",
1345
+ summary_for_user: intent.summary ?? st(args.locale, "remove.done", { type: blockLabel(target.type) }),
1346
+ change_log: [...assumptions, `Removed ${blockLabel(target.type)}.`],
1347
+ ops: [{ op: "remove_block", pageSlug: slug, blockId: target.id }]
1348
+ };
1349
+ }
1350
+ if (intent.action === "update" || (intent.action === "clarify" && !!activeEditablePath)) {
1351
+ const target = resolveBlockRef({
1352
+ ref: intent.target_block_ref,
1353
+ currentPage,
1354
+ activeBlockId,
1355
+ fallbackType: intent.target_block_type
1356
+ });
1357
+ if (!target) {
1358
+ return {
1359
+ intent: "needs_clarification",
1360
+ summary_for_user: st(args.locale, "update.needsBlock"),
1361
+ change_log: assumptions,
1362
+ ops: []
1363
+ };
1364
+ }
1365
+ const childPatch = coercePatchForEditablePath(target, activeEditablePath, intent.patch, message);
1366
+ const fullPatch = coercePatchForBlock(target, intent.patch);
1367
+ const mergedRichTextTranslationPatch = shouldKeepRichTextTitleOnTranslate({
1368
+ target,
1369
+ activeEditablePath,
1370
+ message,
1371
+ fullPatch
1372
+ })
1373
+ ? { ...fullPatch, ...(childPatch?.patch ?? {}) }
1374
+ : null;
1375
+ const patch = mergedRichTextTranslationPatch ?? childPatch?.patch ?? fullPatch;
1376
+ if (Object.keys(patch).length === 0) {
1377
+ const editableFields = userFacingPropNames(target.type, Object.keys(target.props));
1378
+ return {
1379
+ intent: "needs_clarification",
1380
+ summary_for_user: st(args.locale, "update.invalidFields", { type: blockLabel(target.type) }),
1381
+ change_log: [...assumptions, `Editable fields: ${editableFields.join(", ")}`],
1382
+ ops: []
1383
+ };
1384
+ }
1385
+ const changedKeys = userFacingPropNames(target.type, Object.keys(patch));
1386
+ return {
1387
+ intent: "edit_plan",
1388
+ summary_for_user: intent.summary ?? st(args.locale, "update.done", { type: blockLabel(target.type) }),
1389
+ change_log: [
1390
+ ...assumptions,
1391
+ childPatch
1392
+ ? `Updated ${blockLabel(target.type)} ${activeEditablePath}: ${changedKeys.join(", ")}`
1393
+ : `Updated ${blockLabel(target.type)}: ${changedKeys.join(", ")}`
1394
+ ],
1395
+ ops: [{ op: "update_props", pageSlug: slug, blockId: target.id, patch }]
1396
+ };
1397
+ }
1398
+ if (intent.action === "move") {
1399
+ const target = resolveBlockRef({
1400
+ ref: intent.target_block_ref,
1401
+ currentPage,
1402
+ activeBlockId,
1403
+ fallbackType: intent.target_block_type
1404
+ });
1405
+ if (!target) {
1406
+ return {
1407
+ intent: "needs_clarification",
1408
+ summary_for_user: st(args.locale, "move.needsBlock"),
1409
+ change_log: assumptions,
1410
+ ops: []
1411
+ };
1412
+ }
1413
+ let afterBlockId;
1414
+ if (intent.position === "top") {
1415
+ afterBlockId = undefined;
1416
+ }
1417
+ else if (intent.position === "bottom") {
1418
+ const tail = [...currentPage.blocks].reverse().find((b) => b.id !== target.id);
1419
+ afterBlockId = tail?.id;
1420
+ }
1421
+ else if (intent.position === "after" || (intent.anchor_block_ref && !intent.position)) {
1422
+ const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
1423
+ if (!anchor) {
1424
+ return {
1425
+ intent: "needs_clarification",
1426
+ summary_for_user: st(args.locale, "move.anchorBlockNotFound"),
1427
+ change_log: assumptions,
1428
+ ops: []
1429
+ };
1430
+ }
1431
+ afterBlockId = anchor.id;
1432
+ }
1433
+ else if (intent.position === "before") {
1434
+ const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
1435
+ if (!anchor) {
1436
+ return {
1437
+ intent: "needs_clarification",
1438
+ summary_for_user: st(args.locale, "move.anchorBlockBeforeNotFound"),
1439
+ change_log: assumptions,
1440
+ ops: []
1441
+ };
1442
+ }
1443
+ const idx = currentPage.blocks.findIndex((b) => b.id === anchor.id);
1444
+ if (idx > 0)
1445
+ afterBlockId = currentPage.blocks[idx - 1]?.id;
1446
+ else
1447
+ afterBlockId = undefined;
1448
+ }
1449
+ else if (message.toLowerCase().includes("bottom") || message.toLowerCase().includes("end")) {
1450
+ const tail = [...currentPage.blocks].reverse().find((b) => b.id !== target.id);
1451
+ afterBlockId = tail?.id;
1452
+ }
1453
+ else {
1454
+ return {
1455
+ intent: "needs_clarification",
1456
+ summary_for_user: st(args.locale, "move.specifyDirection"),
1457
+ change_log: assumptions,
1458
+ ops: []
1459
+ };
1460
+ }
1461
+ return {
1462
+ intent: "edit_plan",
1463
+ summary_for_user: intent.summary ?? st(args.locale, "move.blockDone", { type: blockLabel(target.type) }),
1464
+ change_log: [...assumptions, `Moved ${blockLabel(target.type)}.`],
1465
+ ops: [{ op: "move_block", pageSlug: slug, blockId: target.id, afterBlockId }]
1466
+ };
1467
+ }
1468
+ if (intent.action === "add") {
1469
+ // --- Batch add: "add 3 blocks: hero, cardgrid and CTA" ----------------
1470
+ if (isBatchAddRequest(message)) {
1471
+ // Strip any trailing positional anchor so a real batch ("add a hero and a
1472
+ // cta below the header") doesn't also create the anchor block (#18).
1473
+ const blockTypes = extractMentionedBlockTypes(stripPositionalAnchorPhrase(message));
1474
+ if (blockTypes.length >= 2) {
1475
+ const ops = [];
1476
+ const changeLog = [...assumptions];
1477
+ // Track blocks as we add them so nextBlockId generates unique IDs
1478
+ let pageSnapshot = currentPage;
1479
+ for (const bt of blockTypes) {
1480
+ const blockId = nextBlockId(bt, pageSnapshot);
1481
+ const props = defaultPropsForType(bt);
1482
+ ops.push({ op: "add_block", pageSlug: slug, block: { id: blockId, type: bt, props } });
1483
+ changeLog.push(`Added ${blockLabel(bt)} block.`);
1484
+ // Update snapshot so the next nextBlockId sees existing IDs
1485
+ pageSnapshot = { ...pageSnapshot, blocks: [...pageSnapshot.blocks, { id: blockId, type: bt, props }] };
1486
+ }
1487
+ return {
1488
+ intent: "edit_plan",
1489
+ summary_for_user: st(args.locale, "add.multiple", { types: blockTypes.map(blockLabel).join(", ") }),
1490
+ change_log: changeLog,
1491
+ ops
1492
+ };
1493
+ }
1494
+ }
1495
+ // --- Single add -------------------------------------------------------
1496
+ // Scan the message with any trailing positional anchor removed so "add a
1497
+ // contact form below the hero" does not resolve to the anchor's type (Hero)
1498
+ // and silently create a phantom duplicate (#18).
1499
+ const addText = stripPositionalAnchorPhrase(message);
1500
+ const blockType = intent.new_block_type ??
1501
+ inferAddedBlockTypeFromMessage(addText) ??
1502
+ intent.target_block_type ??
1503
+ inferBlockTypeFromText(intent.target_block_ref ?? "") ??
1504
+ inferBlockTypeFromText(addText);
1505
+ if (!blockType) {
1506
+ // No block type found — check if this is an image replacement request
1507
+ // e.g. "add unsplash image", "add a new photo", "add image"
1508
+ const isImageRequest = /\b(image|photo|picture)\b/.test(lowerMessage);
1509
+ if (isImageRequest) {
1510
+ const hero = selectedBlock?.type === "Hero"
1511
+ ? selectedBlock
1512
+ : currentPage.blocks.find((b) => b.type === "Hero") ?? null;
1513
+ if (hero) {
1514
+ return {
1515
+ intent: "edit_plan",
1516
+ summary_for_user: st(args.locale, "add.heroImage"),
1517
+ change_log: [...assumptions, `Updated ${blockLabel(hero.type)}: image`],
1518
+ ops: [{ op: "update_props", pageSlug: slug, blockId: hero.id, patch: { imageUrl: "pending" } }]
1519
+ };
1520
+ }
1521
+ }
1522
+ return {
1523
+ intent: "needs_clarification",
1524
+ summary_for_user: st(args.locale, "add.specifyType", { types: allowedBlockTypes.map(blockLabel).join(", ") }),
1525
+ change_log: assumptions,
1526
+ ops: []
1527
+ };
1528
+ }
1529
+ // Check if this is an item-level addition to an existing list block.
1530
+ // An explicit positional placement ("add a testimonials section below the
1531
+ // hero") always means a NEW block at that spot — never an item appended to an
1532
+ // existing one — so skip the append path when an anchor/position is set (#18).
1533
+ const explicitPlacement = intent.position != null || intent.anchor_block_ref != null;
1534
+ // "add an FAQ section" / "add a testimonials section" names a NEW block to
1535
+ // create, even when one of that type already exists — the block noun
1536
+ // ("section"/"block") is the object of the add. Only count it when it appears
1537
+ // BEFORE any "to the <container>" clause, so "add a testimonial to the
1538
+ // testimonials section" (an item appended into a container) still appends (#15).
1539
+ const addObject = lowerMessage.split(/\bto\s+the\b/)[0] ?? lowerMessage;
1540
+ const wantsNewBlock = /\b(?:section|block)\b/.test(addObject);
1541
+ const existingOfType = currentPage.blocks.filter((b) => b.type === blockType);
1542
+ if (existingOfType.length === 1 && !explicitPlacement && !wantsNewBlock) {
1543
+ const existing = existingOfType[0];
1544
+ const listPatch = buildListAppendPatch(existing, message);
1545
+ if (listPatch) {
1546
+ return {
1547
+ intent: "edit_plan",
1548
+ summary_for_user: intent.summary ?? st(args.locale, "add.itemDone", { type: blockLabel(existing.type) }),
1549
+ change_log: [...assumptions, `Appended item to ${blockLabel(existing.type)}.`],
1550
+ ops: [{ op: "update_props", pageSlug: slug, blockId: existing.id, patch: listPatch }]
1551
+ };
1552
+ }
1553
+ }
1554
+ // "add a <child> to/into the <existing block>" — e.g. "add a video to the hero",
1555
+ // "add a button to the CTA", "add a card to the classes grid". A same-type item
1556
+ // append was already handled just above; reaching here means the target names an
1557
+ // existing block of a DIFFERENT type, so the user wants to put something INSIDE (or
1558
+ // onto) that block, not create a new top-level one. The deterministic scan can't model
1559
+ // that and tends to mistake the target's type for the block to create — a phantom
1560
+ // duplicate Hero/CTA, or a stray Card. Hand these to the LLM, which disambiguates (#28).
1561
+ const intoClause = lowerMessage.match(/\b(?:in|into|to)\s+the\s+([a-z][a-z0-9 ]*)/);
1562
+ if (intoClause && !wantsNewBlock && !explicitPlacement) {
1563
+ const targetPhrase = intoClause[1];
1564
+ const targetType = inferBlockTypeFromText(targetPhrase) ?? inferContainerTypeFromNoun(targetPhrase);
1565
+ const targetsPageOrPosition = /\b(page|site|top|bottom|end|start|beginning|middle)\b/.test(targetPhrase);
1566
+ if (targetType != null && !targetsPageOrPosition && currentPage.blocks.some((b) => b.type === targetType)) {
1567
+ return null;
1568
+ }
1569
+ }
1570
+ const blockId = nextBlockId(blockType, currentPage);
1571
+ const baseProps = defaultPropsForType(blockType);
1572
+ const patch = coercePatchForBlock({ id: blockId, type: blockType, props: baseProps }, intent.patch);
1573
+ const props = { ...baseProps, ...patch };
1574
+ const addOp = {
1575
+ op: "add_block",
1576
+ pageSlug: slug,
1577
+ block: { id: blockId, type: blockType, props }
1578
+ };
1579
+ let extraMoveTop = null;
1580
+ if (intent.position === "after" || (intent.anchor_block_ref && !intent.position)) {
1581
+ const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
1582
+ if (!anchor) {
1583
+ return {
1584
+ intent: "needs_clarification",
1585
+ summary_for_user: st(args.locale, "add.anchorAfterNotFound"),
1586
+ change_log: assumptions,
1587
+ ops: []
1588
+ };
1589
+ }
1590
+ addOp.afterBlockId = anchor.id;
1591
+ }
1592
+ else if (intent.position === "before") {
1593
+ const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
1594
+ if (!anchor) {
1595
+ return {
1596
+ intent: "needs_clarification",
1597
+ summary_for_user: st(args.locale, "add.anchorBeforeNotFound"),
1598
+ change_log: assumptions,
1599
+ ops: []
1600
+ };
1601
+ }
1602
+ const idx = currentPage.blocks.findIndex((b) => b.id === anchor.id);
1603
+ if (idx > 0)
1604
+ addOp.afterBlockId = currentPage.blocks[idx - 1]?.id;
1605
+ else
1606
+ extraMoveTop = { op: "move_block", pageSlug: slug, blockId, afterBlockId: undefined };
1607
+ }
1608
+ else if (intent.position === "top") {
1609
+ extraMoveTop = { op: "move_block", pageSlug: slug, blockId, afterBlockId: undefined };
1610
+ }
1611
+ else if (intent.position === "bottom" || !intent.position) {
1612
+ // no-op: add without anchor appends to bottom in applyOpsAtomically
1613
+ }
1614
+ const ops = extraMoveTop ? [addOp, extraMoveTop] : [addOp];
1615
+ return {
1616
+ intent: "edit_plan",
1617
+ summary_for_user: intent.summary ?? st(args.locale, "add.done", { type: blockLabel(blockType) }),
1618
+ change_log: [...assumptions, `Added ${blockLabel(blockType)} block.`],
1619
+ ops
1620
+ };
1621
+ }
1622
+ return null;
1623
+ }
1624
+ // ---------------------------------------------------------------------------
1625
+ // Re-exports from split files (preserve public API)
1626
+ // ---------------------------------------------------------------------------
1627
+ 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";
1629
+ export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
1630
+ export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.js";
1631
+ export { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveByDescriptor, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";