@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,730 @@
1
+ import { z } from "zod";
2
+ import { allowedBlockTypes, getBlockMeta, getPropDisplayName } from "@avocadostudio-ai/shared";
3
+ import { isLikelyClarificationFollowUp, isStandalonePageOperation } from "./intent-helpers.js";
4
+ import { versions, pendingClarificationBySession } from "../state/session-state.js";
5
+ import { UNIT, BLOCK_CATALOG_PATTERNS, BATCH_ADD_PATTERNS, BATCH_UPDATE_PATTERNS, BATCH_PAGE_CREATE_PATTERNS, COUNTED_MULTI_BLOCK_ADD_PATTERN, ADD_ACTION_PATTERN, BLOCK_TYPE_KEYWORDS, KEYWORD_TO_BLOCK_TYPE, EACH_BLOCK_TYPE_PATTERN, PAGE_WIDE_REWRITE_PATTERNS, BATCH_REORDER_PATTERNS, PAGE_LIST_PATTERNS, CONTENT_QUERY_PATTERNS, FIELD_CONTENT_UPDATE_PATTERN, CROSS_PAGE_SCOPE_PATTERN } from "./intent-patterns.js";
6
+ export const siteCapabilitiesSchema = z.object({
7
+ allowStructuralEdits: z.boolean(),
8
+ manifestStatus: z.enum(["loading", "ready", "degraded"]),
9
+ reason: z.string().optional(),
10
+ manifestVersion: z.number().int().positive().optional(),
11
+ blockCount: z.number().int().nonnegative().optional(),
12
+ checkedAt: z.string()
13
+ });
14
+ const businessContextObjectSchema = z.object({
15
+ purpose: z.string().optional(),
16
+ tone: z.string().optional(),
17
+ constraints: z.array(z.string()).optional(),
18
+ });
19
+ const siteContextObjectSchema = z.object({
20
+ siteId: z.string().optional(),
21
+ siteName: z.string().optional(),
22
+ purpose: z.string().optional(),
23
+ hosting: z.string().optional(),
24
+ tone: z.string().optional(),
25
+ constraints: z.array(z.string()).optional(),
26
+ gdriveFolderId: z.string().optional(),
27
+ });
28
+ export const chatAttachmentSchema = z.object({
29
+ id: z.string(),
30
+ kind: z.enum(["image", "pdf"]),
31
+ url: z.string(),
32
+ mimeType: z.string(),
33
+ name: z.string().optional(),
34
+ bytes: z.number().optional(),
35
+ });
36
+ export const chatRequestBodySchema = z.object({
37
+ session: z.string().optional(),
38
+ siteId: z.string().optional(),
39
+ componentsManifest: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
40
+ siteCapabilities: z.union([siteCapabilitiesSchema, z.string()]).optional(),
41
+ sitePurpose: z.string().optional(),
42
+ siteHosting: z.string().optional(),
43
+ businessContext: z.union([businessContextObjectSchema, z.string()]).optional(),
44
+ siteContext: z.union([siteContextObjectSchema, z.string()]).optional(),
45
+ locale: z.string().optional(),
46
+ slug: z.string().optional(),
47
+ message: z.string().optional(),
48
+ modelKey: z.enum(["fast", "balanced", "reasoning", "codex"]).optional(),
49
+ provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
50
+ activeBlockId: z.string().optional(),
51
+ activeBlockType: z.string().optional(),
52
+ activeEditablePath: z.string().optional(),
53
+ executionMode: z.enum(["auto", "plan_only", "apply_pending_plan", "discard_pending_plan", "continue_chain"]).optional(),
54
+ pendingPlanId: z.string().optional(),
55
+ continuationChainId: z.string().optional(),
56
+ attachments: z.array(chatAttachmentSchema).max(6).optional(),
57
+ });
58
+ // ---------------------------------------------------------------------------
59
+ // Shared normaliser used by all intent detectors below.
60
+ // ---------------------------------------------------------------------------
61
+ export function normalizeForIntent(message) {
62
+ return message.toLowerCase().replace(/\s+/g, " ").trim();
63
+ }
64
+ // Backward-compatible re-exports — patterns now live in intent-patterns.ts
65
+ export { BLOCK_CATALOG_PATTERNS } from "./intent-patterns.js";
66
+ export function isBlockCatalogQuery(message) {
67
+ const m = normalizeForIntent(message);
68
+ return BLOCK_CATALOG_PATTERNS.some((re) => re.test(m));
69
+ }
70
+ function countMentionedBlockTypes(message) {
71
+ const matched = new Set();
72
+ for (const entry of BLOCK_TYPE_KEYWORDS) {
73
+ if (entry.pattern.test(message))
74
+ matched.add(entry.key);
75
+ }
76
+ return matched.size;
77
+ }
78
+ // A trailing positional-anchor phrase ("... below the hero", "... after the
79
+ // testimonials") names an EXISTING block used purely for placement — it is never
80
+ // a block to create. Strip it before scanning for block types so the anchor's
81
+ // own type isn't mistaken for new content, which produced phantom duplicate
82
+ // blocks (planner issue #18). The anchor is resolved separately from the full
83
+ // message, so removing it here does not affect placement.
84
+ //
85
+ // Requires an article after the preposition ("below THE hero") so adjectival
86
+ // uses like "a before-and-after gallery" are left intact. A coordinated list
87
+ // before the anchor ("add a hero and a cta below the header") is preserved
88
+ // because only the trailing anchor clause is removed.
89
+ const POSITIONAL_ANCHOR_PHRASE = /\s+\b(?:below|above|under(?:neath)?|beneath|after|before|beside|atop)\s+(?:the|a|an|our|my|that|this|its)\b.*$/i;
90
+ export function stripPositionalAnchorPhrase(message) {
91
+ return message.replace(POSITIONAL_ANCHOR_PHRASE, "").trim();
92
+ }
93
+ /** Return the ordered list of BlockType values mentioned in the message. */
94
+ export function extractMentionedBlockTypes(message) {
95
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
96
+ const found = [];
97
+ for (const entry of BLOCK_TYPE_KEYWORDS) {
98
+ const match = m.match(entry.pattern);
99
+ if (match && match.index !== undefined) {
100
+ // Avoid double-counting "card" when "cardgrid" already matched at the same position
101
+ if (entry.key === "card" && found.some((f) => f.key === "cardgrid" && Math.abs(f.index - match.index) <= 4))
102
+ continue;
103
+ found.push({ key: entry.key, index: match.index });
104
+ }
105
+ }
106
+ // Return in order of appearance in the message
107
+ found.sort((a, b) => a.index - b.index);
108
+ return found.map((f) => KEYWORD_TO_BLOCK_TYPE[f.key]);
109
+ }
110
+ /**
111
+ * Returns true when the message is "add X in/to [the Y] heading/subheading/copy/..."
112
+ * — a field-content update phrased with "add", NOT a batch block add.
113
+ * Guards the loose fallback below from false positives when field-targeting
114
+ * prepositions are combined with block-type keyword aliases like "numbers"
115
+ * (Stats) or "copy" (RichText).
116
+ */
117
+ export function isFieldContentUpdateRequest(message) {
118
+ return FIELD_CONTENT_UPDATE_PATTERN.test(stripSiteContextEnvelope(message));
119
+ }
120
+ export function isBatchAddRequest(message) {
121
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
122
+ if (BATCH_ADD_PATTERNS.some((re) => re.test(m)) || BATCH_PAGE_CREATE_PATTERNS.some((re) => re.test(m)))
123
+ return true;
124
+ if (BATCH_UPDATE_PATTERNS.some((re) => re.test(m)))
125
+ return true;
126
+ if (COUNTED_MULTI_BLOCK_ADD_PATTERN.test(m))
127
+ return true;
128
+ if (EACH_BLOCK_TYPE_PATTERN.test(m))
129
+ return true;
130
+ // "add X in/to Y heading/subheading/copy/..." is a field update, not a batch add.
131
+ // The loose fallback below would otherwise fire on messages where block-type
132
+ // keywords like "numbers" (Stats) or "copy" (RichText) happen to appear.
133
+ if (isFieldContentUpdateRequest(message))
134
+ return false;
135
+ // Drop the trailing positional anchor so "add a testimonials section below the
136
+ // hero" counts one type (Testimonials), not two — otherwise the anchor's type
137
+ // (Hero) inflates the count and the request is misread as a multi-block batch
138
+ // that adds a phantom duplicate of the anchor (#18).
139
+ const body = stripPositionalAnchorPhrase(m);
140
+ const mentionedBlockTypes = countMentionedBlockTypes(body);
141
+ const hasListSeparator = /,|\band\b|&|\bplus\b/.test(body);
142
+ const refersToBlocks = /\b(?:blocks?|components?|sections?|elements?|widgets?)\b/.test(body);
143
+ return ADD_ACTION_PATTERN.test(m) && mentionedBlockTypes >= 2 && (hasListSeparator || refersToBlocks);
144
+ }
145
+ /**
146
+ * Detects batch remove requests — any remove/delete/clear that implies
147
+ * multiple blocks should be affected. Broadly matches so the LLM can
148
+ * handle the nuance (typos, "this one", etc.) without strict single-op limits.
149
+ */
150
+ export function isPageWideRewriteRequest(message) {
151
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
152
+ return PAGE_WIDE_REWRITE_PATTERNS.some((re) => re.test(m));
153
+ }
154
+ export function isBatchReorderRequest(message) {
155
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
156
+ return BATCH_REORDER_PATTERNS.some((re) => re.test(m));
157
+ }
158
+ export function isBatchRemoveRequest(message) {
159
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
160
+ const hasRemoveAction = /\b(?:remove|delete|clear)\b/.test(m);
161
+ if (!hasRemoveAction)
162
+ return false;
163
+ // Any "all/every/everything" + remove → batch
164
+ if (/\b(?:all|every|everything)\b/.test(m))
165
+ return true;
166
+ // "delete every section", "remove all blocks"
167
+ if (new RegExp(String.raw `\b(?:all|every)\s+${UNIT}\b`).test(m))
168
+ return true;
169
+ return false;
170
+ }
171
+ /**
172
+ * Detects duplicate/copy/clone requests targeting a block on the current
173
+ * page. The fast LLM intent router's schema has no "duplicate" action
174
+ * (only add|move|update|remove|info|clarify), so these requests must
175
+ * bypass the router and go straight to the full planner, which supports
176
+ * the `duplicate_block` op.
177
+ */
178
+ export function isDuplicateBlockRequest(message) {
179
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
180
+ // "duplicate X", "clone X", "copy X", "make a copy of X" — where X is any
181
+ // block reference. Guard against "copy" matching unrelated phrases like
182
+ // "the copy" (RichText body) by requiring a verb-y context.
183
+ if (/\b(?:duplicate|clone)\b/.test(m))
184
+ return true;
185
+ if (/\bmake\s+(?:a|another)\s+copy\b/.test(m))
186
+ return true;
187
+ // "copy the <block>", "copy this block" — verb use
188
+ if (/\bcopy\s+(?:this|that|the|a|an|another)\s+/.test(m))
189
+ return true;
190
+ return false;
191
+ }
192
+ /**
193
+ * Detects "duplicate this page and modify the content" patterns — duplicate /
194
+ * clone / copy a page combined with a modification verb (populate, fill,
195
+ * suggest, update, modify, change, adjust, replace, rewrite). Used to bypass
196
+ * `chatStrictPrimaryOpMode` so the planner can emit `duplicate_page` plus
197
+ * follow-on `update_props` / `add_block` / `remove_block` ops in one plan.
198
+ *
199
+ * Without this, strict mode truncates duplicate-and-modify plans to just the
200
+ * `duplicate_page` op, leaving the user with an unmodified clone.
201
+ */
202
+ export function isDuplicateAndModifyRequest(message) {
203
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
204
+ // Must mention a duplicate verb in a page context (not a block-level dup).
205
+ const duplicateVerb = /\b(?:duplicate|clone|copy)\b/.test(m);
206
+ if (!duplicateVerb)
207
+ return false;
208
+ // Page context: explicit "page", "this", "current", "selected", or a route mention.
209
+ const pageContext = /\b(?:page|this|current|selected)\b/.test(m) || /\/[a-z0-9/_-]+/i.test(m);
210
+ if (!pageContext)
211
+ return false;
212
+ // Modification verbs that imply additional ops beyond the bare duplicate.
213
+ const modifyVerb = /\b(?:populate|fill|suggest|propose|recommend|generate|write|draft|describe)\b/.test(m) ||
214
+ /\b(?:modify|adjust|tweak|customi[sz]e|tailor|adapt|repurpose|reskin|rebrand|refresh|refocus)\b/.test(m) ||
215
+ /\b(?:update|change|replace|rewrite|swap|edit|repopulate)\b/.test(m) ||
216
+ /\b(?:and\s+(?:then\s+)?)?(?:make|turn|convert|transform)\s+it\s+(?:into|about|for)\b/.test(m);
217
+ return modifyVerb;
218
+ }
219
+ /**
220
+ * Detects "show me a plan first" / "plan it before doing" phrasing — the user
221
+ * wants the planner to assemble ops but hold them behind the approval gate
222
+ * instead of auto-applying. Used by the chat pipeline to override
223
+ * `executionMode` to `plan_only` when the message asks for review-before-apply.
224
+ */
225
+ export function requestsPlanFirst(message) {
226
+ const m = normalizeForIntent(stripSiteContextEnvelope(message));
227
+ // "make a plan first", "plan it first", "draft a plan", "show me a plan",
228
+ // "propose a plan", "plan before applying", "plan before doing", "plan it before"
229
+ if (/\b(?:make|draft|propose|prepare|create|show)\s+(?:me\s+)?(?:a|the)?\s*plan\b/.test(m))
230
+ return true;
231
+ if (/\bplan\s+(?:it|this|first|before)\b/.test(m))
232
+ return true;
233
+ if (/\bbefore\s+(?:doing|applying|making|executing|building)\b.*\bplan\b/.test(m))
234
+ return true;
235
+ if (/\bplan\b.*\bbefore\s+(?:doing|applying|making|executing|building)\b/.test(m))
236
+ return true;
237
+ return false;
238
+ }
239
+ // "what blocks/sections are on this page?", "list the blocks on this page",
240
+ // "what's on this page?" — a read-only inventory of the CURRENT page's blocks.
241
+ // Distinct from the block CATALOG ("what blocks can I add?") and must never be
242
+ // planned as an edit (issue #20). The "(this|the|current) page" / "on this page"
243
+ // scope is what separates it from the catalog query.
244
+ // An edit instruction riding along with an inventory phrase — a mutation verb, a
245
+ // comparative ("bigger"), or a follow-on clause ("… then delete the last one", "…
246
+ // and rewrite them"). The loose `(list|show|describe) … sections … page` branch
247
+ // would otherwise swallow "show the hero section on this page bigger" as a
248
+ // read-only answer and silently drop the edit (#20 over-match). A pure inventory
249
+ // query carries none of these. Bias toward NOT classifying as info on a hit — the
250
+ // normal planner can still answer, but it won't drop an edit.
251
+ const PAGE_INVENTORY_EDIT_CUE = /\b(?:change|edit|update|rewrite|reword|rephrase|replace|swap|delete|remove|add|insert|move|reorder|rearrange|shorten|lengthen|expand|collapse|hide|reformat|restyle|then|instead)\b|\b(?:bigger|smaller|larger|shorter|longer|bolder|taller|wider|narrower)\b/;
252
+ export function isPageBlockListQuery(message) {
253
+ const m = normalizeForIntent(message);
254
+ if (PAGE_INVENTORY_EDIT_CUE.test(m))
255
+ return false;
256
+ return (/\b(?:what|which)\s+(?:blocks?|sections?|elements?)\s+(?:are|is|does|do|exist|live|sit)\b[^?]*\b(?:this|the|current)\s+page\b/.test(m) ||
257
+ /\b(?:list|show|tell me|describe|give me)\b[^?]*\b(?:blocks?|sections?|elements?)\b[^?]*\b(?:this|the|current)\s+page\b/.test(m) ||
258
+ /\bwhat(?:'s| is| are)\b[^?]*\bon\s+(?:this|the|current)\s+page\b/.test(m) ||
259
+ /\b(?:this|the|current)\s+page\b[^?]*\b(?:blocks?|sections?)\b[^?]*\b(?:have|has|contains?|includes?)\b/.test(m));
260
+ }
261
+ export function isInfoQuery(message) {
262
+ const m = normalizeForIntent(message);
263
+ return (BLOCK_CATALOG_PATTERNS.some((re) => re.test(m)) ||
264
+ isPageBlockListQuery(message) ||
265
+ /\bwhat\s+can\s+i\s+(change|edit)\b/.test(m) ||
266
+ /\bwhat\s+content\b/.test(m) ||
267
+ /\bcontent\s+elements?\b/.test(m) ||
268
+ /\b(which|what)\s+fields?\b/.test(m) ||
269
+ /\bwhat\s+prop(ertie)?s?\b/.test(m) ||
270
+ /\b(?:what|which)\s+(?:tabs?|items?|cards?|features?|links?|images?|pages?|columns?)\s+(?:do|does|can|are)\s+(?:we|i|you|it|this)\s+have\b/.test(m));
271
+ }
272
+ export function isPageListQuery(message) {
273
+ const m = normalizeForIntent(message);
274
+ // Exclude messages that contain page creation/modification verbs — those are action
275
+ // requests that happen to mention "the pages"/"all pages", not listing queries.
276
+ // (A bare "…on all pages" otherwise matches PAGE_LIST_PATTERNS and hijacks a cross-page
277
+ // edit into a read-only page list — see planner-quality issue #17A.)
278
+ if (/\b(?:add|create|generate|build|make|draft|update|link|remove|delete|change|set|replace|edit|rewrite|rename|move|swap|adjust|reorder|reorganize|restructure|translate)\b.*\bpages?\b/.test(m))
279
+ return false;
280
+ return PAGE_LIST_PATTERNS.some((re) => re.test(m));
281
+ }
282
+ /**
283
+ * True when the message asks for a mutation that spans the whole site rather
284
+ * than the current page ("update the CTA on all pages", "make the footer
285
+ * site-wide", "change X everywhere"). The single-page deterministic path can't
286
+ * honor this — it would silently narrow the edit to the current page — so the
287
+ * gate defers these to the LLM, whose ops carry pageSlug and can fan out across
288
+ * pages or clarify. See planner-quality issue #29.
289
+ */
290
+ export function hasCrossPageScope(message) {
291
+ return CROSS_PAGE_SCOPE_PATTERN.test(normalizeForIntent(stripSiteContextEnvelope(message)));
292
+ }
293
+ export function isContentQuery(message) {
294
+ const m = normalizeForIntent(message);
295
+ // Exclude messages with clear edit/mutation verbs
296
+ if (/\b(?:add|create|remove|delete|update|change|replace|move|rewrite|translate|rename)\b/.test(m))
297
+ return false;
298
+ return CONTENT_QUERY_PATTERNS.some((re) => re.test(m));
299
+ }
300
+ export function isAdviceQuery(message) {
301
+ const m = normalizeForIntent(message);
302
+ // Exclude structural action requests that happen to contain "should we/I"
303
+ if (/\b(reorder|reorganize|restructure|rearrange|sort|move)\b/.test(m) && /\bpages?\b/.test(m))
304
+ return false;
305
+ // Exclude explicit edit verbs — "review copy for clarity" is an edit, not advice
306
+ // But allow "should we/I add X?" phrasing — those are advice, not commands
307
+ if (!/\bshould\s+(we|i)\b/.test(m) && /\b(?:add|create|remove|delete|update|change|replace|move|rewrite|translate|rename)\b/.test(m))
308
+ return false;
309
+ return (/\b(is it good|is this good|should (we|i)|do you recommend|would you recommend)\b/.test(m) ||
310
+ /\bwhat do you think\b/.test(m) ||
311
+ /\bwhat (can|should) be improved\b/.test(m) ||
312
+ /\bhow can (this|the) page be improved\b/.test(m) ||
313
+ /\bhow (can|should) i improve (this|the) page\b/.test(m) ||
314
+ /\bimprovements?\b/.test(m) ||
315
+ /\bis faq\b/.test(m) ||
316
+ /\bshould .*faq\b/.test(m) ||
317
+ /\bgood idea\b/.test(m) ||
318
+ /\b(?:audit|review|check|inspect|analyze)\s+(?:the\s+)?(?:this\s+)?(?:page|site|content|copy|text)\b/.test(m));
319
+ }
320
+ export function adviceResponse(args) {
321
+ const { body, current, plannerSource, modelUsed, modelKey } = args;
322
+ const message = (body.message ?? "").toLowerCase();
323
+ const pageLabel = current.slug === "/" ? "this home page" : `this page (${current.slug})`;
324
+ const hasFaq = current.blocks.some((block) => block.type === "FAQAccordion");
325
+ const hasHero = current.blocks.some((block) => block.type === "Hero");
326
+ const hasCta = current.blocks.some((block) => block.type === "CTA");
327
+ if (/\bfaq\b/.test(message)) {
328
+ const summary = hasFaq
329
+ ? `Yes, FAQ can work on ${pageLabel}, but keep it concise and near the bottom so it supports decisions without distracting from the main content.`
330
+ : `FAQ is usually a good fit on ${pageLabel} when visitors may have objections (pricing, process, trust, support).`;
331
+ const changes = hasFaq
332
+ ? ["Current state: FAQ already exists on this page.", "Recommendation: keep 3-6 high-intent questions."]
333
+ : ["Current state: no FAQ block detected on this page.", "Recommendation: add a compact FAQ section near the bottom."];
334
+ return {
335
+ code: 200,
336
+ payload: {
337
+ status: "advice",
338
+ summary,
339
+ changes,
340
+ suggestions: hasFaq
341
+ ? ["Move FAQ to bottom", "Rewrite FAQ questions for this audience", "Keep FAQ, but reduce to 4 questions"]
342
+ : ["Add FAQ section with 4 questions at the bottom", "Add FAQ below testimonials", "Skip FAQ on this page"],
343
+ mentionedSlugs: [current.slug],
344
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
345
+ plannerSource,
346
+ modelUsed,
347
+ modelKey
348
+ }
349
+ };
350
+ }
351
+ // Analyze existing blocks on the page
352
+ const existingTypes = new Set(current.blocks.map((b) => b.type));
353
+ const presentList = current.blocks.map((b) => {
354
+ const meta = getBlockMeta(b.type);
355
+ return meta ? `${meta.displayName}` : b.type;
356
+ });
357
+ const missingTypes = allowedBlockTypes.filter((t) => !existingTypes.has(t));
358
+ const changes = [];
359
+ changes.push(`Current blocks (${current.blocks.length}): ${presentList.join(", ") || "none"}.`);
360
+ if (!hasHero)
361
+ changes.push("Missing: Hero — consider adding a headline section at the top.");
362
+ if (!hasCta)
363
+ changes.push("Missing: CTA — add a call-to-action to drive conversions.");
364
+ if (!existingTypes.has("Testimonials") && !existingTypes.has("Stats"))
365
+ changes.push("Missing: social proof (Testimonials or Stats) — builds trust.");
366
+ if (!existingTypes.has("FAQAccordion"))
367
+ changes.push("Missing: FAQ — addresses objections and improves SEO.");
368
+ // Build contextual suggestions based on what's actually missing
369
+ const suggestions = [];
370
+ const suggestionPriority = [
371
+ { type: "Hero", label: "Add a Hero section at the top" },
372
+ { type: "CTA", label: "Add a CTA section to drive conversions" },
373
+ { type: "Testimonials", label: "Add Testimonials for social proof" },
374
+ { type: "Stats", label: "Add Stats to highlight key numbers" },
375
+ { type: "FAQAccordion", label: "Add FAQ at the bottom" },
376
+ { type: "FeatureGrid", label: "Add a Feature Grid to list benefits" },
377
+ { type: "CardGrid", label: "Add a Card Grid for related content" },
378
+ { type: "ContactForm", label: "Add a Contact Form" },
379
+ { type: "Footer", label: "Add a Footer with links" }
380
+ ];
381
+ for (const item of suggestionPriority) {
382
+ if (!existingTypes.has(item.type) && suggestions.length < 4)
383
+ suggestions.push(item.label);
384
+ }
385
+ // If page has all common blocks, suggest content improvements based on actual props
386
+ if (suggestions.length === 0) {
387
+ for (const block of current.blocks) {
388
+ if (suggestions.length >= 4)
389
+ break;
390
+ const p = block.props;
391
+ if (!p)
392
+ continue;
393
+ if (block.type === "Hero") {
394
+ const heading = (p.heading ?? p.title ?? "");
395
+ if (!p.imageUrl && !p.backgroundImage) {
396
+ suggestions.push("Add a hero image");
397
+ }
398
+ else if (heading && heading.length > 60) {
399
+ suggestions.push("Shorten the hero headline to under 60 characters");
400
+ }
401
+ else if (heading) {
402
+ suggestions.push("Rewrite the hero headline to be more action-oriented");
403
+ }
404
+ }
405
+ if (block.type === "Stats") {
406
+ const items = (p.items ?? p.stats);
407
+ if (items?.some((it) => (it.label ?? it.description ?? "").length > 30)) {
408
+ suggestions.push("Rewrite the stats labels to be shorter and number-driven");
409
+ }
410
+ }
411
+ if (block.type === "CTA") {
412
+ const btn = (p.buttonText ?? p.ctaText ?? "");
413
+ if (btn.length > 20) {
414
+ suggestions.push("Shorten the CTA button text");
415
+ }
416
+ else {
417
+ suggestions.push("Strengthen the CTA copy to create urgency");
418
+ }
419
+ }
420
+ }
421
+ // Fallback if no content-specific suggestions were derived
422
+ if (suggestions.length === 0) {
423
+ if (hasHero)
424
+ suggestions.push("Rewrite the hero headline");
425
+ if (hasCta)
426
+ suggestions.push("Strengthen the CTA copy");
427
+ suggestions.push("Tighten the copy across all sections");
428
+ }
429
+ }
430
+ const summary = `For ${pageLabel} with ${current.blocks.length} block${current.blocks.length === 1 ? "" : "s"}: ${missingTypes.length > 0 ? `consider adding ${missingTypes.slice(0, 3).join(", ")}` : "all major sections are covered — focus on refining content"}.`;
431
+ return {
432
+ code: 200,
433
+ payload: {
434
+ status: "advice",
435
+ summary,
436
+ changes,
437
+ suggestions,
438
+ mentionedSlugs: [current.slug],
439
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
440
+ plannerSource,
441
+ modelUsed,
442
+ modelKey
443
+ }
444
+ };
445
+ }
446
+ // A pending clarification only contextualizes the answer that immediately follows it.
447
+ // One left unanswered for longer than this is stale (a prior session's leftover, or the
448
+ // user simply moved on) and must not be allowed to compose onto a later, unrelated
449
+ // message (#26).
450
+ const PENDING_CLARIFICATION_TTL_MS = 10 * 60 * 1000;
451
+ export function plannerMessageWithPendingContext(session, message) {
452
+ const pending = pendingClarificationBySession.get(session);
453
+ if (!pending)
454
+ return message;
455
+ const age = Date.now() - new Date(pending.updatedAt).getTime();
456
+ if (!Number.isFinite(age) || age > PENDING_CLARIFICATION_TTL_MS) {
457
+ pendingClarificationBySession.delete(session);
458
+ return message;
459
+ }
460
+ if (isStandalonePageOperation(message))
461
+ return message;
462
+ if (!isLikelyClarificationFollowUp(message))
463
+ return message;
464
+ return `${pending.baseRequest}\nClarification from user: ${message}`;
465
+ }
466
+ function parseJsonObjectMaybe(value) {
467
+ if (!value)
468
+ return null;
469
+ if (typeof value === "string") {
470
+ try {
471
+ const parsed = JSON.parse(value);
472
+ return parsed && typeof parsed === "object" ? parsed : null;
473
+ }
474
+ catch {
475
+ return null;
476
+ }
477
+ }
478
+ if (typeof value === "object")
479
+ return value;
480
+ return null;
481
+ }
482
+ function normalizeConstraintList(value) {
483
+ if (Array.isArray(value)) {
484
+ return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
485
+ }
486
+ if (typeof value === "string") {
487
+ return value
488
+ .split(/[,\n]/g)
489
+ .map((item) => item.trim())
490
+ .filter(Boolean);
491
+ }
492
+ return [];
493
+ }
494
+ /** Build the site context lines without wrapping in a message. Returns null if empty. */
495
+ export function buildSiteContextBlock(args) {
496
+ const businessContext = parseJsonObjectMaybe(args?.businessContext);
497
+ const siteContext = parseJsonObjectMaybe(args?.siteContext);
498
+ const purpose = (typeof siteContext?.purpose === "string" ? siteContext.purpose.trim() : "") ||
499
+ (typeof businessContext?.purpose === "string" ? businessContext.purpose.trim() : "") ||
500
+ (typeof args?.sitePurpose === "string" ? args.sitePurpose.trim() : "");
501
+ const tone = (typeof siteContext?.tone === "string" ? siteContext.tone.trim() : "") ||
502
+ (typeof businessContext?.tone === "string" ? businessContext.tone.trim() : "");
503
+ const constraints = [
504
+ ...normalizeConstraintList(siteContext?.constraints),
505
+ ...normalizeConstraintList(businessContext?.constraints)
506
+ ];
507
+ const siteName = typeof siteContext?.siteName === "string" ? siteContext.siteName.trim() : "";
508
+ const pageTemplates = Array.isArray(siteContext?.pageTemplates)
509
+ ? siteContext.pageTemplates
510
+ .filter((t) => typeof t?.name === "string" && typeof t?.description === "string" && t.name.trim() && t.description.trim())
511
+ .map((t) => `- ${t.name.trim()}: ${t.description.trim()}`)
512
+ : [];
513
+ const lines = [
514
+ purpose ? `Site purpose: ${purpose}` : null,
515
+ tone ? `Preferred tone: ${tone}` : null,
516
+ constraints.length > 0 ? `Constraints: ${constraints.join("; ")}` : null,
517
+ siteName ? `Site name: ${siteName}` : null,
518
+ pageTemplates.length > 0 ? `Page templates:\n${pageTemplates.join("\n")}` : null,
519
+ args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null
520
+ ].filter((line) => Boolean(line));
521
+ if (lines.length === 0)
522
+ return null;
523
+ return lines.join("\n");
524
+ }
525
+ export function withSiteContext(message, args) {
526
+ const block = buildSiteContextBlock(args);
527
+ if (!block)
528
+ return message;
529
+ return `${message}\n\n[site context]\n${block}\n[/site context]`;
530
+ }
531
+ export function stripSiteContextEnvelope(message) {
532
+ return message.replace(/\n?\[site context\][\s\S]*?\[\/site context\]\s*$/i, "").trim();
533
+ }
534
+ export function extractSiteContextLineValue(message, label) {
535
+ const lowerLabel = label.toLowerCase();
536
+ const section = message.match(/\[site context\]([\s\S]*?)\[\/site context\]/i)?.[1];
537
+ if (!section)
538
+ return undefined;
539
+ const line = section
540
+ .split("\n")
541
+ .map((item) => item.trim())
542
+ .find((item) => item.toLowerCase().startsWith(`${lowerLabel}:`));
543
+ if (!line)
544
+ return undefined;
545
+ const value = line.slice(line.indexOf(":") + 1).trim();
546
+ return value || undefined;
547
+ }
548
+ // ---------------------------------------------------------------------------
549
+ // Block prop helpers used by infoResponse
550
+ // ---------------------------------------------------------------------------
551
+ function editablePropsFromBlock(block) {
552
+ if (!block || !block.props || typeof block.props !== "object")
553
+ return [];
554
+ return Object.keys(block.props);
555
+ }
556
+ // A short headline for a block, for the "what's on this page?" inventory. Tries the
557
+ // common title-bearing props in priority order and truncates so the listing stays
558
+ // scannable.
559
+ function blockHeadingSnippet(block) {
560
+ const props = (block?.props ?? {});
561
+ for (const key of ["heading", "title", "headline", "eyebrow", "label", "quote", "text"]) {
562
+ const value = props[key];
563
+ if (typeof value === "string" && value.trim()) {
564
+ const text = value.trim().replace(/\s+/g, " ");
565
+ return text.length > 48 ? `${text.slice(0, 47).trimEnd()}…` : text;
566
+ }
567
+ }
568
+ return "";
569
+ }
570
+ function promptFromPropKey(propKey, blockType) {
571
+ const labels = {
572
+ heading: "Change heading to \"...\"",
573
+ subheading: "Change subheading to \"...\"",
574
+ ctaText: "Change CTA text to \"...\"",
575
+ ctaHref: "Change CTA link to \"/...\"",
576
+ imageUrl: "Update hero image (e.g. from Unsplash: cherries)",
577
+ imageAlt: "Change image alt text to \"...\"",
578
+ secondaryCtaText: "Add secondary CTA button \"...\"",
579
+ secondaryCtaHref: "Change secondary CTA link to \"/...\"",
580
+ body: "Edit body text to \"...\"",
581
+ title: "Change title to \"...\"",
582
+ description: "Change description to \"...\"",
583
+ features: "Update feature list",
584
+ items: "Update items",
585
+ cards: "Update cards"
586
+ };
587
+ if (labels[propKey])
588
+ return labels[propKey];
589
+ const human = blockType ? getPropDisplayName(blockType, propKey) : propKey;
590
+ return human === propKey ? `Change ${propKey} to "..."` : `Edit ${human.toLowerCase()}`;
591
+ }
592
+ const ORDINALS = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
593
+ function humanizeArrayPath(root) {
594
+ const match = root.match(/^([a-zA-Z_]+)\[(\d+)\]$/);
595
+ if (!match)
596
+ return root;
597
+ const [, listName, indexStr] = match;
598
+ const index = Number(indexStr);
599
+ const ordinal = ORDINALS[index] ?? `#${index + 1}`;
600
+ const singular = {
601
+ cards: "card",
602
+ features: "feature",
603
+ items: "item",
604
+ stats: "stat",
605
+ columns: "column"
606
+ };
607
+ const noun = singular[listName] ?? listName.replace(/s$/, "");
608
+ return `the ${ordinal} ${noun}`;
609
+ }
610
+ function childSuggestions(args) {
611
+ const { selected, editablePath } = args;
612
+ const path = editablePath.trim();
613
+ if (!path)
614
+ return [];
615
+ const root = path.split(".")[0] ?? path;
616
+ const human = humanizeArrayPath(root);
617
+ if (selected.type === "CardGrid" && root.startsWith("cards[")) {
618
+ return [
619
+ `Update ${human}'s title to \"...\"`,
620
+ `Update ${human}'s description to \"...\"`,
621
+ `Update ${human}'s CTA text to \"...\"`,
622
+ `Update ${human}'s CTA link to \"/...\"`
623
+ ];
624
+ }
625
+ if (selected.type === "FeatureGrid" && root.startsWith("features[")) {
626
+ return [`Update ${human}'s title to \"...\"`, `Update ${human}'s description to \"...\"`];
627
+ }
628
+ if (selected.type === "Testimonials" && root.startsWith("items[")) {
629
+ return [`Update ${human}'s quote to \"...\"`, `Update ${human}'s author to \"...\"`];
630
+ }
631
+ if (selected.type === "FAQAccordion" && root.startsWith("items[")) {
632
+ return [`Update ${human}'s question to \"...\"`, `Update ${human}'s answer to \"...\"`];
633
+ }
634
+ return [`Update ${human} ...`];
635
+ }
636
+ export function infoResponse(args) {
637
+ const { body, current, plannerSource, modelUsed, modelKey } = args;
638
+ // "what blocks are on this page?" — inventory the CURRENT page deterministically.
639
+ // Checked before the catalog branch so "list the blocks on this page" reports the
640
+ // page's blocks (not the available types) and always returns a non-empty answer (#20).
641
+ if (isPageBlockListQuery(body.message ?? "")) {
642
+ const blocks = current.blocks;
643
+ const pageName = current.title?.trim() || current.slug;
644
+ const lines = blocks.map((b, i) => {
645
+ const label = getBlockMeta(b.type)?.displayName ?? b.type;
646
+ const snippet = blockHeadingSnippet(b);
647
+ return snippet ? `${i + 1}. ${label} — “${snippet}”` : `${i + 1}. ${label}`;
648
+ });
649
+ return {
650
+ code: 200,
651
+ payload: {
652
+ status: "info",
653
+ summary: blocks.length === 0
654
+ ? `The ${pageName} page has no blocks yet.`
655
+ : `The ${pageName} page has ${blocks.length} ${blocks.length === 1 ? "block" : "blocks"} in this order:`,
656
+ changes: lines,
657
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
658
+ plannerSource,
659
+ modelUsed,
660
+ modelKey
661
+ }
662
+ };
663
+ }
664
+ if (isBlockCatalogQuery(body.message ?? "")) {
665
+ return {
666
+ code: 200,
667
+ payload: {
668
+ status: "info",
669
+ summary: `You can add these block types: ${allowedBlockTypes.join(", ")}.`,
670
+ changes: ["Tip: specify position, e.g. \u201cadd Testimonials below Hero\u201d."],
671
+ suggestions: [
672
+ "Add Testimonials below Hero",
673
+ "Add CardGrid at the end",
674
+ "Add FeatureGrid after Hero",
675
+ "Add FAQAccordion before CTA",
676
+ "Add CTA at the end"
677
+ ],
678
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
679
+ plannerSource,
680
+ modelUsed,
681
+ modelKey
682
+ }
683
+ };
684
+ }
685
+ const selected = body.activeBlockId && current.blocks.find((b) => b.id === body.activeBlockId)
686
+ ? current.blocks.find((b) => b.id === body.activeBlockId)
687
+ : null;
688
+ if (selected) {
689
+ const keys = editablePropsFromBlock(selected);
690
+ const childPath = String(body.activeEditablePath ?? "");
691
+ const suggestions = childPath ? childSuggestions({ selected, editablePath: childPath }) : keys.slice(0, 4).map((k) => promptFromPropKey(k, selected.type));
692
+ const humanChild = childPath ? humanizeArrayPath(childPath.split(".")[0] ?? childPath) : "";
693
+ const summary = childPath
694
+ ? `Focused on ${humanChild} in ${selected.type}.`
695
+ : `You can edit ${selected.type} fields: ${keys.join(", ")}.`;
696
+ return {
697
+ code: 200,
698
+ payload: {
699
+ status: "info",
700
+ summary,
701
+ changes: childPath
702
+ ? [`Selected block: ${selected.id}`, `Focused path: ${childPath}`]
703
+ : [`Selected block: ${selected.id}`, "Tip: click a field name in your prompt, e.g. \u201cchange heading to \u2026\u201d."],
704
+ suggestions,
705
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
706
+ plannerSource,
707
+ modelUsed,
708
+ modelKey
709
+ }
710
+ };
711
+ }
712
+ const firstByType = new Map();
713
+ for (const block of current.blocks) {
714
+ if (!firstByType.has(block.type))
715
+ firstByType.set(block.type, block);
716
+ }
717
+ const byType = Array.from(firstByType.values()).map((b) => `${b.type}: ${editablePropsFromBlock(b).join(", ")}`);
718
+ return {
719
+ code: 200,
720
+ payload: {
721
+ status: "info",
722
+ summary: "Select a block to get precise editable fields. Current page supports:",
723
+ changes: byType,
724
+ previewVersion: versions.get(body.session ?? "dev") ?? 0,
725
+ plannerSource,
726
+ modelUsed,
727
+ modelKey
728
+ }
729
+ };
730
+ }