@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,1537 @@
1
+ import { allowedBlockTypes, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
2
+ import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Op reordering: create_page must precede ops targeting the same slug
5
+ // ---------------------------------------------------------------------------
6
+ function reorderCreatePageFirst(ops) {
7
+ const createOps = [];
8
+ for (let i = 0; i < ops.length; i++) {
9
+ const op = ops[i];
10
+ if (!op || typeof op !== "object")
11
+ continue;
12
+ if (op.op !== "create_page")
13
+ continue;
14
+ const page = op.page;
15
+ const slug = typeof page?.slug === "string" ? page.slug : undefined;
16
+ if (slug)
17
+ createOps.push({ index: i, slug });
18
+ }
19
+ if (createOps.length === 0)
20
+ return ops;
21
+ const createSlugs = new Set(createOps.map((c) => c.slug));
22
+ const result = [];
23
+ const deferred = [];
24
+ for (let i = 0; i < ops.length; i++) {
25
+ const op = ops[i];
26
+ if (!op || typeof op !== "object") {
27
+ result.push(ops[i]);
28
+ continue;
29
+ }
30
+ if (op.op === "create_page") {
31
+ result.push(op);
32
+ // Flush any deferred ops that target this page
33
+ const page = op.page;
34
+ const slug = typeof page?.slug === "string" ? page.slug : undefined;
35
+ if (slug) {
36
+ const flushed = [];
37
+ for (let j = 0; j < deferred.length; j++) {
38
+ const d = deferred[j];
39
+ if (d.pageSlug === slug) {
40
+ result.push(d);
41
+ flushed.push(j);
42
+ }
43
+ }
44
+ for (let j = flushed.length - 1; j >= 0; j--)
45
+ deferred.splice(flushed[j], 1);
46
+ }
47
+ }
48
+ else if (typeof op.pageSlug === "string" && createSlugs.has(op.pageSlug) && !result.some((r) => {
49
+ const ro = r;
50
+ return ro && ro.op === "create_page" && ro.page?.slug === op.pageSlug;
51
+ })) {
52
+ // This op targets a page that will be created later — defer it
53
+ deferred.push(op);
54
+ }
55
+ else {
56
+ result.push(op);
57
+ }
58
+ }
59
+ // Append any remaining deferred ops (shouldn't happen if create_page exists)
60
+ result.push(...deferred);
61
+ return result;
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // JSON extraction & repair
65
+ // ---------------------------------------------------------------------------
66
+ export function extractJsonObject(input) {
67
+ const start = input.indexOf("{");
68
+ const end = input.lastIndexOf("}");
69
+ if (start === -1 || end === -1 || end <= start)
70
+ return null;
71
+ return input.slice(start, end + 1);
72
+ }
73
+ /**
74
+ * Attempt to repair common JSON malformations from LLM output:
75
+ * - Bare `-` used as markdown bullets inside arrays: `[- "item"]` → `["item"]`
76
+ * - Trailing commas before `]` or `}`
77
+ * Returns repaired string or original if no repair was needed.
78
+ */
79
+ export function repairJson(raw) {
80
+ let result = raw;
81
+ // Escape unescaped control characters (newlines, tabs, etc.) inside JSON
82
+ // string values. LLMs with eager_input_streaming often emit literal control
83
+ // chars in markdown-formatted text.
84
+ result = escapeControlCharsInStrings(result);
85
+ // Fix markdown bullets inside JSON arrays: [- "...", - "..."] → ["...", "..."]
86
+ // Match `- ` after `[` or `,` (with optional whitespace) when not inside a string
87
+ result = result.replace(/(\[|,)\s*-\s+(?=")/g, "$1 ");
88
+ // Fix trailing commas: [item,] or {key: value,}
89
+ result = result.replace(/,\s*([}\]])/g, "$1");
90
+ return result;
91
+ }
92
+ /**
93
+ * Escape unescaped control characters (newlines, tabs, etc.) that appear
94
+ * inside JSON string values. Walks character-by-character tracking string
95
+ * context so control chars outside strings are left untouched.
96
+ */
97
+ function escapeControlCharsInStrings(raw) {
98
+ const out = [];
99
+ let inString = false;
100
+ let escaped = false;
101
+ for (let i = 0; i < raw.length; i++) {
102
+ const code = raw.charCodeAt(i);
103
+ if (escaped) {
104
+ escaped = false;
105
+ out.push(raw[i]);
106
+ continue;
107
+ }
108
+ if (raw[i] === "\\") {
109
+ escaped = true;
110
+ out.push(raw[i]);
111
+ continue;
112
+ }
113
+ if (raw[i] === '"') {
114
+ inString = !inString;
115
+ out.push(raw[i]);
116
+ continue;
117
+ }
118
+ if (inString && code <= 0x1f) {
119
+ // Replace unescaped control characters inside strings
120
+ if (code === 0x0a) {
121
+ out.push("\\n");
122
+ continue;
123
+ }
124
+ if (code === 0x0d) {
125
+ out.push("\\r");
126
+ continue;
127
+ }
128
+ if (code === 0x09) {
129
+ out.push("\\t");
130
+ continue;
131
+ }
132
+ out.push("\\u" + code.toString(16).padStart(4, "0"));
133
+ continue;
134
+ }
135
+ out.push(raw[i]);
136
+ }
137
+ return out.join("");
138
+ }
139
+ /**
140
+ * Close unclosed braces, brackets, and strings in a truncated JSON string.
141
+ * Walks the string tracking nesting context and appends the necessary
142
+ * closing characters.
143
+ */
144
+ function closeOpenBrackets(input) {
145
+ const stack = [];
146
+ let inStr = false;
147
+ let esc = false;
148
+ for (const ch of input) {
149
+ if (esc) {
150
+ esc = false;
151
+ continue;
152
+ }
153
+ if (ch === "\\") {
154
+ esc = true;
155
+ continue;
156
+ }
157
+ if (ch === '"') {
158
+ inStr = !inStr;
159
+ continue;
160
+ }
161
+ if (inStr)
162
+ continue;
163
+ if (ch === "{")
164
+ stack.push("}");
165
+ else if (ch === "[")
166
+ stack.push("]");
167
+ else if (ch === "}" || ch === "]")
168
+ stack.pop();
169
+ }
170
+ let result = input;
171
+ if (inStr)
172
+ result += '"';
173
+ while (stack.length > 0)
174
+ result += stack.pop();
175
+ return result;
176
+ }
177
+ /**
178
+ * Attempt multiple JSON repair strategies. Returns the first one that
179
+ * produces parseable JSON along with metadata about what was done,
180
+ * or throws the original error.
181
+ */
182
+ export function repairAndParseJsonWithMeta(raw) {
183
+ // Strategy 1: basic repair (includes control char escaping + markdown bullet fix)
184
+ const basic = repairJson(raw);
185
+ try {
186
+ return { parsed: JSON.parse(basic), strategy: "basic_repair" };
187
+ }
188
+ catch { }
189
+ // Strategy 2: fix "no number after minus sign" by replacing bare `-`
190
+ // outside of string literals with `0`. Walk character by character to
191
+ // track whether we're inside a JSON string.
192
+ try {
193
+ const chars = [...basic];
194
+ let inString = false;
195
+ let escaped = false;
196
+ let mutations = 0;
197
+ for (let i = 0; i < chars.length; i++) {
198
+ if (escaped) {
199
+ escaped = false;
200
+ continue;
201
+ }
202
+ if (chars[i] === "\\") {
203
+ escaped = true;
204
+ continue;
205
+ }
206
+ if (chars[i] === '"') {
207
+ inString = !inString;
208
+ continue;
209
+ }
210
+ if (!inString && chars[i] === "-") {
211
+ const next = chars[i + 1];
212
+ if (next === undefined || !/\d/.test(next)) {
213
+ // Bare minus not followed by digit — replace with 0
214
+ chars[i] = "0";
215
+ mutations += 1;
216
+ }
217
+ }
218
+ }
219
+ return { parsed: JSON.parse(chars.join("")), strategy: "bare_minus_fix", mutationCount: mutations };
220
+ }
221
+ catch { }
222
+ // Strategy 3: truncated output — close open braces/brackets in correct order
223
+ try {
224
+ return { parsed: JSON.parse(closeOpenBrackets(basic)), strategy: "close_brackets" };
225
+ }
226
+ catch { }
227
+ // Strategy 4: truncate at last valid key-value boundary
228
+ // The model produced valid JSON for some fields but then broke into raw
229
+ // markdown (e.g., bare bullet lists instead of JSON arrays). Collect all
230
+ // comma positions outside strings, then try truncating at each from right
231
+ // to left until we get valid JSON.
232
+ try {
233
+ const commaPositions = [];
234
+ let inStr4 = false;
235
+ let esc4 = false;
236
+ for (let i = 0; i < basic.length; i++) {
237
+ if (esc4) {
238
+ esc4 = false;
239
+ continue;
240
+ }
241
+ if (basic[i] === "\\") {
242
+ esc4 = true;
243
+ continue;
244
+ }
245
+ if (basic[i] === '"') {
246
+ inStr4 = !inStr4;
247
+ continue;
248
+ }
249
+ if (!inStr4 && basic[i] === ",")
250
+ commaPositions.push(i);
251
+ }
252
+ // Try last 10 positions max — truncating further is unlikely to yield useful content
253
+ const startIdx = Math.max(0, commaPositions.length - 10);
254
+ for (let ci = commaPositions.length - 1; ci >= startIdx; ci--) {
255
+ const truncated = basic.slice(0, commaPositions[ci]);
256
+ try {
257
+ return { parsed: JSON.parse(closeOpenBrackets(truncated)), strategy: "truncate_at_boundary", discardedBytes: raw.length - commaPositions[ci] };
258
+ }
259
+ catch { }
260
+ }
261
+ }
262
+ catch { }
263
+ // All strategies failed — throw original parse error
264
+ return { parsed: JSON.parse(raw), strategy: "none" };
265
+ }
266
+ /**
267
+ * Attempt multiple JSON repair strategies. Returns the first one that
268
+ * produces parseable JSON, or throws the original error.
269
+ */
270
+ export function repairAndParseJson(raw) {
271
+ return repairAndParseJsonWithMeta(raw).parsed;
272
+ }
273
+ // ---------------------------------------------------------------------------
274
+ // Operation name normalisation
275
+ // ---------------------------------------------------------------------------
276
+ export function normalizeOpName(op) {
277
+ if (typeof op !== "string")
278
+ return op;
279
+ const key = op.toLowerCase().replace(/[\s-]/g, "_");
280
+ const aliases = {
281
+ create: "create_page",
282
+ create_page: "create_page",
283
+ createpage: "create_page",
284
+ add: "add_block",
285
+ add_block: "add_block",
286
+ addblock: "add_block",
287
+ insert_block: "add_block",
288
+ insertblock: "add_block",
289
+ update: "update_props",
290
+ update_props: "update_props",
291
+ updateprops: "update_props",
292
+ update_block: "update_props",
293
+ updateblock: "update_props",
294
+ edit_block: "update_props",
295
+ editblock: "update_props",
296
+ remove: "remove_block",
297
+ remove_block: "remove_block",
298
+ removeblock: "remove_block",
299
+ delete: "remove_block",
300
+ delete_block: "remove_block",
301
+ deleteblock: "remove_block",
302
+ move: "move_block",
303
+ move_block: "move_block",
304
+ moveblock: "move_block",
305
+ reorder_block: "move_block",
306
+ reorderblock: "move_block",
307
+ duplicate_block: "duplicate_block",
308
+ duplicateblock: "duplicate_block",
309
+ copy_block: "duplicate_block",
310
+ copyblock: "duplicate_block",
311
+ clone_block: "duplicate_block",
312
+ cloneblock: "duplicate_block",
313
+ add_item: "add_item",
314
+ additem: "add_item",
315
+ insert_item: "add_item",
316
+ insertitem: "add_item",
317
+ append_item: "add_item",
318
+ appenditem: "add_item",
319
+ update_item: "update_item",
320
+ updateitem: "update_item",
321
+ edit_item: "update_item",
322
+ edititem: "update_item",
323
+ remove_item: "remove_item",
324
+ removeitem: "remove_item",
325
+ delete_item: "remove_item",
326
+ deleteitem: "remove_item",
327
+ move_item: "move_item",
328
+ moveitem: "move_item",
329
+ reorder_item: "move_item",
330
+ reorderitem: "move_item",
331
+ // Plural reorders are the declarative whole-list/whole-page ops; the
332
+ // singular aliases above stay on move_item/move_block (one element moved).
333
+ reorder_items: "reorder_items",
334
+ reorderitems: "reorder_items",
335
+ sort_items: "reorder_items",
336
+ sortitems: "reorder_items",
337
+ reorder_list: "reorder_items",
338
+ reorderlist: "reorder_items",
339
+ reorder_blocks: "reorder_blocks",
340
+ reorderblocks: "reorder_blocks",
341
+ sort_blocks: "reorder_blocks",
342
+ sortblocks: "reorder_blocks",
343
+ reorder_sections: "reorder_blocks",
344
+ reordersections: "reorder_blocks",
345
+ move_page: "move_page",
346
+ movepage: "move_page",
347
+ reorder_page: "move_page",
348
+ reorderpage: "move_page",
349
+ duplicate_page: "duplicate_page",
350
+ duplicatepage: "duplicate_page",
351
+ copy_page: "duplicate_page",
352
+ copypage: "duplicate_page",
353
+ clone_page: "duplicate_page",
354
+ clonepage: "duplicate_page",
355
+ rename: "rename_page",
356
+ rename_page: "rename_page",
357
+ renamepage: "rename_page",
358
+ remove_page: "remove_page",
359
+ removepage: "remove_page",
360
+ delete_page: "remove_page",
361
+ deletepage: "remove_page",
362
+ update_site_config: "update_site_config",
363
+ updatesiteconfig: "update_site_config",
364
+ update_theme: "update_theme",
365
+ updatetheme: "update_theme",
366
+ set_theme: "update_theme",
367
+ update_styles: "update_theme",
368
+ update_page_meta: "update_page_meta",
369
+ updatepagemeta: "update_page_meta"
370
+ };
371
+ return aliases[key] ?? op;
372
+ }
373
+ // ---------------------------------------------------------------------------
374
+ // Slug / page-id helpers
375
+ // ---------------------------------------------------------------------------
376
+ export function pageIdFromSlug(slug) {
377
+ if (slug === "/")
378
+ return "p_home";
379
+ const core = slug
380
+ .slice(1)
381
+ .replace(/[^a-z0-9]+/gi, "_")
382
+ .replace(/^_+|_+$/g, "");
383
+ return `p_${core || "page"}`;
384
+ }
385
+ export function pageTitleFromSlug(slug) {
386
+ if (slug === "/")
387
+ return "Home";
388
+ const text = slug
389
+ .slice(1)
390
+ .split("/")
391
+ .filter(Boolean)
392
+ .map((part) => part.replace(/[-_]/g, " "))
393
+ .join(" ")
394
+ .trim();
395
+ if (!text)
396
+ return "Untitled Page";
397
+ return text
398
+ .split(/\s+/)
399
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
400
+ .join(" ");
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Block type inference
404
+ // ---------------------------------------------------------------------------
405
+ export function inferBlockTypeFromText(text) {
406
+ const normalized = text.toLowerCase();
407
+ if (normalized.includes("hero"))
408
+ return "Hero";
409
+ if (normalized.includes("featuregrid") || normalized.includes("feature grid") || normalized.includes("features") || /\bfeture/.test(normalized))
410
+ return "FeatureGrid";
411
+ if (normalized.includes("testimonial") || normalized.includes("testomonial") || normalized.includes("social proof") || normalized.includes("review") || (/\bquote\b/.test(normalized) && !normalized.includes("blockquote")))
412
+ return "Testimonials";
413
+ if (normalized.includes("faq"))
414
+ return "FAQAccordion";
415
+ if (normalized.includes("twocolumn") || normalized.includes("two column") || normalized.includes("2 column"))
416
+ return "TwoColumn";
417
+ if (normalized.includes("stats") || normalized.includes("statistics") || normalized.includes("metrics") || normalized.includes("numbers"))
418
+ return "Stats";
419
+ if (normalized.includes("cta") || normalized.includes("call-to-action") || normalized.includes("call to action"))
420
+ return "CTA";
421
+ if (normalized.includes("cardgrid") || normalized.includes("card grid") || normalized.includes("pricing"))
422
+ return "CardGrid";
423
+ if (normalized.includes("card"))
424
+ return "Card";
425
+ if (normalized.includes("richtext") || normalized.includes("rich text") || normalized.includes("rich-text") || normalized.includes("prose") || normalized.includes("text block") || normalized.includes("paragraph") || normalized.includes("copy"))
426
+ return "RichText";
427
+ if (normalized.includes("benefit") || normalized.includes("advantage"))
428
+ return "FeatureGrid";
429
+ if (normalized.includes("carousel") || normalized.includes("slideshow") || normalized.includes("slider"))
430
+ return "Carousel";
431
+ if (normalized.includes("gallery") || normalized.includes("image grid"))
432
+ return "Gallery";
433
+ if (normalized.includes("tabs") || normalized.includes("tabbed"))
434
+ return "Tabs";
435
+ if (normalized.includes("table"))
436
+ return "Table";
437
+ if (normalized.includes("blockquote"))
438
+ return "Quote";
439
+ if (normalized.includes("video"))
440
+ return "Video";
441
+ if (normalized.includes("embed") || normalized.includes("iframe") || normalized.includes("map"))
442
+ return "Embed";
443
+ if (normalized.includes("banner") || normalized.includes("announcement"))
444
+ return "Banner";
445
+ return undefined;
446
+ }
447
+ // ---------------------------------------------------------------------------
448
+ // Block ID generation
449
+ // ---------------------------------------------------------------------------
450
+ export function nextBlockId(type, page) {
451
+ const base = `b_${type.toLowerCase()}_${Date.now()}`;
452
+ if (!page.blocks.some((b) => b.id === base))
453
+ return base;
454
+ let i = 1;
455
+ while (page.blocks.some((b) => b.id === `${base}_${i}`))
456
+ i += 1;
457
+ return `${base}_${i}`;
458
+ }
459
+ // ---------------------------------------------------------------------------
460
+ // Default block props
461
+ // ---------------------------------------------------------------------------
462
+ export function defaultPropsForType(type) {
463
+ return sharedDefaultPropsForType(type);
464
+ }
465
+ // ---------------------------------------------------------------------------
466
+ // Patch helpers
467
+ // ---------------------------------------------------------------------------
468
+ export function patchObject(rawPatch) {
469
+ if (!rawPatch || typeof rawPatch !== "object" || Array.isArray(rawPatch))
470
+ return null;
471
+ if ("props" in rawPatch &&
472
+ rawPatch.props &&
473
+ typeof rawPatch.props === "object" &&
474
+ !Array.isArray(rawPatch.props)) {
475
+ return rawPatch.props;
476
+ }
477
+ return rawPatch;
478
+ }
479
+ function parseListItemPath(path) {
480
+ if (typeof path !== "string")
481
+ return null;
482
+ const trimmed = path.trim();
483
+ const match = trimmed.match(/^\/?([a-zA-Z0-9_]+)\[(\d+)\](?:\.[\w.-]+)?$/);
484
+ if (!match)
485
+ return null;
486
+ const listKey = match[1];
487
+ const index = Number(match[2]);
488
+ if (!listKey || !Number.isInteger(index) || index < 0)
489
+ return null;
490
+ return { listKey, index };
491
+ }
492
+ /** Look up the stable `id` of the list item at `index`, if it has one. */
493
+ function resolveItemIdAtIndex(listValue, index) {
494
+ if (!Array.isArray(listValue) || index < 0 || index >= listValue.length)
495
+ return undefined;
496
+ const entry = listValue[index];
497
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
498
+ return undefined;
499
+ const id = entry.id;
500
+ return typeof id === "string" && id ? id : undefined;
501
+ }
502
+ function inferPreferredListItemTextKey(allowedKeys) {
503
+ const lowered = new Map(allowedKeys.map((key) => [key.toLowerCase(), key]));
504
+ const preference = ["title", "label", "q", "quote", "heading", "description", "author", "value"];
505
+ for (const candidate of preference) {
506
+ const mapped = lowered.get(candidate);
507
+ if (mapped)
508
+ return mapped;
509
+ }
510
+ return allowedKeys[0];
511
+ }
512
+ function remapListItemPatchKeys(args) {
513
+ const patch = patchObject(args.patchRaw);
514
+ if (!patch || args.allowedKeys.length === 0)
515
+ return patch;
516
+ const loweredToAllowed = new Map();
517
+ for (const key of args.allowedKeys)
518
+ loweredToAllowed.set(key.toLowerCase(), key);
519
+ const preferredTextKey = inferPreferredListItemTextKey(args.allowedKeys);
520
+ const aliasToPreferred = new Set(["label", "name", "text", "heading", "headline"]);
521
+ const out = {};
522
+ for (const [rawKey, value] of Object.entries(patch)) {
523
+ const trimmed = rawKey.trim();
524
+ const lowered = trimmed.toLowerCase();
525
+ const mapped = loweredToAllowed.get(lowered) ??
526
+ (aliasToPreferred.has(lowered) ? preferredTextKey : undefined);
527
+ out[mapped ?? trimmed] = value;
528
+ }
529
+ return out;
530
+ }
531
+ // ---------------------------------------------------------------------------
532
+ // Plan candidate normalisation
533
+ // ---------------------------------------------------------------------------
534
+ export function normalizePlanCandidate(input, args) {
535
+ if (!input || typeof input !== "object")
536
+ return input;
537
+ const root = input;
538
+ const hasInlineOp = !Array.isArray(root.ops) && !Array.isArray(root.operations) && Boolean(root.op ?? root.operation ?? root.action ?? root.kind);
539
+ const ops = Array.isArray(root.ops) ? root.ops : Array.isArray(root.operations) ? root.operations : hasInlineOp ? [root] : [];
540
+ const userMessage = (args?.userMessage ?? "").toLowerCase();
541
+ const requestedRoute = firstRouteMention(args?.userMessage);
542
+ const routeMentions = extractRouteMentions(args?.userMessage);
543
+ const requestedCreateSlug = parseCreatePageRequest(args?.userMessage ?? "");
544
+ const createPageIntent = Boolean(requestedCreateSlug);
545
+ const refersToCurrentPage = /\b(this|current|selected|the)\s+page\b/.test(userMessage);
546
+ const resolvePageSlug = (candidate) => {
547
+ const normalized = normalizeRouteCandidate(candidate);
548
+ if (normalized)
549
+ return normalized;
550
+ if (args?.currentPage) {
551
+ if (typeof candidate !== "string")
552
+ return args?.defaultSlug;
553
+ if (candidate === args.currentPage.id)
554
+ return args.currentPage.slug;
555
+ if (candidate.toLowerCase() === "home" && args.currentPage.slug === "/")
556
+ return "/";
557
+ }
558
+ return args?.defaultSlug;
559
+ };
560
+ const beforeToAfter = (beforeId) => {
561
+ if (!args?.currentPage || typeof beforeId !== "string")
562
+ return undefined;
563
+ const idx = args.currentPage.blocks.findIndex((block) => block.id === beforeId);
564
+ if (idx <= 0)
565
+ return undefined;
566
+ return args.currentPage.blocks[idx - 1]?.id;
567
+ };
568
+ const usedBlockIds = new Set((args?.currentPage?.blocks ?? []).map((b) => b.id));
569
+ let createdPageSlug;
570
+ let droppedPageLevelUpdate = false;
571
+ // itemIds already claimed by a remove_item freeze in THIS plan — used to spot
572
+ // sequential-index plans (`remove(2), remove(2)`) and keep their raw indexes.
573
+ const frozenRemovedItemIds = new Set();
574
+ const normalizedOps = ops.flatMap((item) => {
575
+ if (!item || typeof item !== "object")
576
+ return item;
577
+ const source = item;
578
+ const raw = { ...source };
579
+ // Accept malformed one-key op objects like { "move_block": { ...fields } }.
580
+ if (!raw.op && !raw.operation && !raw.action && !raw.kind) {
581
+ for (const key of [
582
+ "create_page",
583
+ "add_block",
584
+ "update_props",
585
+ "remove_block",
586
+ "move_block",
587
+ "duplicate_block",
588
+ "add_item",
589
+ "update_item",
590
+ "remove_item",
591
+ "move_item",
592
+ "reorder_items",
593
+ "reorder_blocks",
594
+ "rename_page",
595
+ "remove_page",
596
+ "move_page",
597
+ "duplicate_page"
598
+ ]) {
599
+ const value = source[key];
600
+ if (value && typeof value === "object" && !Array.isArray(value)) {
601
+ Object.assign(raw, value);
602
+ raw.op = key;
603
+ break;
604
+ }
605
+ }
606
+ }
607
+ raw.op = normalizeOpName(raw.op ?? raw.operation ?? raw.action ?? raw.kind);
608
+ const rawType = raw.type ?? raw.blockType ?? raw.block_type ?? raw.newBlockType ?? raw.new_block_type ?? raw.target_block_type ?? raw.targetBlockType;
609
+ const normalizedType = typeof rawType === "string"
610
+ ? allowedBlockTypes.find((type) => type.toLowerCase() === rawType.toLowerCase()) ?? inferBlockTypeFromText(rawType)
611
+ : undefined;
612
+ const isListOperation = raw.op === "add_item" || raw.op === "update_item" || raw.op === "remove_item" || raw.op === "move_item" || raw.op === "reorder_items";
613
+ const pathLooksLikeListKey = typeof raw.path === "string" && !raw.path.startsWith("/");
614
+ const listPathParsed = parseListItemPath(raw.itemPath ?? raw.item_path ?? raw.itempath ?? raw.itemPtr ?? raw.item_ptr) ??
615
+ parseListItemPath(raw.path);
616
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.page_slug ?? raw.slug ?? raw.page ?? (isListOperation ? undefined : raw.path) ?? raw.route ?? raw.from);
617
+ raw.newPageSlug = normalizeRouteCandidate(raw.newPageSlug ?? raw.new_page_slug ?? raw.targetSlug ?? raw.target_slug ?? raw.toPageSlug ?? raw.to_page_slug ?? raw.to);
618
+ if (!raw.blockId) {
619
+ const pathCandidate = typeof raw.path === "string" && raw.path.startsWith("b_") ? raw.path : undefined;
620
+ raw.blockId =
621
+ raw.block_id ??
622
+ raw.targetBlockId ??
623
+ raw.target_block_id ??
624
+ raw.sourceBlockId ??
625
+ raw.source_block_id ??
626
+ raw.fromBlockId ??
627
+ raw.from_block_id ??
628
+ raw.id ??
629
+ pathCandidate;
630
+ }
631
+ // Infer blockId from currentPage when op needs it but model omitted it
632
+ if (!raw.blockId && args?.currentPage) {
633
+ const needsBlockId = raw.op === "update_props" || raw.op === "remove_block" || raw.op === "move_block" || raw.op === "duplicate_block";
634
+ if (needsBlockId) {
635
+ // Try type from explicit op fields first, then from user message keywords
636
+ const typeHint = normalizedType ?? inferBlockTypeFromText(userMessage);
637
+ if (typeHint) {
638
+ const matches = args.currentPage.blocks.filter((b) => b.type === typeHint);
639
+ if (matches.length === 1) {
640
+ raw.blockId = matches[0].id;
641
+ }
642
+ }
643
+ // Fallback: if patch keys clearly map to only one block's props
644
+ if (!raw.blockId && raw.op === "update_props") {
645
+ const patch = patchObject(raw.patch ?? raw.props ?? raw.changes);
646
+ if (patch) {
647
+ const patchKeys = Object.keys(patch);
648
+ const candidates = args.currentPage.blocks.filter((b) => {
649
+ const bProps = b.props;
650
+ if (!bProps)
651
+ return false;
652
+ return patchKeys.some((k) => k in bProps);
653
+ });
654
+ if (candidates.length === 1)
655
+ raw.blockId = candidates[0].id;
656
+ }
657
+ }
658
+ }
659
+ }
660
+ if (!raw.listKey) {
661
+ raw.listKey =
662
+ raw.list_key ??
663
+ raw.arrayKey ??
664
+ raw.array_key ??
665
+ raw.arrayProp ??
666
+ raw.array_prop ??
667
+ raw.collection ??
668
+ raw.itemsKey ??
669
+ raw.items_key ??
670
+ listPathParsed?.listKey;
671
+ if (!raw.listKey && isListOperation && pathLooksLikeListKey)
672
+ raw.listKey = raw.path;
673
+ if (!raw.listKey && isListOperation && typeof raw.path === "string") {
674
+ const keyCandidate = raw.path.trim().replace(/^\/+/, "");
675
+ if (keyCandidate && !keyCandidate.includes("/"))
676
+ raw.listKey = keyCandidate;
677
+ }
678
+ // Infer listKey from the target block's props when there is exactly one array field
679
+ if (!raw.listKey && isListOperation && typeof raw.blockId === "string" && args?.currentPage) {
680
+ const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
681
+ const targetProps = targetBlock?.props;
682
+ if (targetProps) {
683
+ const arrayKeys = Object.keys(targetProps).filter((k) => Array.isArray(targetProps[k]));
684
+ if (arrayKeys.length === 1)
685
+ raw.listKey = arrayKeys[0];
686
+ }
687
+ }
688
+ }
689
+ if (isListOperation && typeof raw.listKey === "string")
690
+ raw.listKey = raw.listKey.replace(/^\/+/, "");
691
+ if (isListOperation && typeof raw.listKey === "string" && typeof raw.pageSlug === "string" && raw.pageSlug === `/${raw.listKey}` && args?.defaultSlug) {
692
+ raw.pageSlug = args.defaultSlug;
693
+ }
694
+ if (typeof raw.index !== "number") {
695
+ const indexRaw = raw.index ?? raw.itemIndex ?? raw.item_index ?? raw.fromIndex ?? raw.from_index ?? listPathParsed?.index;
696
+ const normalizedIndex = typeof indexRaw === "string" ? Number(indexRaw) : indexRaw;
697
+ if (typeof normalizedIndex === "number" && Number.isFinite(normalizedIndex))
698
+ raw.index = Math.trunc(normalizedIndex);
699
+ }
700
+ if (typeof raw.afterIndex !== "number") {
701
+ const afterIndexRaw = raw.afterIndex ?? raw.after_index ?? raw.toIndex ?? raw.to_index ?? raw.targetIndex ?? raw.target_index;
702
+ const normalizedAfter = typeof afterIndexRaw === "string" ? Number(afterIndexRaw) : afterIndexRaw;
703
+ if (typeof normalizedAfter === "number" && Number.isFinite(normalizedAfter))
704
+ raw.afterIndex = Math.trunc(normalizedAfter);
705
+ }
706
+ // Stable item-id addressing (preferred over positional index). Accept common
707
+ // snake_case / alias spellings the LLM might emit.
708
+ if (typeof raw.itemId !== "string") {
709
+ const idRaw = raw.item_id ?? raw.targetItemId ?? raw.target_item_id;
710
+ if (typeof idRaw === "string" && idRaw)
711
+ raw.itemId = idRaw;
712
+ }
713
+ if (typeof raw.afterItemId !== "string") {
714
+ const afterIdRaw = raw.after_item_id ?? raw.afterId ?? raw.after_id;
715
+ if (typeof afterIdRaw === "string" && afterIdRaw)
716
+ raw.afterItemId = afterIdRaw;
717
+ }
718
+ // Freeze positional addressing into stable item ids at plan time. The planner
719
+ // reasons about indexes from get_page output, but a numeric index reindexes
720
+ // the moment an earlier op in the same plan mutates the list. Resolving
721
+ // index→itemId here — against the pre-mutation draft — makes compound plans
722
+ // ("delete the 2nd feature and reword the 4th") target the items the planner
723
+ // meant, not whatever later slides into those slots.
724
+ //
725
+ // Two guards:
726
+ // - pageSlug: only freeze ops that target the CURRENT page — cross-page ops
727
+ // (authorized since 573f644) may hit a same-named blockId/listKey on a
728
+ // different page, and resolving against the current page's list would
729
+ // freeze an id that doesn't exist over there.
730
+ // - sequential-remove dedupe: an LLM that emits `remove(2), remove(2)`
731
+ // means "remove two adjacent items" (post-mutation indexes). If a second
732
+ // remove_item would freeze to an id an earlier remove_item in this plan
733
+ // already claimed, skip the freeze and let the raw index apply
734
+ // sequentially instead of hard-failing on a double-delete.
735
+ const freezeTargetsCurrentPage = args?.currentPage && (typeof raw.pageSlug !== "string" || raw.pageSlug.length === 0 || raw.pageSlug === args.currentPage.slug);
736
+ if ((raw.op === "update_item" || raw.op === "remove_item" || raw.op === "move_item") &&
737
+ typeof raw.itemId !== "string" &&
738
+ typeof raw.index === "number" &&
739
+ typeof raw.blockId === "string" &&
740
+ typeof raw.listKey === "string" &&
741
+ freezeTargetsCurrentPage) {
742
+ const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
743
+ const listValue = targetBlock?.props?.[raw.listKey];
744
+ const frozen = resolveItemIdAtIndex(listValue, raw.index);
745
+ if (frozen) {
746
+ const claimKey = `${raw.blockId}|${raw.listKey}|${frozen}`;
747
+ if (raw.op === "remove_item" && frozenRemovedItemIds.has(claimKey)) {
748
+ // sequential-index semantics — leave the raw index in place
749
+ }
750
+ else {
751
+ if (raw.op === "remove_item")
752
+ frozenRemovedItemIds.add(claimKey);
753
+ raw.itemId = frozen;
754
+ }
755
+ }
756
+ }
757
+ if (raw.op === "move_item" &&
758
+ typeof raw.afterItemId !== "string" &&
759
+ typeof raw.afterIndex === "number" &&
760
+ typeof raw.blockId === "string" &&
761
+ typeof raw.listKey === "string" &&
762
+ freezeTargetsCurrentPage) {
763
+ const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
764
+ const listValue = targetBlock?.props?.[raw.listKey];
765
+ const frozen = resolveItemIdAtIndex(listValue, raw.afterIndex);
766
+ if (frozen)
767
+ raw.afterItemId = frozen;
768
+ }
769
+ // Declarative reorders: accept alias spellings of `order`, coerce
770
+ // digit-strings to numeric indexes for reorder_items, and freeze numeric
771
+ // entries to stable item ids against the current draft (same rationale and
772
+ // pageSlug gate as the item-op freeze above).
773
+ if (raw.op === "reorder_items" || raw.op === "reorder_blocks") {
774
+ if (!Array.isArray(raw.order)) {
775
+ const orderRaw = raw.new_order ?? raw.newOrder ?? raw.itemOrder ?? raw.item_order ?? raw.itemIds ?? raw.item_ids ??
776
+ raw.blockOrder ?? raw.block_order ?? raw.blockIds ?? raw.block_ids ?? raw.ids ?? raw.sequence;
777
+ if (Array.isArray(orderRaw))
778
+ raw.order = orderRaw;
779
+ }
780
+ if (raw.op === "reorder_items" && Array.isArray(raw.order)) {
781
+ raw.order = raw.order.map((entry) => typeof entry === "string" && /^\d+$/.test(entry.trim()) ? Number(entry.trim()) : entry);
782
+ if (typeof raw.blockId === "string" && typeof raw.listKey === "string" && freezeTargetsCurrentPage) {
783
+ const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
784
+ const listValue = targetBlock?.props?.[raw.listKey];
785
+ raw.order = raw.order.map((entry) => {
786
+ if (typeof entry !== "number")
787
+ return entry;
788
+ return resolveItemIdAtIndex(listValue, entry) ?? entry;
789
+ });
790
+ }
791
+ }
792
+ }
793
+ if (!raw.item) {
794
+ const sourceItem = raw.newItem ?? raw.new_item ?? raw.value;
795
+ if (sourceItem && typeof sourceItem === "object" && !Array.isArray(sourceItem))
796
+ raw.item = sourceItem;
797
+ }
798
+ if (raw.op === "add_item" && (!raw.item || typeof raw.item !== "object" || Array.isArray(raw.item))) {
799
+ const listKey = typeof raw.listKey === "string" ? raw.listKey.replace(/^\/+/, "") : "";
800
+ const blockId = typeof raw.blockId === "string" ? raw.blockId : "";
801
+ const currentBlock = blockId ? args?.currentPage?.blocks.find((block) => block.id === blockId) : undefined;
802
+ const currentProps = currentBlock?.props;
803
+ const listValue = listKey ? currentProps?.[listKey] : undefined;
804
+ const firstItem = Array.isArray(listValue) ? listValue.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : undefined;
805
+ if (firstItem) {
806
+ const cloned = structuredClone(firstItem);
807
+ delete cloned.id; // never reuse the template item's id — the engine mints a fresh one
808
+ raw.item = cloned;
809
+ }
810
+ else if (currentBlock?.type === "FAQAccordion") {
811
+ raw.item = { q: "New question", a: "New answer" };
812
+ }
813
+ else if (currentBlock?.type === "FeatureGrid") {
814
+ raw.item = { title: "New feature", description: "Feature description" };
815
+ }
816
+ else if (currentBlock?.type === "Testimonials") {
817
+ raw.item = { quote: "New testimonial", author: "Customer" };
818
+ }
819
+ else if (currentBlock?.type === "CardGrid") {
820
+ raw.item = { title: "New card", description: "Card description", ctaText: "Learn more", ctaHref: "/" };
821
+ }
822
+ }
823
+ if (!raw.newBlockId) {
824
+ raw.newBlockId = raw.new_block_id ?? raw.targetBlockId ?? raw.target_block_id ?? raw.copiedBlockId ?? raw.copied_block_id;
825
+ }
826
+ if (!raw.afterBlockId) {
827
+ raw.afterBlockId =
828
+ raw.after_block_id ?? raw.after ?? raw.insertAfterId ?? beforeToAfter(raw.beforeId ?? raw.insertBeforeId);
829
+ }
830
+ if (!raw.afterPageSlug) {
831
+ raw.afterPageSlug =
832
+ raw.afterPageSlug ??
833
+ raw.after_page_slug ??
834
+ raw.afterPage ??
835
+ raw.after_page ??
836
+ raw.anchorPageSlug ??
837
+ raw.anchor_page_slug ??
838
+ raw.after;
839
+ }
840
+ raw.afterPageSlug = resolvePageSlug(raw.afterPageSlug);
841
+ raw.beforePageSlug = resolvePageSlug(raw.beforePageSlug ?? raw.before_page_slug ?? raw.beforePage ?? raw.before_page);
842
+ if (!raw.patch) {
843
+ raw.patch = raw.props ?? raw.changes;
844
+ }
845
+ // LLMs sometimes put prop values directly on the op object instead of
846
+ // nesting them under "patch". Extract non-structural keys as the patch.
847
+ if (!raw.patch && raw.op === "update_props") {
848
+ const structuralKeys = new Set([
849
+ "op", "pageSlug", "page_slug", "blockId", "block_id", "patch", "props", "changes",
850
+ "type", "blockType", "block_type", "newBlockType", "new_block_type", "targetBlockType", "target_block_type",
851
+ "newPageSlug", "new_page_slug", "targetSlug", "target_slug", "toPageSlug", "to_page_slug",
852
+ "afterBlockId", "after_block_id", "afterPageSlug", "after_page_slug", "beforePageSlug", "before_page_slug",
853
+ "newBlockId", "new_block_id", "listKey", "list_key", "block",
854
+ "path", "index", "item", "afterIndex", "itemPath",
855
+ "itemId", "item_id", "afterItemId", "after_item_id", "order",
856
+ ]);
857
+ const extracted = {};
858
+ let found = false;
859
+ for (const key of Object.keys(raw)) {
860
+ if (!structuralKeys.has(key)) {
861
+ extracted[key] = raw[key];
862
+ found = true;
863
+ }
864
+ }
865
+ if (found)
866
+ raw.patch = extracted;
867
+ }
868
+ // Remap update_props patch keys: heading→title for non-Hero, question→q/answer→a in list items
869
+ if (raw.op === "update_props" && raw.patch && typeof raw.patch === "object" && !Array.isArray(raw.patch) && typeof raw.blockId === "string") {
870
+ const patch = raw.patch;
871
+ const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
872
+ const blockType = targetBlock?.type ?? "";
873
+ if (blockType !== "Hero" && "heading" in patch && !("title" in patch)) {
874
+ patch.title = patch.heading;
875
+ delete patch.heading;
876
+ }
877
+ const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
878
+ for (const [propKey, propVal] of Object.entries(patch)) {
879
+ if (!Array.isArray(propVal))
880
+ continue;
881
+ patch[propKey] = propVal.map((item) => {
882
+ if (!item || typeof item !== "object" || Array.isArray(item))
883
+ return item;
884
+ const entry = item;
885
+ let changed = false;
886
+ const mapped = {};
887
+ for (const [k, v] of Object.entries(entry)) {
888
+ const alias = itemKeyAliases[k.toLowerCase()];
889
+ if (alias && !(alias in entry)) {
890
+ mapped[alias] = v;
891
+ changed = true;
892
+ }
893
+ else {
894
+ mapped[k] = v;
895
+ }
896
+ }
897
+ return changed ? mapped : entry;
898
+ });
899
+ }
900
+ // Block-specific prop key remapping and type coercion (mirrors add_block path)
901
+ for (const k of ["autoplay", "loop", "striped"]) {
902
+ if (typeof patch[k] === "boolean")
903
+ patch[k] = patch[k] ? "true" : "false";
904
+ }
905
+ if (blockType === "Carousel" && Array.isArray(patch.slides) && !patch.items) {
906
+ patch.items = patch.slides;
907
+ delete patch.slides;
908
+ }
909
+ if (blockType === "Gallery") {
910
+ const alt = patch.photos ?? patch.pictures;
911
+ if (Array.isArray(alt) && !patch.images) {
912
+ patch.images = alt;
913
+ delete patch.photos;
914
+ delete patch.pictures;
915
+ }
916
+ if (typeof patch.columns === "number")
917
+ patch.columns = String(patch.columns);
918
+ }
919
+ if (blockType === "Table") {
920
+ if (Array.isArray(patch.columns) && !patch.headers) {
921
+ patch.headers = patch.columns;
922
+ delete patch.columns;
923
+ }
924
+ if (Array.isArray(patch.data) && !patch.rows) {
925
+ patch.rows = patch.data;
926
+ delete patch.data;
927
+ }
928
+ }
929
+ }
930
+ // Convert update_props with appended array items → add_item ops.
931
+ // When the LLM returns the full array (existing + new), extract only the new items as add_item ops
932
+ // to avoid overwriting existing items with potentially stale copies.
933
+ if (raw.op === "update_props" && typeof raw.blockId === "string" && raw.patch && typeof raw.patch === "object" && !Array.isArray(raw.patch) && args?.currentPage) {
934
+ const patch = raw.patch;
935
+ const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
936
+ if (targetBlock) {
937
+ const blockProps = targetBlock.props;
938
+ for (const [key, patchVal] of Object.entries(patch)) {
939
+ if (!Array.isArray(patchVal))
940
+ continue;
941
+ const existing = blockProps[key];
942
+ if (!Array.isArray(existing) || existing.length === 0)
943
+ continue;
944
+ if (patchVal.length <= existing.length)
945
+ continue;
946
+ // The patch array is longer — likely existing items + new appended items.
947
+ // Convert new tail items to add_item ops.
948
+ const newItems = patchVal.slice(existing.length);
949
+ if (newItems.length > 0 && newItems.every((it) => it && typeof it === "object" && !Array.isArray(it))) {
950
+ delete patch[key];
951
+ const slug = raw.pageSlug ?? args.defaultSlug ?? "/";
952
+ const addItemOps = newItems.map((item) => ({
953
+ op: "add_item",
954
+ pageSlug: slug,
955
+ blockId: raw.blockId,
956
+ listKey: key,
957
+ item
958
+ }));
959
+ // If patch has no remaining keys, replace this op entirely with add_item ops
960
+ if (Object.keys(patch).length === 0) {
961
+ return addItemOps;
962
+ }
963
+ // Otherwise keep the update_props for scalar changes and append add_item ops
964
+ return [raw, ...addItemOps];
965
+ }
966
+ }
967
+ }
968
+ }
969
+ if (raw.op === "update_item" && typeof raw.blockId === "string" && typeof raw.listKey === "string") {
970
+ const block = args?.currentPage?.blocks.find((candidate) => candidate.id === raw.blockId);
971
+ const blockProps = block?.props;
972
+ const list = blockProps?.[raw.listKey];
973
+ if (Array.isArray(list)) {
974
+ const index = typeof raw.index === "number" ? raw.index : -1;
975
+ const itemAtIndex = index >= 0 ? list[index] : undefined;
976
+ const referenceItem = itemAtIndex && typeof itemAtIndex === "object" && !Array.isArray(itemAtIndex)
977
+ ? itemAtIndex
978
+ : list.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry));
979
+ const allowedKeys = referenceItem ? Object.keys(referenceItem) : [];
980
+ const remapped = remapListItemPatchKeys({ patchRaw: raw.patch, allowedKeys });
981
+ if (remapped)
982
+ raw.patch = remapped;
983
+ }
984
+ }
985
+ if (raw.op === "update_props" &&
986
+ (!raw.blockId || typeof raw.blockId !== "string") &&
987
+ args?.defaultSlug) {
988
+ const patch = patchObject(raw.patch);
989
+ const newSlugFromPatch = normalizeRouteCandidate(patch?.slug ?? patch?.path ?? patch?.route);
990
+ const newSlugFromPath = typeof raw.path === "string" && raw.path.startsWith("/") ? normalizeRouteCandidate(raw.path) : null;
991
+ const fromSlugFromMentions = routeMentions[0];
992
+ const toSlugFromMentions = routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined;
993
+ const nextSlug = raw.newPageSlug ?? newSlugFromPatch ?? newSlugFromPath ?? toSlugFromMentions;
994
+ const fromSlug = resolvePageSlug(raw.pageSlug ?? raw.fromPageSlug ?? raw.from_page_slug ?? raw.oldSlug ?? fromSlugFromMentions);
995
+ if (fromSlug && nextSlug && fromSlug !== nextSlug) {
996
+ raw.op = "rename_page";
997
+ raw.pageSlug = fromSlug;
998
+ raw.newPageSlug = nextSlug;
999
+ delete raw.patch;
1000
+ }
1001
+ }
1002
+ if (raw.op === "remove_block" && (!raw.blockId || typeof raw.blockId !== "string")) {
1003
+ const asksDeletePage = /\b(delete|remove)\b.*\bpage\b/.test(userMessage);
1004
+ if (asksDeletePage) {
1005
+ raw.op = "remove_page";
1006
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
1007
+ }
1008
+ }
1009
+ // Convert remove_block → remove_item when user is removing a list item (e.g., "remove the first question")
1010
+ if (raw.op === "remove_block" && typeof raw.blockId === "string" && args?.currentPage) {
1011
+ const asksRemoveItem = /\b(question|item|faq|entry|testimonial|card|feature|first|second|third|last)\b/i.test(userMessage);
1012
+ if (asksRemoveItem) {
1013
+ const block = args.currentPage.blocks.find((b) => b.id === raw.blockId);
1014
+ if (block) {
1015
+ const bProps = block.props;
1016
+ if (bProps) {
1017
+ for (const [key, val] of Object.entries(bProps)) {
1018
+ if (!Array.isArray(val) || val.length === 0)
1019
+ continue;
1020
+ // Determine which index to remove
1021
+ let idx = 0;
1022
+ if (/\blast\b/i.test(userMessage))
1023
+ idx = val.length - 1;
1024
+ else if (/\bsecond\b/i.test(userMessage))
1025
+ idx = 1;
1026
+ else if (/\bthird\b/i.test(userMessage))
1027
+ idx = 2;
1028
+ raw.op = "remove_item";
1029
+ raw.listKey = key;
1030
+ raw.index = idx;
1031
+ break;
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ if (raw.op === "rename_page") {
1038
+ const nextSlug = raw.newPageSlug ??
1039
+ normalizeRouteCandidate(raw.path) ??
1040
+ normalizeRouteCandidate(raw.route) ??
1041
+ normalizeRouteCandidate(raw.slug) ??
1042
+ (routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined);
1043
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.fromPageSlug ?? raw.from_page_slug ?? raw.oldSlug ?? routeMentions[0]);
1044
+ raw.newPageSlug = nextSlug;
1045
+ if (!raw.newTitle && typeof raw.title === "string" && raw.title.trim().length > 0)
1046
+ raw.newTitle = raw.title.trim();
1047
+ if (typeof raw.pageSlug === "string" &&
1048
+ typeof raw.newPageSlug === "string" &&
1049
+ raw.pageSlug === raw.newPageSlug) {
1050
+ return null;
1051
+ }
1052
+ }
1053
+ if (raw.op === "remove_page") {
1054
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
1055
+ }
1056
+ if (raw.op === "move_page") {
1057
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
1058
+ if (!raw.afterPageSlug && raw.beforePageSlug && args?.currentPage) {
1059
+ if (raw.beforePageSlug === "/")
1060
+ raw.afterPageSlug = undefined;
1061
+ else if (raw.beforePageSlug === args.currentPage.slug)
1062
+ raw.afterPageSlug = undefined;
1063
+ }
1064
+ if (!raw.afterPageSlug && routeMentions.length >= 2) {
1065
+ const lower = userMessage;
1066
+ if (/\b(after|below|under)\b/.test(lower))
1067
+ raw.afterPageSlug = routeMentions[1];
1068
+ }
1069
+ }
1070
+ if (raw.op === "duplicate_page") {
1071
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? raw.from ?? routeMentions[0] ?? args?.defaultSlug);
1072
+ raw.newPageSlug = normalizeRouteCandidate(raw.newPageSlug ?? raw.new_page_slug ?? raw.targetSlug ?? raw.target_slug ?? raw.toPageSlug ?? raw.to_page_slug ?? raw.to);
1073
+ if (!raw.afterPageSlug && routeMentions.length >= 2) {
1074
+ const lower = userMessage;
1075
+ if (/\b(after|below|under)\b/.test(lower))
1076
+ raw.afterPageSlug = routeMentions[1];
1077
+ }
1078
+ }
1079
+ if (raw.op === "duplicate_block") {
1080
+ raw.toPageSlug = resolvePageSlug(raw.toPageSlug ?? raw.to_page_slug ?? raw.targetPageSlug ?? raw.target_page_slug ?? raw.newPageSlug ?? raw.new_page_slug);
1081
+ }
1082
+ if (!raw.block) {
1083
+ raw.block = raw.newBlock ?? raw.new_block;
1084
+ if (!raw.block && (raw.op === "add_block" || raw.op === "create_page") && normalizedType) {
1085
+ let generatedId = typeof raw.blockId === "string" && raw.blockId.length > 0
1086
+ ? raw.blockId
1087
+ : `b_${String(normalizedType).toLowerCase()}_${Date.now()}`;
1088
+ // Dedup against both existing page blocks and earlier ops in this batch
1089
+ let suffix = 0;
1090
+ while (usedBlockIds.has(generatedId)) {
1091
+ suffix++;
1092
+ generatedId = `b_${String(normalizedType).toLowerCase()}_${Date.now()}_${suffix}`;
1093
+ }
1094
+ usedBlockIds.add(generatedId);
1095
+ const incomingPatch = patchObject(raw.props ?? raw.patch ?? raw.changes) ?? {};
1096
+ raw.block = {
1097
+ id: generatedId,
1098
+ type: normalizedType,
1099
+ props: { ...defaultPropsForType(normalizedType), ...incomingPatch }
1100
+ };
1101
+ }
1102
+ }
1103
+ if (raw.op === "add_block" && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
1104
+ const block = raw.block;
1105
+ if (typeof block.type === "string") {
1106
+ const fixed = allowedBlockTypes.find((t) => t.toLowerCase() === block.type.toLowerCase()) ?? inferBlockTypeFromText(block.type);
1107
+ if (fixed)
1108
+ block.type = fixed;
1109
+ }
1110
+ else if (normalizedType) {
1111
+ block.type = normalizedType;
1112
+ }
1113
+ if (!block.props || typeof block.props !== "object" || Array.isArray(block.props)) {
1114
+ if (raw.patch || raw.props || raw.changes) {
1115
+ block.props = patchObject(raw.patch ?? raw.props ?? raw.changes) ?? {};
1116
+ }
1117
+ else if (typeof block.type === "string") {
1118
+ // LLM omitted props entirely — seed with schema defaults so validation passes
1119
+ block.props = defaultPropsForType(block.type);
1120
+ }
1121
+ else {
1122
+ block.props = {};
1123
+ }
1124
+ }
1125
+ if ((!block.id || typeof block.id !== "string") && typeof block.type === "string") {
1126
+ let fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}`;
1127
+ let sfx = 0;
1128
+ while (usedBlockIds.has(fallbackId)) {
1129
+ sfx++;
1130
+ fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}_${sfx}`;
1131
+ }
1132
+ usedBlockIds.add(fallbackId);
1133
+ block.id = fallbackId;
1134
+ }
1135
+ raw.block = block;
1136
+ // Remap heading→title for non-Hero blocks (LLMs often confuse heading/title)
1137
+ if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1138
+ const bProps = block.props;
1139
+ const blockType = typeof block.type === "string" ? block.type : "";
1140
+ if (blockType !== "Hero" && "heading" in bProps && !("title" in bProps)) {
1141
+ bProps.title = bProps.heading;
1142
+ delete bProps.heading;
1143
+ }
1144
+ }
1145
+ // Remap list item keys inside add_block props (e.g., question→q, answer→a)
1146
+ if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1147
+ const bProps = block.props;
1148
+ const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
1149
+ for (const [propKey, propVal] of Object.entries(bProps)) {
1150
+ if (!Array.isArray(propVal))
1151
+ continue;
1152
+ bProps[propKey] = propVal.map((item) => {
1153
+ if (!item || typeof item !== "object" || Array.isArray(item))
1154
+ return item;
1155
+ const entry = item;
1156
+ let changed = false;
1157
+ const mapped = {};
1158
+ for (const [k, v] of Object.entries(entry)) {
1159
+ const alias = itemKeyAliases[k.toLowerCase()];
1160
+ if (alias && !(alias in entry)) {
1161
+ mapped[alias] = v;
1162
+ changed = true;
1163
+ }
1164
+ else {
1165
+ mapped[k] = v;
1166
+ }
1167
+ }
1168
+ return changed ? mapped : entry;
1169
+ });
1170
+ }
1171
+ }
1172
+ // Block-specific prop key remapping and type coercion
1173
+ if (typeof block.type === "string" && block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1174
+ const cProps = block.props;
1175
+ const bt = block.type;
1176
+ // Coerce boolean→string for "true"/"false" enum props (Carousel, Video, Table)
1177
+ for (const k of ["autoplay", "loop", "striped"]) {
1178
+ if (typeof cProps[k] === "boolean")
1179
+ cProps[k] = cProps[k] ? "true" : "false";
1180
+ }
1181
+ // Carousel: slides→items
1182
+ if (bt === "Carousel" && Array.isArray(cProps.slides) && !cProps.items) {
1183
+ cProps.items = cProps.slides;
1184
+ delete cProps.slides;
1185
+ }
1186
+ // Gallery: photos/pictures→images, numeric columns→string
1187
+ if (bt === "Gallery") {
1188
+ const alt = cProps.photos ?? cProps.pictures;
1189
+ if (Array.isArray(alt) && !cProps.images) {
1190
+ cProps.images = alt;
1191
+ delete cProps.photos;
1192
+ delete cProps.pictures;
1193
+ }
1194
+ if (typeof cProps.columns === "number")
1195
+ cProps.columns = String(cProps.columns);
1196
+ }
1197
+ // Table: columns→headers, data→rows
1198
+ if (bt === "Table") {
1199
+ if (Array.isArray(cProps.columns) && !cProps.headers) {
1200
+ cProps.headers = cProps.columns;
1201
+ delete cProps.columns;
1202
+ }
1203
+ if (Array.isArray(cProps.data) && !cProps.rows) {
1204
+ cProps.rows = cProps.data;
1205
+ delete cProps.data;
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ // Convert add_block → add_item when targeting an existing single-instance block with list items
1211
+ if (raw.op === "add_block" && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block) && args?.currentPage) {
1212
+ const block = raw.block;
1213
+ const blockType = typeof block.type === "string" ? block.type : undefined;
1214
+ if (blockType) {
1215
+ const existingBlocks = args.currentPage.blocks.filter((b) => b.type === blockType);
1216
+ if (existingBlocks.length === 1) {
1217
+ const existing = existingBlocks[0];
1218
+ const existingProps = existing.props;
1219
+ const newProps = block.props;
1220
+ if (existingProps && newProps) {
1221
+ for (const [key, val] of Object.entries(existingProps)) {
1222
+ if (!Array.isArray(val))
1223
+ continue;
1224
+ const newVal = newProps[key];
1225
+ if (!Array.isArray(newVal) || newVal.length === 0)
1226
+ continue;
1227
+ const newItem = newVal[0];
1228
+ if (newItem && typeof newItem === "object" && !Array.isArray(newItem)) {
1229
+ raw.op = "add_item";
1230
+ raw.blockId = existing.id;
1231
+ raw.listKey = key;
1232
+ raw.item = newItem;
1233
+ raw.pageSlug = raw.pageSlug ?? args?.defaultSlug;
1234
+ delete raw.block;
1235
+ break;
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ const createSlugCandidate = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? requestedRoute);
1243
+ const explicitCreateTarget = createSlugCandidate && createSlugCandidate !== args?.defaultSlug;
1244
+ // If user asked to create a page and model emitted add_block on a new route, synthesize create_page.
1245
+ if (raw.op === "add_block" && createPageIntent && explicitCreateTarget && !createdPageSlug) {
1246
+ const createSlug = createSlugCandidate ?? requestedRoute ?? args?.defaultSlug ?? "/";
1247
+ const nowIso = new Date().toISOString();
1248
+ let firstBlock = null;
1249
+ if (raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
1250
+ const block = raw.block;
1251
+ const typeRaw = typeof block.type === "string" ? block.type : normalizedType;
1252
+ const blockType = typeof typeRaw === "string"
1253
+ ? allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw)
1254
+ : undefined;
1255
+ if (blockType) {
1256
+ const id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
1257
+ const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
1258
+ ? { ...defaultPropsForType(blockType), ...block.props }
1259
+ : defaultPropsForType(blockType);
1260
+ firstBlock = { id, type: blockType, props };
1261
+ }
1262
+ }
1263
+ if (!firstBlock) {
1264
+ firstBlock = {
1265
+ id: `b_hero_${Date.now()}`,
1266
+ type: "Hero",
1267
+ props: defaultPropsForType("Hero")
1268
+ };
1269
+ }
1270
+ raw.op = "create_page";
1271
+ raw.page = {
1272
+ id: pageIdFromSlug(createSlug),
1273
+ slug: createSlug,
1274
+ title: pageTitleFromSlug(createSlug),
1275
+ updatedAt: nowIso,
1276
+ blocks: [firstBlock]
1277
+ };
1278
+ raw.pageSlug = createSlug;
1279
+ createdPageSlug = createSlug;
1280
+ return raw;
1281
+ }
1282
+ // LLMs sometimes emit create_page when they actually mean add_block.
1283
+ if (raw.op === "create_page" &&
1284
+ !raw.page &&
1285
+ !explicitCreateTarget &&
1286
+ (raw.block || normalizedType || raw.blockId || raw.patch || raw.props)) {
1287
+ raw.op = "add_block";
1288
+ raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? args?.defaultSlug);
1289
+ }
1290
+ // LLMs also emit create_page with blocks[] for existing pages. Convert to add_block sequence.
1291
+ // But only when targeting the current page — if it's an explicit new route, let it fall
1292
+ // through to the create_page handler that synthesizes a proper PageDoc.
1293
+ if (raw.op === "create_page" &&
1294
+ !raw.page &&
1295
+ !explicitCreateTarget &&
1296
+ Array.isArray(raw.blocks) &&
1297
+ raw.blocks.length > 0) {
1298
+ const pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? args?.defaultSlug) ?? args?.defaultSlug;
1299
+ if (!pageSlug)
1300
+ return raw;
1301
+ const out = [];
1302
+ let previousId;
1303
+ // Track blocks as we add them so nextBlockId generates unique IDs
1304
+ let pageSnapshot = args?.currentPage;
1305
+ for (const candidate of raw.blocks) {
1306
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
1307
+ continue;
1308
+ const block = { ...candidate };
1309
+ const typeRaw = typeof block.type === "string" ? block.type : "";
1310
+ const blockType = allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw);
1311
+ if (!blockType)
1312
+ continue;
1313
+ if (typeof block.id !== "string" || block.id.length === 0) {
1314
+ block.id = pageSnapshot ? nextBlockId(blockType, pageSnapshot) : `b_${blockType.toLowerCase()}_${Date.now()}`;
1315
+ }
1316
+ if (!block.props || typeof block.props !== "object" || Array.isArray(block.props)) {
1317
+ block.props = defaultPropsForType(blockType);
1318
+ }
1319
+ const addOp = {
1320
+ op: "add_block",
1321
+ pageSlug,
1322
+ block
1323
+ };
1324
+ if (previousId)
1325
+ addOp.afterBlockId = previousId;
1326
+ previousId = block.id;
1327
+ // Update snapshot so the next nextBlockId sees this block's ID
1328
+ if (pageSnapshot) {
1329
+ pageSnapshot = { ...pageSnapshot, blocks: [...pageSnapshot.blocks, { id: block.id, type: blockType, props: block.props }] };
1330
+ }
1331
+ out.push(addOp);
1332
+ }
1333
+ return out.length > 0 ? out : raw;
1334
+ }
1335
+ // Accept lightweight or partial create_page operations and synthesize a valid PageDoc payload.
1336
+ if (raw.op === "create_page") {
1337
+ const pageInput = raw.page && typeof raw.page === "object" && !Array.isArray(raw.page) ? raw.page : {};
1338
+ const pageSlugInput = pageInput.slug ?? raw.pageSlug ?? raw.page_slug ?? raw.path ?? raw.slug ?? raw.route ?? requestedRoute ?? args?.defaultSlug ?? "/";
1339
+ const slug = resolvePageSlug(pageSlugInput) ?? requestedRoute ?? args?.defaultSlug ?? "/";
1340
+ const nowIso = new Date().toISOString();
1341
+ const blocks = [];
1342
+ const shouldTreatAsCurrentPageEdit = !requestedCreateSlug && refersToCurrentPage && !!args?.defaultSlug && slug === args.defaultSlug;
1343
+ // Blocks may be inside pageInput.blocks (page wrapper) or raw.blocks (flat format).
1344
+ const blocksSource = Array.isArray(pageInput.blocks) ? pageInput.blocks : Array.isArray(raw.blocks) ? raw.blocks : null;
1345
+ if (blocksSource) {
1346
+ const usedIds = new Set();
1347
+ for (const candidate of blocksSource) {
1348
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
1349
+ continue;
1350
+ const block = candidate;
1351
+ const typeRaw = typeof block.type === "string" ? block.type : "";
1352
+ const blockType = allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw);
1353
+ if (!blockType)
1354
+ continue;
1355
+ let id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
1356
+ let suffix = 1;
1357
+ while (usedIds.has(id)) {
1358
+ id = `b_${blockType.toLowerCase()}_${Date.now()}_${suffix++}`;
1359
+ }
1360
+ usedIds.add(id);
1361
+ const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
1362
+ ? { ...defaultPropsForType(blockType), ...block.props }
1363
+ : defaultPropsForType(blockType);
1364
+ blocks.push({ id, type: blockType, props });
1365
+ }
1366
+ }
1367
+ if (shouldTreatAsCurrentPageEdit && blocks.length > 0) {
1368
+ let previousId;
1369
+ const out = [];
1370
+ for (const block of blocks) {
1371
+ const addOp = { op: "add_block", pageSlug: slug, block };
1372
+ if (previousId)
1373
+ addOp.afterBlockId = previousId;
1374
+ previousId = block.id;
1375
+ out.push(addOp);
1376
+ }
1377
+ return out;
1378
+ }
1379
+ if (blocks.length === 0 && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
1380
+ const block = { ...raw.block };
1381
+ const typeRaw = typeof block.type === "string" ? block.type : normalizedType;
1382
+ const blockType = typeof typeRaw === "string"
1383
+ ? allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw)
1384
+ : undefined;
1385
+ if (blockType) {
1386
+ const id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
1387
+ const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
1388
+ ? { ...defaultPropsForType(blockType), ...block.props }
1389
+ : defaultPropsForType(blockType);
1390
+ blocks.push({ id, type: blockType, props });
1391
+ }
1392
+ }
1393
+ if (shouldTreatAsCurrentPageEdit && blocks.length > 0) {
1394
+ raw.op = "add_block";
1395
+ raw.pageSlug = slug;
1396
+ raw.block = blocks[0];
1397
+ delete raw.page;
1398
+ delete raw.page_slug;
1399
+ delete raw.slug;
1400
+ delete raw.path;
1401
+ return raw;
1402
+ }
1403
+ raw.page = {
1404
+ id: typeof pageInput.id === "string" && pageInput.id.trim().length > 0 ? pageInput.id.trim() : pageIdFromSlug(slug),
1405
+ slug,
1406
+ title: typeof pageInput.title === "string" && pageInput.title.trim().length > 0 ? pageInput.title.trim() : pageTitleFromSlug(slug),
1407
+ updatedAt: typeof pageInput.updatedAt === "string" && pageInput.updatedAt.trim().length > 0 ? pageInput.updatedAt.trim() : nowIso,
1408
+ blocks
1409
+ };
1410
+ raw.pageSlug = slug;
1411
+ createdPageSlug = slug;
1412
+ }
1413
+ // If model mixes create_page + add_block and keeps add_block on the current route, move it to the new route.
1414
+ if (raw.op === "add_block" && createPageIntent && createdPageSlug && raw.pageSlug === args?.defaultSlug) {
1415
+ raw.pageSlug = createdPageSlug;
1416
+ }
1417
+ // Intent repair: if user asked for bottom/end and model omitted an anchor, place at end.
1418
+ if ((raw.op === "move_block" || raw.op === "add_block") &&
1419
+ !raw.afterBlockId &&
1420
+ args?.currentPage &&
1421
+ (userMessage.includes("bottom") || userMessage.includes("end") || userMessage.includes("last"))) {
1422
+ const movingId = typeof raw.blockId === "string"
1423
+ ? raw.blockId
1424
+ : raw.op === "add_block" && raw.block && typeof raw.block === "object" && typeof raw.block.id === "string"
1425
+ ? raw.block.id
1426
+ : undefined;
1427
+ const tail = [...args.currentPage.blocks].reverse().find((b) => b.id !== movingId);
1428
+ if (tail)
1429
+ raw.afterBlockId = tail.id;
1430
+ }
1431
+ // Remap add_item item keys to match existing schema (e.g., question→q, answer→a)
1432
+ if (raw.op === "add_item" && raw.item && typeof raw.item === "object" && !Array.isArray(raw.item) && typeof raw.blockId === "string") {
1433
+ const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
1434
+ if (targetBlock) {
1435
+ const bProps = targetBlock.props;
1436
+ const lk = typeof raw.listKey === "string" ? raw.listKey : undefined;
1437
+ const list = lk ? bProps?.[lk] : undefined;
1438
+ const refItem = Array.isArray(list)
1439
+ ? list.find((e) => e && typeof e === "object" && !Array.isArray(e))
1440
+ : undefined;
1441
+ if (refItem) {
1442
+ const allowedKeys = Object.keys(refItem);
1443
+ const remapped = remapListItemPatchKeys({ patchRaw: raw.item, allowedKeys });
1444
+ if (remapped) {
1445
+ // Semantic aliases for common key alternatives
1446
+ const extraAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
1447
+ const allowedSet = new Set(allowedKeys);
1448
+ const final = {};
1449
+ for (const [k, v] of Object.entries(remapped)) {
1450
+ const alias = extraAliases[k.toLowerCase()];
1451
+ if (alias && allowedSet.has(alias) && !(alias in remapped)) {
1452
+ final[alias] = v;
1453
+ }
1454
+ else {
1455
+ final[k] = v;
1456
+ }
1457
+ }
1458
+ raw.item = final;
1459
+ }
1460
+ }
1461
+ }
1462
+ }
1463
+ return raw;
1464
+ });
1465
+ const sanitizedOps = normalizedOps.filter((item) => {
1466
+ if (!item)
1467
+ return false;
1468
+ if (typeof item !== "object" || Array.isArray(item))
1469
+ return true;
1470
+ const raw = item;
1471
+ if (normalizeOpName(raw.op) !== "update_props")
1472
+ return true;
1473
+ if (typeof raw.blockId === "string" && raw.blockId.length > 0)
1474
+ return true;
1475
+ const patch = patchObject(raw.patch);
1476
+ const hasPageLevelPatch = !!patch &&
1477
+ (typeof patch.slug === "string" || typeof patch.path === "string" || typeof patch.route === "string" || typeof patch.title === "string");
1478
+ const pathLooksLikeRoute = typeof raw.path === "string" && raw.path.startsWith("/");
1479
+ if (hasPageLevelPatch || pathLooksLikeRoute) {
1480
+ droppedPageLevelUpdate = true;
1481
+ return false;
1482
+ }
1483
+ return false;
1484
+ });
1485
+ if (droppedPageLevelUpdate && sanitizedOps.length === 0) {
1486
+ return {
1487
+ ...root,
1488
+ intent: "needs_clarification",
1489
+ summary_for_user: "I could not infer a valid page operation. Specify the source and target routes explicitly.",
1490
+ change_log: [
1491
+ "Ignored an invalid page-level update_props operation that was missing blockId.",
1492
+ "Try: rename page from /old to /new, or delete page /path."
1493
+ ],
1494
+ ops: []
1495
+ };
1496
+ }
1497
+ // Reorder ops so create_page always precedes ops targeting the same slug.
1498
+ // The LLM may emit add_block ops before the corresponding create_page.
1499
+ const reorderedOps = reorderCreatePageFirst(sanitizedOps);
1500
+ // Ensure required EditPlan fields exist — smaller models sometimes omit them
1501
+ if (!root.intent) {
1502
+ root.intent = reorderedOps.length > 0 ? "edit_plan" : "needs_clarification";
1503
+ }
1504
+ if (!root.summary_for_user && typeof root.summary === "string") {
1505
+ root.summary_for_user = root.summary; // common model alias
1506
+ }
1507
+ if (typeof root.suggested_next_actions === "string") {
1508
+ root.suggested_next_actions = /^\s*\[\s*\]\s*$/.test(root.suggested_next_actions)
1509
+ ? []
1510
+ : root.suggested_next_actions
1511
+ .split(/\n|[,;]/)
1512
+ .map((s) => s.replace(/^\s*[-•*\d.)\]]+\s*/, "").trim())
1513
+ .filter((s) => /\w/.test(s));
1514
+ }
1515
+ if (typeof root.change_log === "string") {
1516
+ // Models sometimes emit the literal string "[]" (or "[ ]") for an empty
1517
+ // change log; treat that as an empty array instead of a one-item "[]" list.
1518
+ root.change_log = /^\s*\[\s*\]\s*$/.test(root.change_log)
1519
+ ? []
1520
+ : root.change_log
1521
+ .split(/\n/)
1522
+ .map((s) => s.replace(/^\s*[-•*\d.)\]]+\s*/, "").trim())
1523
+ .filter((s) => /\w/.test(s));
1524
+ }
1525
+ if (!Array.isArray(root.change_log)) {
1526
+ root.change_log = reorderedOps.map((op) => {
1527
+ const verb = op.op?.replace(/_/g, " ") ?? "modify";
1528
+ return `${verb} on ${op.pageSlug ?? "page"}`;
1529
+ });
1530
+ }
1531
+ if (hasInlineOp) {
1532
+ if (reorderedOps.length === 1)
1533
+ return reorderedOps[0];
1534
+ return { ...root, ops: reorderedOps };
1535
+ }
1536
+ return { ...root, ops: reorderedOps };
1537
+ }