@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,3999 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { blockManifestSchema } from "@avocadostudio-ai/shared";
3
+ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
4
+ import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
5
+ import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
6
+ import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
+ import { toErrorDetail, isNoEffectiveChangeError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
8
+ import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
9
+ import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
10
+ import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
11
+ import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
12
+ import { isCancelError as _isCancelError } from "../errors.js";
13
+ // `isCancelError` is used directly (without underscore) in runChatPipeline
14
+ const isCancelError = _isCancelError;
15
+ import { isMultiStepCandidate, decomposeRequest } from "./decomposer.js";
16
+ import { generatePlanWithAnthropic, parseIntentWithAnthropic } from "./anthropic-planner.js";
17
+ import { generatePlanWithGemini, parseIntentWithGemini } from "./gemini-planner.js";
18
+ import { createPlannerRegistry, budgetTokensToEffort } from "./planner-types.js";
19
+ import { estimateUsd } from "../telemetry/usage.js";
20
+ import { executeToolCall } from "../tools/runtime.js";
21
+ // ---------------------------------------------------------------------------
22
+ // Re-exports from extracted modules (for backwards compat with external importers)
23
+ // ---------------------------------------------------------------------------
24
+ export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.js";
25
+ export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
26
+ export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
27
+ export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
28
+ export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.js";
29
+ export { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, resolveEffectiveSlug, CancelError, isCancelError, throwIfCanceled, raceCancel, sseWrite, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
30
+ // Internal imports from extracted modules (used by this file)
31
+ import { collectMentionedSlugsFromPlan, normalizePlanCopyForUi, futureToPastTense, pastToFutureTense } from "./chat-pipeline-ui.js";
32
+ import { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
33
+ import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
34
+ import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
35
+ import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
36
+ import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource } from "./provider-routing.js";
37
+ import { runVariationPipeline, getCachedVariations } from "./variation-pipeline.js";
38
+ import { validateAndStripHallucinatedProps } from "./hallucination-validator.js";
39
+ import { validateChangelogCoverage } from "./changelog-coverage-validator.js";
40
+ import { generateAltTextFromVision, parseAltPathForOp } from "./vision-alt-generator.js";
41
+ /**
42
+ * Whether the CURRENT message plausibly depends on prior conversation turns — an
43
+ * anaphoric or very terse follow-up ("make it bigger", "undo", "do that again").
44
+ * Self-contained requests that name their own target ("shorten the hero heading")
45
+ * do NOT, so they should plan from a clean slate rather than inherit stale, unrelated
46
+ * history that bleeds into summaries/clarifications (#25).
47
+ */
48
+ function messageDependsOnHistory(message) {
49
+ const m = message.toLowerCase().trim();
50
+ if (!m)
51
+ return false;
52
+ if (m.split(/\s+/).length <= 2)
53
+ return true; // "bigger", "undo that" — terse follow-up
54
+ return /\b(it|its|it's|that|this|those|these|them|they|same|again|also|too|as well|instead|previous(?:ly)?|earlier|undo|redo|revert)\b/.test(m);
55
+ }
56
+ /**
57
+ * Parses an image-source chip answer ("Use Unsplash photo", "Generate with AI",
58
+ * "Either's fine — pick for me") and returns the matching preference, or null
59
+ * when the message isn't a chip reply. Matching is loose (plain-text containment)
60
+ * so typed-out variants still resolve.
61
+ */
62
+ function parseImageSourceChoiceFromMessage(message) {
63
+ if (typeof message !== "string")
64
+ return null;
65
+ const normalized = message.toLowerCase().replace(/\s+/g, " ").trim();
66
+ if (!normalized)
67
+ return null;
68
+ // "Either's fine" must take precedence — otherwise a message that mentions
69
+ // both sources ("I'll let you pick between unsplash or AI") would resolve
70
+ // ambiguously.
71
+ if (/\beither\b/.test(normalized) && /\b(fine|pick|choose|you\s+decide)\b/.test(normalized))
72
+ return "either";
73
+ if (/\buse\s+unsplash\b/.test(normalized) || /^unsplash(?:\s+photo)?$/.test(normalized))
74
+ return "unsplash";
75
+ if (/\bgenerate\s+with\s+ai\b/.test(normalized) || /^(?:ai|ai[-\s]?generated)$/.test(normalized))
76
+ return "genai";
77
+ return null;
78
+ }
79
+ /**
80
+ * Appends a source hint token ("unsplash" / "generate image") to the planner
81
+ * message IFF the user's message doesn't already name a source. Avoids
82
+ * contradicting an explicit prompt like "Generate an AI image of X" when the
83
+ * session preference happens to be Unsplash.
84
+ *
85
+ * Exported for unit tests.
86
+ */
87
+ export function applyImageSourceHint(plannerMessage, sanitizedMessage, preference) {
88
+ if (!preference || preference === "either")
89
+ return plannerMessage;
90
+ const hasExplicit = /\b(unsplash|stock\s+photo|stock\s+image|royalty[-\s]?free)\b/i.test(sanitizedMessage) ||
91
+ /\b(generate|generated|ai[-\s]?generated|ai\s+image|ai\s+photo|dall[-\s]?e|midjourney|stable\s+diffusion)\b/i.test(sanitizedMessage) ||
92
+ /\bwith\s+ai\b/i.test(sanitizedMessage);
93
+ if (hasExplicit)
94
+ return plannerMessage;
95
+ if (preference === "unsplash")
96
+ return `${plannerMessage} unsplash`;
97
+ if (preference === "genai")
98
+ return `${plannerMessage} generate image`;
99
+ return plannerMessage;
100
+ }
101
+ /**
102
+ * Quick-action prompts the inline AI wand on imageAlt fields sends ("Generate
103
+ * alt text", "Improve accessibility", "Make more descriptive", "Describe this
104
+ * image"). When one of these arrives with an alt-text field active and a
105
+ * companion image URL on disk, we must route to the full vision-aware planner —
106
+ * the fast JSON-only intent router can't see images.
107
+ */
108
+ const VISION_ALT_PROMPT_RE = /\b(generate\s+alt(?:\s+text)?|improve\s+accessibility|make\s+(?:it\s+|this\s+)?more\s+descriptive|describe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration)|write\s+alt(?:\s+text)?)\b/i;
109
+ export function isVisionAltGenerationRequest(args) {
110
+ if (!args.message || !args.activeEditablePath || !args.activeBlockId)
111
+ return false;
112
+ if (!/(^|[.])imageAlt$/.test(args.activeEditablePath) && !/(^|[.])alt$/.test(args.activeEditablePath))
113
+ return false;
114
+ if (!VISION_ALT_PROMPT_RE.test(args.message))
115
+ return false;
116
+ const block = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
117
+ if (!block)
118
+ return false;
119
+ return Boolean(resolveImageUrlForAltField(block.props, args.activeEditablePath));
120
+ }
121
+ /**
122
+ * Image fields that, when they are the ONLY fields touched in an update_props
123
+ * patch, make the op a pure "image update" — safe to auto-apply via the deferred
124
+ * image resolution path (shimmer placeholder → real URL streamed in over SSE).
125
+ *
126
+ * Compound plans or plans that touch text fields alongside images keep the
127
+ * approval gate so the user can still review text copy before it lands.
128
+ */
129
+ const IMAGE_ONLY_PATCH_FIELDS = new Set(["imageUrl", "imageAlt", "ogImage", "logoUrl"]);
130
+ /**
131
+ * True when every op in the plan is an `update_props` whose patch touches
132
+ * only image-related fields (imageUrl/imageAlt/ogImage/logoUrl). Such plans
133
+ * should skip the approval gate — image resolution is deterministic work.
134
+ *
135
+ * Exported for unit tests.
136
+ */
137
+ export function isImageOnlyUpdatePropsPlan(plan) {
138
+ if (plan.intent !== "edit_plan")
139
+ return false;
140
+ if (!Array.isArray(plan.ops) || plan.ops.length === 0)
141
+ return false;
142
+ return plan.ops.every((op) => {
143
+ if (op.op !== "update_props")
144
+ return false;
145
+ const rawPatch = op.patch;
146
+ if (!rawPatch || typeof rawPatch !== "object")
147
+ return false;
148
+ const patch = typeof rawPatch.props === "object" &&
149
+ rawPatch.props !== null &&
150
+ !Array.isArray(rawPatch.props)
151
+ ? (rawPatch.props)
152
+ : rawPatch;
153
+ const keys = Object.keys(patch);
154
+ if (keys.length === 0)
155
+ return false;
156
+ return keys.every((k) => IMAGE_ONLY_PATCH_FIELDS.has(k));
157
+ });
158
+ }
159
+ let generatePlanWithOpenAIImpl = generatePlanWithOpenAI;
160
+ export function setGeneratePlanWithOpenAIForTests(fn) {
161
+ generatePlanWithOpenAIImpl = fn ?? generatePlanWithOpenAI;
162
+ }
163
+ let generatePlanWithAnthropicImpl = generatePlanWithAnthropic;
164
+ export function setGeneratePlanWithAnthropicForTests(fn) {
165
+ generatePlanWithAnthropicImpl = fn ?? generatePlanWithAnthropic;
166
+ }
167
+ let demoPlanFromMessageImpl = demoPlanFromMessage;
168
+ export function setDemoPlanFromMessageForTests(fn) {
169
+ demoPlanFromMessageImpl = fn ?? demoPlanFromMessage;
170
+ }
171
+ let parseIntentWithOpenAIImpl = parseIntentWithOpenAI;
172
+ export function setParseIntentWithOpenAIForTests(fn) {
173
+ parseIntentWithOpenAIImpl = fn ?? parseIntentWithOpenAI;
174
+ }
175
+ let parseIntentWithAnthropicImpl = parseIntentWithAnthropic;
176
+ export function setParseIntentWithAnthropicForTests(fn) {
177
+ parseIntentWithAnthropicImpl = fn ?? parseIntentWithAnthropic;
178
+ }
179
+ let generatePlanWithGeminiImpl = generatePlanWithGemini;
180
+ export function setGeneratePlanWithGeminiForTests(fn) {
181
+ generatePlanWithGeminiImpl = fn ?? generatePlanWithGemini;
182
+ }
183
+ let parseIntentWithGeminiImpl = parseIntentWithGemini;
184
+ export function setParseIntentWithGeminiForTests(fn) {
185
+ parseIntentWithGeminiImpl = fn ?? parseIntentWithGemini;
186
+ }
187
+ // ---------------------------------------------------------------------------
188
+ // Main chat pipeline
189
+ // ---------------------------------------------------------------------------
190
+ export async function runChatPipeline(ctx, body, options) {
191
+ let executionMode = body.executionMode ?? "auto";
192
+ const requiresMessage = executionMode === "auto" || executionMode === "plan_only";
193
+ if (!body.session || !body.slug || (requiresMessage && !body.message)) {
194
+ return { code: 400, payload: { error: "session and slug are required; message is required for planning" } };
195
+ }
196
+ // An explicit "make a plan first" / "plan it before doing" request now APPLIES
197
+ // the plan to the draft as a revertible soft draft instead of holding it
198
+ // uncommitted: the user reviews the whole page in the live preview and Keeps or
199
+ // Reverts (one-step undo) inline in chat. Scoped to the typed phrase so the
200
+ // editor's complexity-based plan_only (and destructive ops) still hold behind
201
+ // the Apply/Discard gate. Keys off the message regardless of executionMode, so
202
+ // it works whether the editor sent "auto" or "plan_only".
203
+ const planFirstApplyAsDraft = !!body.message && requestsPlanFirst(body.message);
204
+ const manifestPayload = (() => {
205
+ if (!body.componentsManifest)
206
+ return undefined;
207
+ if (typeof body.componentsManifest !== "string")
208
+ return body.componentsManifest;
209
+ try {
210
+ return JSON.parse(body.componentsManifest);
211
+ }
212
+ catch {
213
+ return "__invalid_json__";
214
+ }
215
+ })();
216
+ const parsedManifest = manifestPayload === "__invalid_json__"
217
+ ? { success: false }
218
+ : manifestPayload
219
+ ? blockManifestSchema.safeParse(manifestPayload)
220
+ : { success: true, data: undefined };
221
+ if (!parsedManifest.success) {
222
+ return { code: 400, payload: { error: "invalid componentsManifest payload" } };
223
+ }
224
+ const componentsManifest = parsedManifest.data;
225
+ const capabilitiesPayload = (() => {
226
+ if (!body.siteCapabilities)
227
+ return undefined;
228
+ if (typeof body.siteCapabilities !== "string")
229
+ return body.siteCapabilities;
230
+ try {
231
+ return JSON.parse(body.siteCapabilities);
232
+ }
233
+ catch {
234
+ return "__invalid_json__";
235
+ }
236
+ })();
237
+ const parsedCapabilities = capabilitiesPayload === "__invalid_json__"
238
+ ? { success: false }
239
+ : capabilitiesPayload
240
+ ? siteCapabilitiesSchema.safeParse(capabilitiesPayload)
241
+ : { success: true, data: undefined };
242
+ if (!parsedCapabilities.success) {
243
+ return { code: 400, payload: { error: "invalid siteCapabilities payload" } };
244
+ }
245
+ const siteCapabilities = parsedCapabilities.data;
246
+ if (executionMode === "discard_pending_plan") {
247
+ const existing = pendingApprovalPlanBySession.get(body.session);
248
+ if (!existing) {
249
+ const defaultProvider = ctx.availableProviders[0] ?? "openai";
250
+ const defaultModelKey = process.env.OPENAI_MODEL_KEY ?? "balanced";
251
+ return {
252
+ code: 200,
253
+ payload: {
254
+ status: "canceled",
255
+ summary: "No pending plan to stop.",
256
+ changes: [],
257
+ previewVersion: versions.get(body.session) ?? 0,
258
+ plannerSource: ctx.availableProviders.length > 0 ? ctx.availableProviders[0] : "demo",
259
+ modelUsed: ctx.modelLookup[defaultProvider][defaultModelKey],
260
+ modelKey: defaultModelKey
261
+ }
262
+ };
263
+ }
264
+ if (body.pendingPlanId && body.pendingPlanId !== existing.id) {
265
+ return { code: 409, payload: { error: "pending plan mismatch" } };
266
+ }
267
+ pendingApprovalPlanBySession.delete(body.session);
268
+ return {
269
+ code: 200,
270
+ payload: {
271
+ status: "canceled",
272
+ summary: "Stopped. The pending plan was discarded and will not be executed.",
273
+ changes: [],
274
+ previewVersion: versions.get(body.session) ?? 0,
275
+ plannerSource: existing.source,
276
+ modelUsed: existing.modelUsed,
277
+ modelKey: existing.modelKey
278
+ }
279
+ };
280
+ }
281
+ if (executionMode === "continue_chain") {
282
+ const chain = continuationChainBySession.get(body.session);
283
+ if (!chain) {
284
+ return { code: 409, payload: { error: "no continuation chain found for this session" } };
285
+ }
286
+ if (body.continuationChainId && body.continuationChainId !== chain.id) {
287
+ return { code: 409, payload: { error: "continuation chain id mismatch" } };
288
+ }
289
+ if (chain.currentStep >= chain.totalSteps) {
290
+ continuationChainBySession.delete(body.session);
291
+ return { code: 409, payload: { error: "continuation chain already completed" } };
292
+ }
293
+ // Execute the next step as a normal chat message
294
+ throwIfCanceled(options?.signal);
295
+ const stepMessage = chain.steps[chain.currentStep];
296
+ chain.currentStep++;
297
+ const isLastStep = chain.currentStep >= chain.totalSteps;
298
+ const result = await runChatPipeline(ctx, {
299
+ ...body,
300
+ message: stepMessage,
301
+ slug: chain.effectiveSlug,
302
+ executionMode: "auto"
303
+ }, options);
304
+ // Attach continuation info if more steps remain
305
+ if (!isLastStep && result.code === 200 && "status" in result.payload && result.payload.status !== "error") {
306
+ const payload = result.payload;
307
+ payload.continuation = {
308
+ chainId: chain.id,
309
+ currentStep: chain.currentStep + 1,
310
+ totalSteps: chain.totalSteps,
311
+ nextStepLabel: chain.stepLabels[chain.currentStep]
312
+ };
313
+ }
314
+ if (isLastStep) {
315
+ continuationChainBySession.delete(body.session);
316
+ }
317
+ return result;
318
+ }
319
+ const sanitizedMessage = sanitizeMessageForPlanning(body.message ?? "");
320
+ const pageDirectory = buildPageDirectory(body.session);
321
+ const siteContextBlock = buildSiteContextBlock({
322
+ sitePurpose: body.sitePurpose,
323
+ siteHosting: body.siteHosting,
324
+ businessContext: body.businessContext,
325
+ siteContext: body.siteContext,
326
+ pageDirectory: pageDirectory || undefined
327
+ });
328
+ // Site context is now passed to the LLM system prompt (cacheable) instead of the user message.
329
+ let plannerMessage = plannerMessageWithPendingContext(body.session, sanitizedMessage);
330
+ // ---------------------------------------------------------------------------
331
+ // Image source preference (Layer 3): capture the user's choice from a chip
332
+ // answer (if any), then bias downstream image-source detection via a hint
333
+ // token so repeated prompts stop within the same session. The ambiguity
334
+ // challenge itself (returning needs_clarification chips) runs further down
335
+ // once withDebugPayload is in scope.
336
+ // ---------------------------------------------------------------------------
337
+ const capturedImageSourceChoice = parseImageSourceChoiceFromMessage(sanitizedMessage);
338
+ if (capturedImageSourceChoice) {
339
+ imageSourcePreferenceBySession.set(body.session, capturedImageSourceChoice);
340
+ }
341
+ const activeImageSourcePreference = capturedImageSourceChoice ?? imageSourcePreferenceBySession.get(body.session);
342
+ plannerMessage = applyImageSourceHint(plannerMessage, sanitizedMessage, activeImageSourcePreference);
343
+ // Let the LLM decide whether a prompt is genuinely ambiguous about image
344
+ // source (Unsplash vs AI). This flag only opens the option when both sources
345
+ // are configured AND no session-level preference is in effect — otherwise the
346
+ // planner must proceed without asking. See sectionImageSourceChoice in
347
+ // prompts.ts for the full rule the LLM receives.
348
+ const hasUnsplashConfigured = Boolean(process.env.UNSPLASH_ACCESS_KEY?.trim());
349
+ const hasGenAIConfigured = Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.GOOGLE_GENAI_API_KEY?.trim());
350
+ const imageSourceChoiceOpen = hasUnsplashConfigured && hasGenAIConfigured && !activeImageSourcePreference;
351
+ const inferredTranslationScope = inferTranslationScopeFromMessage(plannerMessage);
352
+ const translationScope = shouldPreferFocusedTranslation({
353
+ message: plannerMessage,
354
+ inferredScope: inferredTranslationScope,
355
+ activeBlockId: body.activeBlockId
356
+ })
357
+ ? "component"
358
+ : inferredTranslationScope;
359
+ const planningActiveBlockId = translationScope === "page" ? undefined : body.activeBlockId;
360
+ const planningActiveEditablePath = translationScope === "page" ? undefined : body.activeEditablePath;
361
+ const parsedSiteContext = (() => {
362
+ if (!body.siteContext)
363
+ return {};
364
+ if (typeof body.siteContext === "object")
365
+ return body.siteContext;
366
+ try {
367
+ return JSON.parse(body.siteContext);
368
+ }
369
+ catch {
370
+ return {};
371
+ }
372
+ })();
373
+ const gdriveFolderId = typeof parsedSiteContext.gdriveFolderId === "string" ? parsedSiteContext.gdriveFolderId.trim() || undefined : undefined;
374
+ const sessionChatHistory = chatHistoryBySession.get(body.session) ?? [];
375
+ // Only feed prior turns to the planner when the current message actually leans on
376
+ // them: an anaphoric/terse follow-up (#25), OR a direct answer to an OPEN clarification.
377
+ // A self-contained command that merely happens to arrive while a clarification is pending
378
+ // is NOT a follow-up — passing history there lets the abandoned clarification's context
379
+ // bleed into an unrelated turn (#26). When that happens the user has clearly moved on, so
380
+ // drop the stale pending clarification too.
381
+ const hasPendingClarification = pendingClarificationBySession.has(body.session);
382
+ const answersPendingClarification = hasPendingClarification && isLikelyClarificationFollowUp(body.message ?? "");
383
+ if (hasPendingClarification && !answersPendingClarification) {
384
+ pendingClarificationBySession.delete(body.session);
385
+ }
386
+ const plannerChatHistory = answersPendingClarification || messageDependsOnHistory(body.message ?? "")
387
+ ? sessionChatHistory
388
+ : [];
389
+ const chatRequestId = randomUUID();
390
+ const requestedSlug = body.slug;
391
+ const effectiveSlug = resolveEffectiveSlug({
392
+ session: body.session,
393
+ requestedSlug,
394
+ activeBlockId: planningActiveBlockId
395
+ });
396
+ ctx.log.info({
397
+ event: "chat_pipeline_start",
398
+ chatRequestId,
399
+ session: body.session,
400
+ requestedSlug,
401
+ effectiveSlug,
402
+ activeBlockId: planningActiveBlockId,
403
+ activeEditablePath: planningActiveEditablePath,
404
+ message: body.message
405
+ }, "Chat pipeline request received");
406
+ if (ctx.evalCandidates && body.message) {
407
+ const draftMap = getSessionDraft(body.session);
408
+ const fixture = Array.from(draftMap.values()).map((page) => structuredClone(page));
409
+ ctx.evalCandidates.start({
410
+ id: chatRequestId,
411
+ session: body.session,
412
+ slug: effectiveSlug,
413
+ prompt: body.message,
414
+ fixture,
415
+ activeBlockId: planningActiveBlockId,
416
+ activeEditablePath: planningActiveEditablePath
417
+ });
418
+ }
419
+ // Planner model pin — "always run on Sonnet". Small fast models under-follow
420
+ // the planner contract (op/summary consistency, assumption handling under the
421
+ // review gate), so when Anthropic is configured we pin the *planner* (not
422
+ // variations or the agent) to Sonnet (balanced tier) and skip every fast/cheap
423
+ // downgrade below. Honors an explicit caller provider/modelKey and falls back
424
+ // to the prior auto-routing when Anthropic is unavailable. Disable with
425
+ // CHAT_PLANNER_FORCE_SONNET=0. See CLAUDE.md § Chat Pipeline Flags.
426
+ const forceSonnet = /^(1|true|yes|on)$/i.test((process.env.CHAT_PLANNER_FORCE_SONNET ?? "").trim()) &&
427
+ !body.provider &&
428
+ !body.modelKey &&
429
+ ctx.availableProviders.includes("anthropic");
430
+ const requestedProvider = body.provider ??
431
+ (forceSonnet ? "anthropic" : ctx.availableProviders[0]);
432
+ const provider = resolveEffectiveProvider({
433
+ requestedProvider,
434
+ availableProviders: ctx.availableProviders,
435
+ fallbackProvider: ctx.availableProviders[0] ?? "openai"
436
+ });
437
+ const baseModelKey = resolveModelKeyForProvider({
438
+ requestedModelKey: body.modelKey,
439
+ provider,
440
+ modelLookup: ctx.modelLookup,
441
+ defaultModelKey: process.env.OPENAI_MODEL_KEY ?? "balanced"
442
+ });
443
+ const modelKey = forceSonnet
444
+ ? "balanced"
445
+ : !body.modelKey &&
446
+ baseModelKey !== "fast" &&
447
+ ctx.modelLookup[provider].fast &&
448
+ shouldPreferFastModelForMessage(plannerMessage)
449
+ ? "fast"
450
+ : baseModelKey;
451
+ const modelUsed = ctx.modelLookup[provider][modelKey];
452
+ const plannerSource = resolvePlannerSource(provider);
453
+ // Auto-escalate to extended thinking for complex/ambiguous Anthropic prompts.
454
+ // Only applies when provider = anthropic (other providers ignore `thinking`).
455
+ // User can disable with CHAT_AUTO_REASONING=0.
456
+ const reasoningAutoEnabled = process.env.CHAT_AUTO_REASONING !== "0";
457
+ const parsedReasoningBudget = Number(process.env.CHAT_AUTO_REASONING_BUDGET ?? 2048);
458
+ const reasoningBudgetTokens = Math.max(1024, Number.isFinite(parsedReasoningBudget) ? parsedReasoningBudget : 2048);
459
+ if (!Number.isFinite(parsedReasoningBudget)) {
460
+ ctx.log.warn({
461
+ event: "reasoning_budget_invalid",
462
+ raw: process.env.CHAT_AUTO_REASONING_BUDGET
463
+ }, "CHAT_AUTO_REASONING_BUDGET is not a number; falling back to 2048");
464
+ }
465
+ const reasoningEnabled = reasoningAutoEnabled &&
466
+ plannerSource === "anthropic" &&
467
+ shouldEnableReasoningForMessage(plannerMessage);
468
+ const reasoningEffort = budgetTokensToEffort(reasoningBudgetTokens);
469
+ const thinkingConfig = reasoningEnabled ? { effort: reasoningEffort } : undefined;
470
+ if (reasoningEnabled) {
471
+ ctx.log.info({
472
+ event: "reasoning_auto_enabled",
473
+ model: modelUsed,
474
+ effort: reasoningEffort,
475
+ budgetTokens: reasoningBudgetTokens,
476
+ promptLength: plannerMessage.length
477
+ }, "Adaptive thinking auto-enabled for this request");
478
+ }
479
+ const promptHash = ctx.chatTelemetry.promptHash(plannerMessage);
480
+ const promptExcerpt = ctx.chatTelemetry.promptExcerpt(plannerMessage);
481
+ const pipelineStartedAtMs = Date.now();
482
+ const stageTimeline = [];
483
+ let planningStartedAtMs = null;
484
+ let planningFinishedAtMs = null;
485
+ let firstPlanningTokenMs = null;
486
+ // Auto-routing decision tracker. Populated by the parallel-router branch
487
+ // when it observes a complexity signal worth acting on (or shadow-logging).
488
+ // Threaded into `debug.routingDecision` via `withDebugPayload`.
489
+ let routingDecision;
490
+ // Which planner actually produced the response. Surfaced in `debug.plannerTier`
491
+ // so a deterministic plan isn't attributed to the configured LLM (its provider
492
+ // name + model still appear because they'd be used IF an LLM ran) — issue #14b.
493
+ // respondFromPlan overrides this with the plan's true tier; null tokens +
494
+ // tier=deterministic together signal "no LLM spend".
495
+ let activePlannerTier;
496
+ let planningAttempts = 0;
497
+ let imageResolutionDurationMs = 0;
498
+ let applyDurationMs;
499
+ let firstApplyMs = null;
500
+ let doneStageMarked = false;
501
+ const markPlanningStart = () => {
502
+ if (planningStartedAtMs === null)
503
+ planningStartedAtMs = Date.now();
504
+ };
505
+ const markPlanningFinish = () => {
506
+ planningFinishedAtMs = Date.now();
507
+ };
508
+ const onPlanningToken = (token) => {
509
+ if (firstPlanningTokenMs === null) {
510
+ firstPlanningTokenMs = Date.now() - pipelineStartedAtMs;
511
+ emitStatusTone("generating_plan");
512
+ if (!stageTimeline.some((item) => item.stage === "first_token")) {
513
+ stageTimeline.push({ stage: "first_token", atMs: firstPlanningTokenMs });
514
+ }
515
+ ctx.chatTelemetry.push({
516
+ id: chatRequestId,
517
+ at: new Date().toISOString(),
518
+ phase: "milestone",
519
+ timelineStage: "first_token",
520
+ session: body.session,
521
+ requestedSlug,
522
+ effectiveSlug,
523
+ plannerSource,
524
+ modelKey,
525
+ modelUsed,
526
+ promptHash,
527
+ promptExcerpt,
528
+ promptLength: plannerMessage.length,
529
+ totalDurationMs: firstPlanningTokenMs
530
+ });
531
+ }
532
+ options?.onPlanningToken?.(token);
533
+ };
534
+ const markFirstStructuredProgress = () => {
535
+ if (stageTimeline.some((item) => item.stage === "first_structured_progress"))
536
+ return;
537
+ const atMs = Date.now() - pipelineStartedAtMs;
538
+ stageTimeline.push({ stage: "first_structured_progress", atMs });
539
+ ctx.chatTelemetry.push({
540
+ id: chatRequestId,
541
+ at: new Date().toISOString(),
542
+ phase: "milestone",
543
+ timelineStage: "first_structured_progress",
544
+ session: body.session,
545
+ requestedSlug,
546
+ effectiveSlug,
547
+ plannerSource,
548
+ modelKey,
549
+ modelUsed,
550
+ promptHash,
551
+ promptExcerpt,
552
+ promptLength: plannerMessage.length,
553
+ totalDurationMs: atMs
554
+ });
555
+ };
556
+ const emitStatus = (message) => {
557
+ options?.onStatusUpdate?.(message);
558
+ ctx.log.info({
559
+ event: "chat_pipeline_status",
560
+ chatRequestId,
561
+ session: body.session,
562
+ message,
563
+ elapsedMs: Date.now() - pipelineStartedAtMs
564
+ }, "Chat pipeline status update");
565
+ };
566
+ const pipelineStatusVariants = {
567
+ analyzing: [
568
+ "Analyzing your request...",
569
+ "Reviewing your request...",
570
+ "Looking over your request..."
571
+ ],
572
+ planning: [
573
+ "Planning edits...",
574
+ "Drafting the edit plan...",
575
+ "Mapping out the changes..."
576
+ ],
577
+ understanding: [
578
+ "Understanding your request...",
579
+ "Interpreting your request...",
580
+ "Confirming intent..."
581
+ ],
582
+ breaking_down: [
583
+ "Breaking down your request...",
584
+ "Splitting your request into steps...",
585
+ "Organizing requested changes..."
586
+ ],
587
+ generating_plan: [
588
+ "Generating plan...",
589
+ "Composing edit plan...",
590
+ "Building execution plan..."
591
+ ],
592
+ resolving_assets: [
593
+ "Resolving image assets...",
594
+ "Collecting image assets...",
595
+ "Preparing image assets..."
596
+ ],
597
+ applying: [
598
+ "Applying changes...",
599
+ "Applying edits...",
600
+ "Updating draft..."
601
+ ],
602
+ validating: [
603
+ "Wrapping up…",
604
+ "Finalizing the response…",
605
+ "Almost done…"
606
+ ],
607
+ repairing: [
608
+ "Repairing plan and retrying...",
609
+ "Fixing plan details and retrying...",
610
+ "Adjusting plan and trying again..."
611
+ ],
612
+ thinking: [
613
+ "Thinking...",
614
+ "Working through the request...",
615
+ "Refining the plan..."
616
+ ]
617
+ };
618
+ const pipelineStatusCursor = new Map();
619
+ const emitStatusTone = (tone) => {
620
+ const variants = pipelineStatusVariants[tone];
621
+ const nextIdx = (pipelineStatusCursor.get(tone) ?? 0) % variants.length;
622
+ pipelineStatusCursor.set(tone, nextIdx + 1);
623
+ emitStatus(variants[nextIdx] ?? variants[0] ?? "Working...");
624
+ };
625
+ const planningDurationMs = () => planningStartedAtMs !== null && planningFinishedAtMs !== null
626
+ ? Math.max(0, planningFinishedAtMs - planningStartedAtMs)
627
+ : undefined;
628
+ const timingFields = () => ({
629
+ totalDurationMs: Date.now() - pipelineStartedAtMs,
630
+ planningDurationMs: planningDurationMs(),
631
+ firstPlanningTokenMs: firstPlanningTokenMs ?? undefined,
632
+ applyDurationMs,
633
+ imageResolutionDurationMs: imageResolutionDurationMs > 0 ? imageResolutionDurationMs : undefined,
634
+ planningAttempts: planningAttempts > 0 ? planningAttempts : undefined
635
+ });
636
+ const incrementalApplyEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_APPLY ?? "1").trim());
637
+ const streamedApplyMinStepMsRaw = Number(process.env.CHAT_STREAM_APPLY_MIN_STEP_MS ?? 260);
638
+ const streamedApplyMinStepMs = Number.isFinite(streamedApplyMinStepMsRaw) && streamedApplyMinStepMsRaw > 0
639
+ ? Math.min(Math.max(Math.trunc(streamedApplyMinStepMsRaw), 1), 2000)
640
+ : 0;
641
+ const incrementalPlanStreamEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_PLAN_STREAM ?? "1").trim());
642
+ const withDebugPayload = (payload, extra) => ({
643
+ ...payload,
644
+ debug: {
645
+ traceId: chatRequestId,
646
+ promptHash,
647
+ promptExcerpt,
648
+ modelUsed,
649
+ plannerSource,
650
+ executionMode,
651
+ currentPage: effectiveSlug,
652
+ siteId: body.siteId || undefined,
653
+ activeBlockId: planningActiveBlockId || undefined,
654
+ activeEditablePath: planningActiveEditablePath || undefined,
655
+ ...(planningAttempts > 1 ? { planningAttempts } : {}),
656
+ ...(routingDecision ? { routingDecision } : {}),
657
+ ...(activePlannerTier ? { plannerTier: activePlannerTier } : {}),
658
+ timeline: (() => {
659
+ if (!doneStageMarked) {
660
+ doneStageMarked = true;
661
+ const doneAtMs = Date.now() - pipelineStartedAtMs;
662
+ stageTimeline.push({ stage: "done", atMs: doneAtMs });
663
+ ctx.chatTelemetry.push({
664
+ id: chatRequestId,
665
+ at: new Date().toISOString(),
666
+ phase: "milestone",
667
+ timelineStage: "done",
668
+ session: body.session,
669
+ requestedSlug,
670
+ effectiveSlug,
671
+ plannerSource,
672
+ modelKey,
673
+ modelUsed,
674
+ promptHash,
675
+ promptExcerpt,
676
+ promptLength: plannerMessage.length,
677
+ totalDurationMs: doneAtMs
678
+ });
679
+ }
680
+ return stageTimeline.slice();
681
+ })(),
682
+ ...(payload.debug ?? {}),
683
+ ...(extra ?? {})
684
+ }
685
+ });
686
+ stageTimeline.push({ stage: "request_received", atMs: 0 });
687
+ ctx.chatTelemetry.push({
688
+ id: chatRequestId,
689
+ at: new Date().toISOString(),
690
+ phase: "received",
691
+ session: body.session,
692
+ requestedSlug,
693
+ effectiveSlug,
694
+ plannerSource,
695
+ modelKey,
696
+ modelUsed,
697
+ promptHash,
698
+ promptExcerpt,
699
+ promptLength: plannerMessage.length
700
+ });
701
+ ctx.chatTelemetry.push({
702
+ id: chatRequestId,
703
+ at: new Date().toISOString(),
704
+ phase: "milestone",
705
+ timelineStage: "request_received",
706
+ session: body.session,
707
+ requestedSlug,
708
+ effectiveSlug,
709
+ plannerSource,
710
+ modelKey,
711
+ modelUsed,
712
+ promptHash,
713
+ promptExcerpt,
714
+ promptLength: plannerMessage.length,
715
+ totalDurationMs: 0
716
+ });
717
+ if (executionMode === "auto") {
718
+ const existingPendingPlan = pendingApprovalPlanBySession.get(body.session);
719
+ const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
720
+ const normalizedPendingMessage = typeof existingPendingPlan?.originalMessage === "string"
721
+ ? existingPendingPlan.originalMessage.trim()
722
+ : "";
723
+ const replayedPendingPrompt = Boolean(existingPendingPlan &&
724
+ (existingPendingPlan.promptHash === promptHash ||
725
+ (normalizedIncomingMessage.length > 0 && normalizedPendingMessage.length > 0 && normalizedIncomingMessage === normalizedPendingMessage)));
726
+ if (replayedPendingPrompt) {
727
+ executionMode = "apply_pending_plan";
728
+ body.pendingPlanId = body.pendingPlanId ?? existingPendingPlan?.id;
729
+ }
730
+ }
731
+ let current = getPage(body.session, effectiveSlug);
732
+ if (!current) {
733
+ // Allow page-creation requests through by providing an empty stub page
734
+ const looksLikeCreatePage = body.message && /\b(create|add|make|build|generate|new)\b.*\b(page|homepage|landing)\b/i.test(body.message);
735
+ if (!looksLikeCreatePage)
736
+ return { code: 404, payload: { error: "page not found" } };
737
+ current = { id: effectiveSlug, slug: effectiveSlug, title: "", updatedAt: new Date().toISOString(), blocks: [] };
738
+ }
739
+ // --- Clone-and-translate compound ---
740
+ // "clone the home page into /test, same content but translated to Spanish" is
741
+ // a two-phase request a single planner pass can't satisfy: it can emit
742
+ // `duplicate_page`, but the copy's blocks don't exist at plan time, so the
743
+ // translation half is silently dropped and the copy stays in the source
744
+ // language while the summary claims otherwise. Run it server-side as two
745
+ // phases — duplicate the page, then translate the fresh copy via the normal
746
+ // full-page translation path (which already covers every translatable field).
747
+ if (executionMode === "auto" &&
748
+ body.message &&
749
+ current.blocks.length > 0 &&
750
+ !continuationChainBySession.has(body.session) &&
751
+ (plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini")) {
752
+ const cloneTranslate = parseCloneAndTranslateRequest(plannerMessage);
753
+ if (cloneTranslate) {
754
+ // Set once phase 1 commits the copy. After that the fall-through to the
755
+ // normal planner is NOT safe — it would re-plan the same clone request
756
+ // against the already-committed copy and emit a SECOND duplicate (or a
757
+ // "slug exists" guardrail error). So once committed we return the
758
+ // untranslated-duplicate result on any phase-2 failure, never fall through.
759
+ let clonedSlug = null;
760
+ let clonedDupVersion = 0;
761
+ // Captured narrowed (the closure would otherwise widen body.session back to
762
+ // `string | undefined`; it's already guarded non-empty at the top).
763
+ const sessionId = body.session;
764
+ const untranslatedDuplicateResult = (newSlug, dupVersion) => ({
765
+ code: 200,
766
+ payload: withDebugPayload({
767
+ status: "applied",
768
+ summary: `Cloned the ${fmtSlug(effectiveSlug)} page to ${fmtSlug(newSlug)}, but couldn't translate it to ${cloneTranslate.languageName} — try "translate this page to ${cloneTranslate.languageName}" on ${fmtSlug(newSlug)}.`,
769
+ changes: [`Duplicated ${fmtSlug(effectiveSlug)} → ${fmtSlug(newSlug)}.`],
770
+ mentionedSlugs: [newSlug],
771
+ suggestions: [`Translate ${fmtSlug(newSlug)} to ${cloneTranslate.languageName}`],
772
+ previewVersion: versions.get(sessionId) ?? dupVersion,
773
+ updatedSlug: newSlug,
774
+ undoSlug: newSlug,
775
+ plannerSource,
776
+ modelUsed,
777
+ modelKey
778
+ }, {
779
+ outcome: "applied",
780
+ intent: "edit_plan",
781
+ opCount: 1,
782
+ opTypes: ["duplicate_page"]
783
+ })
784
+ });
785
+ try {
786
+ options?.onStatusUpdate?.(`Duplicating ${fmtSlug(effectiveSlug)}…`);
787
+ // Phase 1 — duplicate the current page (deterministic; the planner is
788
+ // reliable here but we skip it to avoid a needless LLM round-trip).
789
+ const dupOp = {
790
+ op: "duplicate_page",
791
+ pageSlug: effectiveSlug,
792
+ ...(cloneTranslate.newSlug ? { newPageSlug: cloneTranslate.newSlug } : {}),
793
+ // Tag the copy's title with the language code (e.g. "Home (ES)") so the
794
+ // localized page is distinguishable in nav/SEO from its source. The
795
+ // full-page translation in phase 2 covers block content, not page meta.
796
+ ...(current.title.trim() ? { newTitle: `${current.title.trim()} (${cloneTranslate.languageCode})` } : {})
797
+ };
798
+ const dupResult = await applyOpsAtomically(body.session, [dupOp], { componentsManifest });
799
+ const created = dupResult.duplicatedPages[0];
800
+ if (created) {
801
+ const newSlug = created.slug;
802
+ // Make the duplication undoable on its own (undo = delete the copy).
803
+ // The phase-2 translation pushes its own pre-translation snapshot on
804
+ // top, so undo unwinds: translation → English copy → page removed.
805
+ pushUndo(body.session, newSlug, null);
806
+ const dupVersion = bumpVersion(body.session);
807
+ clonedSlug = newSlug;
808
+ clonedDupVersion = dupVersion;
809
+ options?.onOpApplied?.({ index: 1, total: 1, op: dupOp, previewVersion: dupVersion, updatedSlug: newSlug });
810
+ options?.onStatusUpdate?.(`Translating ${fmtSlug(newSlug)} to ${cloneTranslate.languageName}…`);
811
+ // Phase 2 — translate the fresh copy. Clear any active-block context so
812
+ // resolveEffectiveSlug can't follow the original (un-suffixed) block id
813
+ // back to the source page and translate that instead.
814
+ const translateResult = await runChatPipeline(ctx, {
815
+ ...body,
816
+ slug: newSlug,
817
+ message: `translate this page to ${cloneTranslate.languageName}`,
818
+ executionMode: "auto",
819
+ activeBlockId: undefined,
820
+ activeEditablePath: undefined
821
+ }, options);
822
+ if (translateResult.code === 200 &&
823
+ "status" in translateResult.payload &&
824
+ translateResult.payload.status === "applied") {
825
+ const payload = translateResult.payload;
826
+ payload.summary = `Cloned the ${fmtSlug(effectiveSlug)} page to ${fmtSlug(newSlug)} and translated all content to ${cloneTranslate.languageName}.`;
827
+ payload.changes = [`Duplicated ${fmtSlug(effectiveSlug)} → ${fmtSlug(newSlug)}.`, ...(payload.changes ?? [])];
828
+ payload.updatedSlug = newSlug;
829
+ payload.undoSlug = newSlug;
830
+ payload.mentionedSlugs = payload.mentionedSlugs?.includes(newSlug)
831
+ ? payload.mentionedSlugs
832
+ : [newSlug, ...(payload.mentionedSlugs ?? [])];
833
+ return translateResult;
834
+ }
835
+ // Phase 2 didn't apply — return the (untranslated) duplicate so the user
836
+ // at least gets the copy, with an honest note that translation failed.
837
+ return untranslatedDuplicateResult(newSlug, dupVersion);
838
+ }
839
+ }
840
+ catch (cloneTranslateError) {
841
+ if (isCancelError(cloneTranslateError))
842
+ throw cloneTranslateError;
843
+ ctx.log.warn({ event: "clone_translate_failed", err: toErrorDetail(cloneTranslateError) }, clonedSlug
844
+ ? "Clone-and-translate phase 2 threw after the copy was committed; returning the untranslated duplicate"
845
+ : "Clone-and-translate compound failed before committing; falling back to normal planner");
846
+ // If the copy is already committed, returning it (untranslated) is the
847
+ // only safe outcome — see the note where clonedSlug is declared.
848
+ if (clonedSlug)
849
+ return untranslatedDuplicateResult(clonedSlug, clonedDupVersion);
850
+ // Nothing committed (phase 1 is atomic) — fall through to the normal planner.
851
+ }
852
+ }
853
+ }
854
+ // "describe this image" on an image field — return the current image URL and suggest alt text
855
+ if (body.message && /\bdescribe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration)\b/i.test(body.message) && body.activeEditablePath) {
856
+ const isImageField = /(?:imageUrl|image\.?src|\.url)$/i.test(body.activeEditablePath);
857
+ if (isImageField && body.activeBlockId) {
858
+ const block = current.blocks.find((b) => b.id === body.activeBlockId);
859
+ if (block) {
860
+ const imageUrl = getValueAtPath(block.props, body.activeEditablePath);
861
+ const summary = typeof imageUrl === "string" && imageUrl.startsWith("http")
862
+ ? `This image is: ${imageUrl}`
863
+ : "No image URL is currently set for this field.";
864
+ return {
865
+ code: 200,
866
+ payload: withDebugPayload({
867
+ status: "info",
868
+ summary,
869
+ changes: [],
870
+ suggestions: [
871
+ "Write alt text for this image",
872
+ "Replace this image",
873
+ "Search for a new image"
874
+ ],
875
+ previewVersion: versions.get(body.session) ?? 0,
876
+ plannerSource,
877
+ modelUsed,
878
+ modelKey
879
+ }, { outcome: "info" })
880
+ };
881
+ }
882
+ }
883
+ }
884
+ if (body.message && isInfoQuery(body.message) && !isBatchAddRequest(body.message)) {
885
+ activePlannerTier = "deterministic";
886
+ const info = infoResponse({ body, current, plannerSource, modelUsed, modelKey });
887
+ return { code: info.code, payload: withDebugPayload(info.payload, { outcome: "info" }) };
888
+ }
889
+ if (body.message && isAdviceQuery(body.message) && !isBatchAddRequest(body.message)) {
890
+ activePlannerTier = "deterministic";
891
+ const advice = adviceResponse({ body, current, plannerSource, modelUsed, modelKey });
892
+ return { code: advice.code, payload: withDebugPayload(advice.payload, { outcome: "advice" }) };
893
+ }
894
+ if (body.message && isPageListQuery(body.message)) {
895
+ const directory = buildPageDirectory(body.session);
896
+ const draft = getSessionDraft(body.session);
897
+ const pageCount = draft.size;
898
+ return {
899
+ code: 200,
900
+ payload: withDebugPayload({
901
+ status: "info",
902
+ summary: `This site has ${pageCount} page${pageCount === 1 ? "" : "s"}:`,
903
+ changes: directory ? directory.split("\n").map((l) => l.trim()) : ["No pages found."],
904
+ suggestions: [
905
+ "Add a new page",
906
+ "Delete a page",
907
+ "Rename a page",
908
+ "Reorder pages"
909
+ ],
910
+ previewVersion: versions.get(body.session) ?? 0,
911
+ plannerSource,
912
+ modelUsed,
913
+ modelKey
914
+ }, { outcome: "info" })
915
+ };
916
+ }
917
+ if (body.message && isVariationRequestMessage(body.message)) {
918
+ if (body.activeBlockId) {
919
+ // Route to the dedicated variation pipeline and return its result directly.
920
+ // The editor detects the VariationResult shape (status "ok" + variations array)
921
+ // and opens the variation modal, same as it does for /chat/variations responses.
922
+ // Disambiguate "show me the variations again" (cached, no LLM) from
923
+ // "generate new variations" (fresh LLM call). When the user clearly wants
924
+ // to re-display an existing set and we have one cached, return it immediately.
925
+ const verbIntent = variationVerbIntent(body.message);
926
+ if (verbIntent === "show") {
927
+ const cached = getCachedVariations(body.session, body.activeBlockId);
928
+ if (cached) {
929
+ const shown = {
930
+ ...cached,
931
+ summary: `Showing your ${cached.variations.length} variations for ${cached.blockType}.`
932
+ };
933
+ return { code: 200, payload: shown };
934
+ }
935
+ }
936
+ const intent = verbIntent === "show" ? "show" : "generate";
937
+ const variationResult = await runVariationPipeline(ctx, {
938
+ session: body.session,
939
+ slug: effectiveSlug,
940
+ message: body.message,
941
+ activeBlockId: body.activeBlockId,
942
+ activeBlockType: body.activeBlockType,
943
+ modelKey,
944
+ provider: body.provider,
945
+ sitePurpose: body.sitePurpose,
946
+ siteHosting: body.siteHosting,
947
+ businessContext: body.businessContext,
948
+ siteContext: body.siteContext
949
+ }, { intent });
950
+ return variationResult;
951
+ }
952
+ // No block selected — ask which block
953
+ return {
954
+ code: 200,
955
+ payload: withDebugPayload({
956
+ status: "needs_clarification",
957
+ summary: "Select a block first, then ask for variants.",
958
+ changes: [],
959
+ suggestions: [
960
+ "Click on a block to select it, then try again.",
961
+ "Example: select the Hero block, then say 'generate 3 variations'."
962
+ ],
963
+ previewVersion: versions.get(body.session) ?? 0,
964
+ plannerSource,
965
+ modelUsed,
966
+ modelKey
967
+ }, { outcome: "variation_request_redirect", reasonCategory: "ambiguity" })
968
+ };
969
+ }
970
+ emitStatusTone("analyzing");
971
+ // Content queries (read-only questions about page content) need full props for the LLM
972
+ const contentQuery = body.message ? isContentQuery(body.message) : false;
973
+ const contextPack = plannerContextPack({
974
+ session: body.session,
975
+ slug: effectiveSlug,
976
+ message: plannerMessage,
977
+ currentPage: current,
978
+ activeBlockId: planningActiveBlockId,
979
+ activeBlockType: body.activeBlockType,
980
+ activeEditablePath: planningActiveEditablePath,
981
+ // Plan-first requests get full props for EVERY block (not just text hints +
982
+ // array lengths) so the planner can see nested list-item content — FAQ items,
983
+ // card titles, etc. Without this the model sees "FAQ has 3 items" with no
984
+ // labels and is forced to ask, defeating the plan-first flow. The user opted
985
+ // into a considered plan, so the extra context tokens are justified.
986
+ includeFullProps: translationScope === "page" || contentQuery || requestsPlanFirst(plannerMessage),
987
+ attachments: body.attachments
988
+ });
989
+ const compactContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_COMPACT_CONTEXT_EXPERIMENT ?? "").trim());
990
+ const minimalContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_MINIMAL_CONTEXT_EXPERIMENT ?? "").trim());
991
+ const intentComplexity = !contentQuery
992
+ ? classifyMessageComplexity({
993
+ message: plannerMessage,
994
+ currentPage: current,
995
+ activeBlockId: planningActiveBlockId,
996
+ activeEditablePath: planningActiveEditablePath,
997
+ translationScope
998
+ })
999
+ : "standard";
1000
+ let isLightweightEdit = intentComplexity === "simple";
1001
+ const basePlannerContext = (compactContextExperimentEnabled || isLightweightEdit)
1002
+ ? compactPlannerContextPack({ contextPack, message: plannerMessage, translationScope })
1003
+ : contextPack;
1004
+ const useMinimalPlannerContext = !contentQuery && (minimalContextExperimentEnabled || isLightweightEdit) && shouldUseMinimalPlannerContext({
1005
+ message: plannerMessage,
1006
+ translationScope,
1007
+ activeBlockId: planningActiveBlockId,
1008
+ activeEditablePath: planningActiveEditablePath
1009
+ });
1010
+ let plannerContext = useMinimalPlannerContext
1011
+ ? minimalPlannerContextPack({ contextPack: basePlannerContext })
1012
+ : basePlannerContext;
1013
+ const contextPackBytes = (() => {
1014
+ try {
1015
+ return Buffer.byteLength(JSON.stringify(plannerContext), "utf8");
1016
+ }
1017
+ catch {
1018
+ return undefined;
1019
+ }
1020
+ })();
1021
+ const plannerContextTelemetryFields = {
1022
+ ...(typeof contextPackBytes === "number" ? { contextPackBytes } : {}),
1023
+ compactContextEnabled: compactContextExperimentEnabled,
1024
+ minimalContextEnabled: useMinimalPlannerContext,
1025
+ strictJsonEnabled: isStrictJsonResponseEnabled()
1026
+ };
1027
+ const guardrailFailureResponse = (args) => {
1028
+ const category = classifyGuardrailError(args.reason);
1029
+ ctx.chatTelemetry.push({
1030
+ id: chatRequestId,
1031
+ at: new Date().toISOString(),
1032
+ phase: "result",
1033
+ session: body.session,
1034
+ requestedSlug,
1035
+ effectiveSlug,
1036
+ plannerSource: args.source,
1037
+ modelKey,
1038
+ modelUsed,
1039
+ promptHash,
1040
+ promptExcerpt,
1041
+ promptLength: plannerMessage.length,
1042
+ outcome: "guardrail_failure",
1043
+ reason: args.reason.slice(0, 300),
1044
+ reasonCategory: category,
1045
+ ...plannerContextTelemetryFields,
1046
+ ...timingFields()
1047
+ });
1048
+ if (category === "ambiguity") {
1049
+ const selected = planningActiveBlockId && current.blocks.find((b) => b.id === planningActiveBlockId)
1050
+ ? current.blocks.find((b) => b.id === planningActiveBlockId)
1051
+ : null;
1052
+ return {
1053
+ code: 200,
1054
+ payload: withDebugPayload({
1055
+ status: "needs_clarification",
1056
+ summary: "I need one more detail before applying this safely.",
1057
+ changes: [],
1058
+ mentionedSlugs: [effectiveSlug],
1059
+ suggestions: clarificationSuggestions({ body, current, selected }),
1060
+ previewVersion: versions.get(body.session) ?? 0,
1061
+ plannerSource: args.source,
1062
+ modelUsed,
1063
+ modelKey
1064
+ }, { outcome: "needs_clarification", reasonCategory: category })
1065
+ };
1066
+ }
1067
+ return {
1068
+ code: 400,
1069
+ payload: withDebugPayload({
1070
+ status: "validation_error",
1071
+ summary: "I could not apply that change safely.",
1072
+ changes: [],
1073
+ validationErrors: [formatValidationError(args.reason)],
1074
+ previewVersion: versions.get(body.session) ?? 0,
1075
+ plannerSource: args.source,
1076
+ modelUsed,
1077
+ modelKey
1078
+ }, { outcome: "validation_error", reasonCategory: category })
1079
+ };
1080
+ };
1081
+ let planUsage;
1082
+ let usedNativeUnsplashTool = false;
1083
+ let usedNativeImageTool = false;
1084
+ let deferredNativeImageCalls = [];
1085
+ const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
1086
+ if (plannerTier)
1087
+ activePlannerTier = plannerTier;
1088
+ const usageFields = planUsage ? {
1089
+ inputTokens: planUsage.inputTokens,
1090
+ outputTokens: planUsage.outputTokens,
1091
+ totalTokens: planUsage.totalTokens,
1092
+ ...(typeof planUsage.cacheCreationInputTokens === "number"
1093
+ ? { cacheCreationInputTokens: planUsage.cacheCreationInputTokens }
1094
+ : {}),
1095
+ ...(typeof planUsage.cacheReadInputTokens === "number"
1096
+ ? { cacheReadInputTokens: planUsage.cacheReadInputTokens }
1097
+ : {}),
1098
+ estimatedUsd: estimateUsd(modelUsed, planUsage)
1099
+ } : {};
1100
+ ctx.chatTelemetry.push({
1101
+ id: chatRequestId,
1102
+ at: new Date().toISOString(),
1103
+ phase: "plan_generated",
1104
+ session: body.session,
1105
+ requestedSlug,
1106
+ effectiveSlug,
1107
+ plannerSource: source,
1108
+ modelKey,
1109
+ modelUsed,
1110
+ promptHash,
1111
+ promptExcerpt,
1112
+ promptLength: plannerMessage.length,
1113
+ intent: plan.intent,
1114
+ opCount: plan.ops.length,
1115
+ opTypes: plan.ops.map((op) => op.op),
1116
+ plannerTier,
1117
+ ...plannerContextTelemetryFields,
1118
+ ...usageFields,
1119
+ ...timingFields()
1120
+ });
1121
+ let resolvedPlan = plan;
1122
+ let detectedImageOps = [];
1123
+ let effectiveApplyMode = applyMode;
1124
+ if (!optionsOverride?.preResolvedPlan) {
1125
+ resolvedPlan = normalizePlanCopyForUi(plan, current);
1126
+ resolvedPlan = rewriteAddBlockToChildImageUpdate({
1127
+ plan: resolvedPlan,
1128
+ message: plannerMessage,
1129
+ currentPage: current,
1130
+ slug: effectiveSlug
1131
+ });
1132
+ // Strip hallucinated props (planner promising changes to fields the
1133
+ // block schema doesn't have — e.g. "colors" on Stats). Appends a note
1134
+ // to summary_for_user and change_log so the user isn't misled.
1135
+ const hallucinationResult = validateAndStripHallucinatedProps({
1136
+ plan: resolvedPlan,
1137
+ draft: getSessionDraft(body.session)
1138
+ });
1139
+ if (hallucinationResult.hallucinatedProps.length > 0) {
1140
+ for (const entry of hallucinationResult.hallucinatedProps) {
1141
+ ctx.log.warn({
1142
+ event: "planner_hallucinated_prop",
1143
+ chatRequestId,
1144
+ session: body.session,
1145
+ slug: effectiveSlug,
1146
+ blockId: entry.blockId,
1147
+ blockType: entry.blockType,
1148
+ propName: entry.propName,
1149
+ plannerSource: source,
1150
+ modelKey,
1151
+ modelUsed
1152
+ }, "Planner generated an unsupported prop; stripped before apply");
1153
+ }
1154
+ }
1155
+ // Ensure change_log has one entry per op. When the planner clusters
1156
+ // ops into fewer entries, synthesize generic fallbacks so the user
1157
+ // sees the correct count instead of silently approving uncovered ops.
1158
+ const changelogResult = validateChangelogCoverage({
1159
+ plan: resolvedPlan,
1160
+ draft: getSessionDraft(body.session)
1161
+ });
1162
+ if (changelogResult.missingCount > 0) {
1163
+ ctx.log.warn({
1164
+ event: "planner_incomplete_changelog",
1165
+ chatRequestId,
1166
+ session: body.session,
1167
+ slug: effectiveSlug,
1168
+ opCount: resolvedPlan.ops.length,
1169
+ missingCount: changelogResult.missingCount,
1170
+ plannerSource: source,
1171
+ modelKey,
1172
+ modelUsed
1173
+ }, "Planner returned fewer change_log entries than ops; synthesized fallbacks");
1174
+ }
1175
+ if (changelogResult.extraCount > 0) {
1176
+ ctx.log.warn({
1177
+ event: "planner_overdescribed_changelog",
1178
+ chatRequestId,
1179
+ session: body.session,
1180
+ slug: effectiveSlug,
1181
+ opCount: resolvedPlan.ops.length,
1182
+ extraCount: changelogResult.extraCount,
1183
+ droppedEntries: changelogResult.droppedEntries,
1184
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1185
+ plannerSource: source,
1186
+ modelKey,
1187
+ modelUsed
1188
+ }, "Planner returned more change_log entries than ops; trimmed trailing entries to match");
1189
+ }
1190
+ if (changelogResult.fieldMislabelCount > 0) {
1191
+ ctx.log.warn({
1192
+ event: "planner_mislabeled_changelog_field",
1193
+ chatRequestId,
1194
+ session: body.session,
1195
+ slug: effectiveSlug,
1196
+ fieldMislabelCount: changelogResult.fieldMislabelCount,
1197
+ relabeledEntries: changelogResult.relabeledEntries,
1198
+ plannerSource: source,
1199
+ modelKey,
1200
+ modelUsed
1201
+ }, "Planner named a field its op does not change; relabeled change_log/summary from the actual patch");
1202
+ }
1203
+ // Detect image ops synchronously before making any API calls
1204
+ detectedImageOps = detectImageOps({
1205
+ plan: resolvedPlan,
1206
+ message: plannerMessage,
1207
+ slug: effectiveSlug,
1208
+ currentPage: current,
1209
+ activeBlockId: planningActiveBlockId,
1210
+ activeEditablePath: planningActiveEditablePath
1211
+ });
1212
+ // Filter out image ops targeting blocks whose schema doesn't support imageUrl
1213
+ if (detectedImageOps.length > 0) {
1214
+ const unsupportedBlocks = new Set();
1215
+ const unsupportedImagePaths = [];
1216
+ detectedImageOps = detectedImageOps.filter((imgOp) => {
1217
+ const block = current.blocks.find((b) => b.id === imgOp.blockId);
1218
+ if (!block)
1219
+ return true;
1220
+ const path = imgOp.path || "imageUrl";
1221
+ if (blockSupportsImageAtPath(block.type, path))
1222
+ return true;
1223
+ unsupportedBlocks.add(block.type);
1224
+ unsupportedImagePaths.push({ blockId: imgOp.blockId, pageSlug: imgOp.pageSlug, path, altPath: imgOp.altPath });
1225
+ return false;
1226
+ });
1227
+ // Strip unsupported imageUrl values from ops patches so they don't leak through
1228
+ for (const removed of unsupportedImagePaths) {
1229
+ const op = resolvedPlan.ops.find((o) => o.op === "update_props" && o.blockId === removed.blockId && o.pageSlug === removed.pageSlug);
1230
+ if (!op || op.op !== "update_props")
1231
+ continue;
1232
+ const rawPatch = op.patch;
1233
+ const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
1234
+ ? rawPatch.props
1235
+ : rawPatch;
1236
+ deleteValueAtPath(patchCandidate, removed.path);
1237
+ if (removed.altPath)
1238
+ deleteValueAtPath(patchCandidate, removed.altPath);
1239
+ }
1240
+ if (unsupportedBlocks.size > 0) {
1241
+ const names = Array.from(unsupportedBlocks).join(", ");
1242
+ resolvedPlan.change_log = [
1243
+ ...resolvedPlan.change_log,
1244
+ `Note: ${names} blocks do not support images. Consider using CardGrid instead.`
1245
+ ];
1246
+ }
1247
+ }
1248
+ if (detectedImageOps.length > 0) {
1249
+ // Decide whether to hold for approval. Image resolution alone is
1250
+ // deterministic background work — if the plan is nothing but image
1251
+ // updates, auto-apply and resolve in the background so the user sees
1252
+ // "Added a spring avocados image" → shimmer → real image without a
1253
+ // redundant "approve" button. Compound plans (structural changes,
1254
+ // text updates, multi-op) keep the approval gate.
1255
+ const imageOnlyPlan = isImageOnlyUpdatePropsPlan(resolvedPlan);
1256
+ if (!imageOnlyPlan) {
1257
+ effectiveApplyMode = "plan_only";
1258
+ }
1259
+ // Strip placeholder imageUrl values from ops so preview shows current image.
1260
+ // For approval flow: values are restored to "pending" before withUnsplashHeroImage.
1261
+ // For auto-apply flow: the deferred resolution path restores them (see below).
1262
+ for (const op of resolvedPlan.ops) {
1263
+ if (op.op !== "update_props")
1264
+ continue;
1265
+ const imgOps = detectedImageOps.filter((io) => io.blockId === op.blockId && io.pageSlug === op.pageSlug);
1266
+ if (imgOps.length === 0)
1267
+ continue;
1268
+ const rawPatch = op.patch;
1269
+ const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
1270
+ ? rawPatch.props
1271
+ : rawPatch;
1272
+ for (const imgOp of imgOps) {
1273
+ if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
1274
+ deleteValueAtPath(patchCandidate, imgOp.path);
1275
+ }
1276
+ else {
1277
+ delete patchCandidate.imageUrl;
1278
+ }
1279
+ if (typeof imgOp.altPath === "string" && imgOp.altPath.length > 0) {
1280
+ deleteValueAtPath(patchCandidate, imgOp.altPath);
1281
+ }
1282
+ else {
1283
+ delete patchCandidate.imageAlt;
1284
+ }
1285
+ }
1286
+ // If stripping left the patch empty, write a shimmer placeholder so
1287
+ // applyOpsAtomically doesn't reject the op as a no-op (which aborts
1288
+ // the deferred image resolution before it can swap in the real URL).
1289
+ if (Object.keys(patchCandidate).length === 0) {
1290
+ const primary = imgOps[0];
1291
+ const placeholder = primary.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
1292
+ if (typeof primary.path === "string" && primary.path.length > 0) {
1293
+ setValueAtPath(patchCandidate, primary.path, placeholder);
1294
+ }
1295
+ else {
1296
+ patchCandidate.imageUrl = placeholder;
1297
+ }
1298
+ }
1299
+ }
1300
+ // Annotate change_log with image work.
1301
+ // Phrasing differs: approval flow uses future tense ("Will ..."),
1302
+ // auto-apply uses present tense ("Adding ...") so the summary reads
1303
+ // sensibly even though the image arrives a moment later.
1304
+ // Per prompt rule (prompts.ts: HERO_IMAGE_URL_BASE) we never mention
1305
+ // the image source (Unsplash / AI) in user-facing copy — just "image".
1306
+ for (const imgOp of detectedImageOps) {
1307
+ const query = imgOp.query && imgOp.query.trim().length > 0 ? imgOp.query.trim() : null;
1308
+ const subject = query ? `: "${query}"` : "";
1309
+ const pendingImageMessage = imageOnlyPlan
1310
+ ? `Finding an image${subject}…`
1311
+ : `Will find an image${subject}.`;
1312
+ resolvedPlan.change_log = [...resolvedPlan.change_log, pendingImageMessage];
1313
+ }
1314
+ if (imageOnlyPlan) {
1315
+ // Auto-apply path: tag the plan so the post-apply deferred block
1316
+ // resolves the real image and streams it back via onOpApplied.
1317
+ ;
1318
+ resolvedPlan._deferredImageResolution = true;
1319
+ resolvedPlan._deferredImageArgs = {
1320
+ message: plannerMessage,
1321
+ slug: effectiveSlug,
1322
+ currentPage: current,
1323
+ activeBlockId: planningActiveBlockId,
1324
+ activeEditablePath: planningActiveEditablePath,
1325
+ chatRequestId,
1326
+ gdriveFolderId,
1327
+ preferredImageOps: detectedImageOps
1328
+ };
1329
+ // Mirror the else-branch's progress markers so the editor gets instant
1330
+ // "plan ready" feedback while images resolve in the background.
1331
+ options?.onPlanMeta?.({
1332
+ intent: resolvedPlan.intent,
1333
+ summary: effectiveApplyMode === "plan_only" ? pastToFutureTense(resolvedPlan.summary_for_user) : resolvedPlan.summary_for_user,
1334
+ estimatedOps: resolvedPlan.ops.length
1335
+ });
1336
+ markFirstStructuredProgress();
1337
+ if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
1338
+ const planReadyAtMs = Date.now() - pipelineStartedAtMs;
1339
+ stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
1340
+ ctx.chatTelemetry.push({
1341
+ id: chatRequestId,
1342
+ at: new Date().toISOString(),
1343
+ phase: "milestone",
1344
+ timelineStage: "plan_ready",
1345
+ session: body.session,
1346
+ requestedSlug,
1347
+ effectiveSlug,
1348
+ plannerSource: source,
1349
+ modelKey,
1350
+ modelUsed,
1351
+ promptHash,
1352
+ promptExcerpt,
1353
+ promptLength: plannerMessage.length,
1354
+ totalDurationMs: planReadyAtMs
1355
+ });
1356
+ }
1357
+ }
1358
+ }
1359
+ else {
1360
+ // Emit plan metadata and progress markers so the UI gets instant feedback
1361
+ // while images resolve in the background after ops are applied.
1362
+ options?.onPlanMeta?.({
1363
+ intent: resolvedPlan.intent,
1364
+ summary: resolvedPlan.summary_for_user,
1365
+ estimatedOps: resolvedPlan.ops.length
1366
+ });
1367
+ markFirstStructuredProgress();
1368
+ if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
1369
+ const planReadyAtMs = Date.now() - pipelineStartedAtMs;
1370
+ stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
1371
+ ctx.chatTelemetry.push({
1372
+ id: chatRequestId,
1373
+ at: new Date().toISOString(),
1374
+ phase: "milestone",
1375
+ timelineStage: "plan_ready",
1376
+ session: body.session,
1377
+ requestedSlug,
1378
+ effectiveSlug,
1379
+ plannerSource: source,
1380
+ modelKey,
1381
+ modelUsed,
1382
+ promptHash,
1383
+ promptExcerpt,
1384
+ promptLength: plannerMessage.length,
1385
+ totalDurationMs: planReadyAtMs
1386
+ });
1387
+ }
1388
+ // Defer image resolution: apply text/structural ops immediately, then
1389
+ // resolve images in the background and patch them in via follow-up events.
1390
+ // This avoids blocking the user for 1-15s while images are fetched/generated.
1391
+ const deferImageResolution = !/^(0|false|no|off)$/i.test((process.env.CHAT_DEFER_IMAGE_RESOLUTION ?? "1").trim());
1392
+ if (!usedNativeUnsplashTool && !usedNativeImageTool && deferImageResolution) {
1393
+ // Tag the plan with deferred image metadata so the apply step can
1394
+ // resolve images after text ops are visible.
1395
+ ;
1396
+ resolvedPlan._deferredImageResolution = true;
1397
+ resolvedPlan._deferredImageArgs = {
1398
+ message: plannerMessage,
1399
+ slug: effectiveSlug,
1400
+ currentPage: current,
1401
+ activeBlockId: planningActiveBlockId,
1402
+ activeEditablePath: planningActiveEditablePath,
1403
+ chatRequestId,
1404
+ gdriveFolderId
1405
+ };
1406
+ }
1407
+ else if (!usedNativeUnsplashTool && !usedNativeImageTool) {
1408
+ // Legacy blocking path (deferred disabled via env)
1409
+ const imageResolutionStartMs = Date.now();
1410
+ emitStatusTone("resolving_assets");
1411
+ resolvedPlan = await withUnsplashHeroImage({
1412
+ plan: resolvedPlan,
1413
+ message: plannerMessage,
1414
+ slug: effectiveSlug,
1415
+ currentPage: current,
1416
+ activeBlockId: planningActiveBlockId,
1417
+ activeEditablePath: planningActiveEditablePath,
1418
+ chatRequestId,
1419
+ gdriveFolderId,
1420
+ log: ctx.log,
1421
+ onStatusUpdate: options?.onStatusUpdate,
1422
+ onImageProgress: options?.onImageProgress
1423
+ });
1424
+ imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
1425
+ }
1426
+ }
1427
+ if (resolvedPlan.intent === "needs_clarification" && planningActiveBlockId) {
1428
+ const focusedFallback = compileDeterministicPlan({
1429
+ session: body.session ?? "dev",
1430
+ intent: { action: "clarify" },
1431
+ message: plannerMessage,
1432
+ slug: effectiveSlug ?? "/",
1433
+ currentPage: current,
1434
+ activeBlockId: planningActiveBlockId,
1435
+ activeEditablePath: planningActiveEditablePath,
1436
+ locale: body.locale
1437
+ });
1438
+ if (focusedFallback?.intent === "edit_plan" && focusedFallback.ops.length > 0) {
1439
+ resolvedPlan = focusedFallback;
1440
+ }
1441
+ }
1442
+ }
1443
+ if (resolvedPlan.intent === "needs_clarification" && body.message && isBlockCatalogQuery(body.message)) {
1444
+ const forcedInfo = infoResponse({ body, current, plannerSource: source, modelUsed, modelKey });
1445
+ return { done: true, response: forcedInfo };
1446
+ }
1447
+ if (translationScope === "page") {
1448
+ const translationCoverageGap = findFullPageTranslationCoverageGap({
1449
+ plan: resolvedPlan,
1450
+ message: plannerMessage,
1451
+ currentPage: current,
1452
+ slug: effectiveSlug
1453
+ });
1454
+ if (translationCoverageGap)
1455
+ return { done: false, reason: translationCoverageGap };
1456
+ }
1457
+ const explicitCtaCoverageGap = findExplicitCtaTargetCoverageGap({
1458
+ plan: resolvedPlan,
1459
+ message: plannerMessage,
1460
+ currentPage: current,
1461
+ slug: effectiveSlug
1462
+ });
1463
+ if (explicitCtaCoverageGap)
1464
+ return { done: false, reason: explicitCtaCoverageGap };
1465
+ // Tier-1 destructive-action gate. Hold any edit_plan containing destructive
1466
+ // ops (remove_page on a page with content, multi-page scope, bulk deletes)
1467
+ // for explicit approval — undo protects recovery but not accidental intent.
1468
+ // Skip when preResolvedPlan is set: that flag means the plan came from
1469
+ // apply_pending_plan (user already clicked Approve) and must not loop back.
1470
+ let destructiveReasons = [];
1471
+ if (resolvedPlan.intent === "edit_plan" && resolvedPlan.ops.length > 0 && !optionsOverride?.preResolvedPlan) {
1472
+ const destructiveEval = evaluateDestructiveActions(resolvedPlan, (slug) => getPage(body.session, slug));
1473
+ if (destructiveEval.requiresApproval) {
1474
+ effectiveApplyMode = "plan_only";
1475
+ destructiveReasons = destructiveEval.messages;
1476
+ }
1477
+ }
1478
+ // Emit plan metadata if not already emitted (deferred image path and other branches skip the early emit above)
1479
+ if (!stageTimeline.some((item) => item.stage === "first_structured_progress")) {
1480
+ options?.onPlanMeta?.({
1481
+ intent: resolvedPlan.intent,
1482
+ summary: effectiveApplyMode === "plan_only" ? pastToFutureTense(resolvedPlan.summary_for_user) : resolvedPlan.summary_for_user,
1483
+ estimatedOps: resolvedPlan.ops.length
1484
+ });
1485
+ }
1486
+ markFirstStructuredProgress();
1487
+ if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
1488
+ const planReadyAtMs = Date.now() - pipelineStartedAtMs;
1489
+ stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
1490
+ ctx.chatTelemetry.push({
1491
+ id: chatRequestId,
1492
+ at: new Date().toISOString(),
1493
+ phase: "milestone",
1494
+ timelineStage: "plan_ready",
1495
+ session: body.session,
1496
+ requestedSlug,
1497
+ effectiveSlug,
1498
+ plannerSource: source,
1499
+ modelKey,
1500
+ modelUsed,
1501
+ promptHash,
1502
+ promptExcerpt,
1503
+ promptLength: plannerMessage.length,
1504
+ totalDurationMs: planReadyAtMs
1505
+ });
1506
+ }
1507
+ if (resolvedPlan.intent === "content_answer") {
1508
+ ctx.chatTelemetry.push({
1509
+ id: chatRequestId,
1510
+ at: new Date().toISOString(),
1511
+ phase: "result",
1512
+ session: body.session,
1513
+ requestedSlug,
1514
+ effectiveSlug,
1515
+ plannerSource: source,
1516
+ modelKey,
1517
+ modelUsed,
1518
+ promptHash,
1519
+ promptExcerpt,
1520
+ promptLength: plannerMessage.length,
1521
+ outcome: "content_answer",
1522
+ intent: resolvedPlan.intent,
1523
+ opCount: 0,
1524
+ opTypes: [],
1525
+ plannerTier,
1526
+ ...plannerContextTelemetryFields,
1527
+ ...timingFields()
1528
+ });
1529
+ if (body.message)
1530
+ pushChatHistory(body.session, body.message, resolvedPlan.summary_for_user);
1531
+ return {
1532
+ done: true,
1533
+ response: {
1534
+ code: 200,
1535
+ payload: withDebugPayload({
1536
+ status: "info",
1537
+ summary: resolvedPlan.summary_for_user,
1538
+ changes: resolvedPlan.change_log,
1539
+ mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
1540
+ suggestions: resolvedPlan.suggested_next_actions ?? [],
1541
+ previewVersion: versions.get(body.session) ?? 0,
1542
+ plannerSource: source,
1543
+ modelUsed,
1544
+ modelKey
1545
+ }, {
1546
+ outcome: "content_answer",
1547
+ intent: resolvedPlan.intent,
1548
+ opCount: 0,
1549
+ opTypes: [],
1550
+ ...usageFields
1551
+ })
1552
+ }
1553
+ };
1554
+ }
1555
+ if (resolvedPlan.intent === "needs_clarification") {
1556
+ // If the message already had a clarification suffix and still failed, clear the
1557
+ // pending context — the clarification chain is not converging and further stacking
1558
+ // will only produce incoherent prompts.
1559
+ const alreadyHadClarification = plannerMessage.includes("\nClarification from user:");
1560
+ if (alreadyHadClarification) {
1561
+ pendingClarificationBySession.delete(body.session);
1562
+ }
1563
+ else {
1564
+ pendingClarificationBySession.set(body.session, { baseRequest: plannerMessage, updatedAt: new Date().toISOString() });
1565
+ }
1566
+ ctx.chatTelemetry.push({
1567
+ id: chatRequestId,
1568
+ at: new Date().toISOString(),
1569
+ phase: "result",
1570
+ session: body.session,
1571
+ requestedSlug,
1572
+ effectiveSlug,
1573
+ plannerSource: source,
1574
+ modelKey,
1575
+ modelUsed,
1576
+ promptHash,
1577
+ promptExcerpt,
1578
+ promptLength: plannerMessage.length,
1579
+ outcome: "needs_clarification",
1580
+ intent: resolvedPlan.intent,
1581
+ opCount: resolvedPlan.ops.length,
1582
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1583
+ plannerTier,
1584
+ ...plannerContextTelemetryFields,
1585
+ ...timingFields()
1586
+ });
1587
+ if (body.message)
1588
+ pushChatHistory(body.session, body.message, resolvedPlan.summary_for_user);
1589
+ const selected = planningActiveBlockId && current.blocks.find((b) => b.id === planningActiveBlockId)
1590
+ ? current.blocks.find((b) => b.id === planningActiveBlockId)
1591
+ : null;
1592
+ return {
1593
+ done: true,
1594
+ response: {
1595
+ code: 200,
1596
+ payload: withDebugPayload({
1597
+ status: "needs_clarification",
1598
+ summary: resolvedPlan.summary_for_user,
1599
+ changes: resolvedPlan.change_log,
1600
+ mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
1601
+ suggestions: resolvedPlan.suggested_next_actions ?? clarificationSuggestions({ body, current, selected }),
1602
+ previewVersion: versions.get(body.session) ?? 0,
1603
+ plannerSource: source,
1604
+ modelUsed,
1605
+ modelKey
1606
+ }, {
1607
+ outcome: "needs_clarification",
1608
+ intent: resolvedPlan.intent,
1609
+ opCount: resolvedPlan.ops.length,
1610
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1611
+ ...usageFields
1612
+ })
1613
+ }
1614
+ };
1615
+ }
1616
+ if (effectiveApplyMode === "plan_only") {
1617
+ // Dry-run the held plan up front to build the structured before→after
1618
+ // preview the approval card renders AND to learn whether it changes anything.
1619
+ // Best-effort: a preview failure must never block showing the plan.
1620
+ let planPreview;
1621
+ try {
1622
+ const dry = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest, dryRun: true });
1623
+ if (dry.preview && dry.opResults) {
1624
+ planPreview = {
1625
+ opResults: dry.opResults,
1626
+ diff: dry.preview,
1627
+ appliedCount: dry.opResults.filter((r) => r.status === "applied").length,
1628
+ skippedCount: dry.opResults.filter((r) => r.status === "skipped").length,
1629
+ failedCount: dry.opResults.filter((r) => r.status === "failed").length,
1630
+ };
1631
+ }
1632
+ }
1633
+ catch (previewError) {
1634
+ ctx.log.warn({ err: String(previewError) }, "[chat] plan preview dry-run failed; falling back to change_log");
1635
+ }
1636
+ // Every op was a no-op — an unsupported field stripped to an empty patch, or
1637
+ // values already set. The plan changes nothing, so don't offer it for approval
1638
+ // with a "Will change X" summary the draft never honours (#23). Return an
1639
+ // honest no-effective-change response instead.
1640
+ if (planPreview && planPreview.appliedCount === 0 && planPreview.failedCount === 0 && planPreview.skippedCount > 0) {
1641
+ pendingApprovalPlanBySession.delete(body.session);
1642
+ const onlyUnchanged = planPreview.opResults.every((r) => r.status !== "skipped" || r.reason === "unchanged_value");
1643
+ const noopSummary = onlyUnchanged
1644
+ ? "No changes needed — that content is already set."
1645
+ : "I couldn't make that change — it isn't supported on this block. Try a different field or wording.";
1646
+ ctx.chatTelemetry.push({
1647
+ id: chatRequestId,
1648
+ at: new Date().toISOString(),
1649
+ phase: "result",
1650
+ session: body.session,
1651
+ requestedSlug,
1652
+ effectiveSlug,
1653
+ plannerSource: source,
1654
+ modelKey,
1655
+ modelUsed,
1656
+ promptHash,
1657
+ promptExcerpt,
1658
+ promptLength: plannerMessage.length,
1659
+ outcome: "no_effective_change",
1660
+ intent: resolvedPlan.intent,
1661
+ opCount: resolvedPlan.ops.length,
1662
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1663
+ skippedOpCount: planPreview.skippedCount,
1664
+ plannerTier,
1665
+ ...plannerContextTelemetryFields,
1666
+ ...timingFields()
1667
+ });
1668
+ return {
1669
+ done: true,
1670
+ response: {
1671
+ code: 200,
1672
+ payload: withDebugPayload({
1673
+ status: "info",
1674
+ summary: noopSummary,
1675
+ changes: [],
1676
+ mentionedSlugs: [effectiveSlug],
1677
+ previewVersion: versions.get(body.session) ?? 0,
1678
+ plannerSource: source,
1679
+ modelUsed,
1680
+ modelKey
1681
+ }, { outcome: "no_effective_change", plannerTier })
1682
+ }
1683
+ };
1684
+ }
1685
+ const pendingPlanId = randomUUID();
1686
+ // Planner prompts instruct past tense on the assumption ops are applied
1687
+ // immediately, but the approval gate holds the plan. Flip to future tense
1688
+ // so the copy matches the "Approve plan" UX.
1689
+ const approvalSummary = pastToFutureTense(resolvedPlan.summary_for_user);
1690
+ const approvalChangeLog = resolvedPlan.change_log.map(pastToFutureTense);
1691
+ pendingApprovalPlanBySession.set(body.session, {
1692
+ id: pendingPlanId,
1693
+ createdAt: new Date().toISOString(),
1694
+ promptHash,
1695
+ requestedSlug,
1696
+ effectiveSlug,
1697
+ summary: resolvedPlan.summary_for_user,
1698
+ source,
1699
+ modelUsed,
1700
+ modelKey,
1701
+ plan: structuredClone(resolvedPlan),
1702
+ originalMessage: plannerMessage,
1703
+ ...(detectedImageOps.length > 0 ? { pendingImageOps: detectedImageOps } : {}),
1704
+ ...(destructiveReasons.length > 0 ? { destructiveReasons } : {})
1705
+ });
1706
+ ctx.chatTelemetry.push({
1707
+ id: chatRequestId,
1708
+ at: new Date().toISOString(),
1709
+ phase: "result",
1710
+ session: body.session,
1711
+ requestedSlug,
1712
+ effectiveSlug,
1713
+ plannerSource: source,
1714
+ modelKey,
1715
+ modelUsed,
1716
+ promptHash,
1717
+ promptExcerpt,
1718
+ promptLength: plannerMessage.length,
1719
+ outcome: "plan_ready_for_approval",
1720
+ intent: resolvedPlan.intent,
1721
+ opCount: resolvedPlan.ops.length,
1722
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1723
+ plannerTier,
1724
+ ...plannerContextTelemetryFields,
1725
+ ...timingFields()
1726
+ });
1727
+ // Filter out slugs that don't exist yet — for plan_only the page hasn't
1728
+ // been applied, so an "Open /season-recipes" navigation chip would 404.
1729
+ // The full mentioned list is restored after the user approves the plan.
1730
+ const planOnlyDraft = getSessionDraft(body.session);
1731
+ const planOnlyMentionedSlugs = collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug)
1732
+ .filter((slug) => planOnlyDraft.has(slug));
1733
+ // planPreview was computed at the top of this branch (the no-op guard needs it).
1734
+ return {
1735
+ done: true,
1736
+ response: {
1737
+ code: 200,
1738
+ payload: withDebugPayload({
1739
+ status: "plan_ready",
1740
+ summary: approvalSummary,
1741
+ changes: approvalChangeLog,
1742
+ mentionedSlugs: planOnlyMentionedSlugs,
1743
+ previewVersion: versions.get(body.session) ?? 0,
1744
+ plannerSource: source,
1745
+ modelUsed,
1746
+ modelKey,
1747
+ pendingPlanId,
1748
+ ...(planPreview ? { planPreview } : {}),
1749
+ ...(destructiveReasons.length > 0 ? { destructiveReasons } : {})
1750
+ }, {
1751
+ outcome: "plan_ready_for_approval",
1752
+ intent: resolvedPlan.intent,
1753
+ opCount: resolvedPlan.ops.length,
1754
+ opTypes: resolvedPlan.ops.map((op) => op.op),
1755
+ ...usageFields
1756
+ })
1757
+ }
1758
+ };
1759
+ }
1760
+ const previous = getPage(body.session, effectiveSlug);
1761
+ const hasCreatePage = resolvedPlan.ops.some((op) => op.op === "create_page");
1762
+ if (!previous && !hasCreatePage) {
1763
+ return {
1764
+ done: true,
1765
+ response: { code: 404, payload: { error: "page not found" } }
1766
+ };
1767
+ }
1768
+ if (resolvedPlan.ops.length === 0) {
1769
+ pendingClarificationBySession.delete(body.session);
1770
+ pendingApprovalPlanBySession.delete(body.session);
1771
+ ctx.chatTelemetry.push({
1772
+ id: chatRequestId,
1773
+ at: new Date().toISOString(),
1774
+ phase: "result",
1775
+ session: body.session,
1776
+ requestedSlug,
1777
+ effectiveSlug,
1778
+ plannerSource: source,
1779
+ modelKey,
1780
+ modelUsed,
1781
+ promptHash,
1782
+ promptExcerpt,
1783
+ promptLength: plannerMessage.length,
1784
+ outcome: "no_effective_change",
1785
+ intent: resolvedPlan.intent,
1786
+ opCount: 0,
1787
+ opTypes: [],
1788
+ plannerTier,
1789
+ ...plannerContextTelemetryFields,
1790
+ ...timingFields()
1791
+ });
1792
+ return {
1793
+ done: true,
1794
+ response: {
1795
+ code: 200,
1796
+ payload: withDebugPayload({
1797
+ status: "applied",
1798
+ summary: "No changes needed. That content is already up to date.",
1799
+ changes: [],
1800
+ mentionedSlugs: [effectiveSlug],
1801
+ previewVersion: versions.get(body.session) ?? 0,
1802
+ plannerSource: source,
1803
+ modelUsed,
1804
+ modelKey
1805
+ }, { outcome: "no_effective_change", plannerTier })
1806
+ }
1807
+ };
1808
+ }
1809
+ if (siteCapabilities?.allowStructuralEdits === false && resolvedPlan.ops.some((op) => isStructuralOperation(op))) {
1810
+ const reason = typeof siteCapabilities.reason === "string" && siteCapabilities.reason.trim().length > 0
1811
+ ? ` ${siteCapabilities.reason.trim()}`
1812
+ : "";
1813
+ return {
1814
+ done: true,
1815
+ response: {
1816
+ code: 200,
1817
+ payload: withDebugPayload({
1818
+ status: "needs_clarification",
1819
+ summary: `Structural edits are disabled for this site context.${reason}`,
1820
+ changes: ["Expose GET /api/editor/blocks with a valid manifest to enable structural operations."],
1821
+ mentionedSlugs: [effectiveSlug],
1822
+ previewVersion: versions.get(body.session) ?? 0,
1823
+ plannerSource: source,
1824
+ modelUsed,
1825
+ modelKey
1826
+ }, { outcome: "blocked_structural_capability" })
1827
+ }
1828
+ };
1829
+ }
1830
+ // Demo-mode guard: returns a friendly "needs_clarification" before we
1831
+ // hit the ops-engine hard gate. The engine gate still runs as a safety
1832
+ // net (`enforceDemoOps` in ops-engine.ts) — this one exists purely for
1833
+ // UX so the user sees an example prompt instead of a generic error.
1834
+ if (isDemoModeEnabled()) {
1835
+ const stagedForDemo = new Map();
1836
+ for (const [slug, page] of getSessionDraft(body.session)) {
1837
+ stagedForDemo.set(slug, page);
1838
+ }
1839
+ const demoSplit = splitDemoOps(resolvedPlan.ops, stagedForDemo);
1840
+ if (demoSplit.rejected.length > 0) {
1841
+ const allowedBlockLabel = getDemoAllowedBlockTypes().join(" / ");
1842
+ return {
1843
+ done: true,
1844
+ response: {
1845
+ code: 200,
1846
+ payload: withDebugPayload({
1847
+ status: "needs_clarification",
1848
+ summary: `This demo only supports editing the ${allowedBlockLabel} section. Try a hero-focused request — everything else is locked for now.`,
1849
+ changes: [
1850
+ "Try: \"change the hero headline to Welcome to my new site\"",
1851
+ "Try: \"make the hero subheading sound more playful\"",
1852
+ "Try: \"update the hero CTA to Get Started\""
1853
+ ],
1854
+ mentionedSlugs: [effectiveSlug],
1855
+ previewVersion: versions.get(body.session) ?? 0,
1856
+ plannerSource: source,
1857
+ modelUsed,
1858
+ modelKey
1859
+ }, { outcome: "blocked_demo_mode" })
1860
+ }
1861
+ };
1862
+ }
1863
+ }
1864
+ let applyStartedAtMs = null;
1865
+ let skippedOps = [];
1866
+ // When ops were already applied incrementally during LLM streaming (preApplied),
1867
+ // skip the validation/apply step and go straight to post-apply bookkeeping.
1868
+ const skipApply = Boolean(optionsOverride?.preApplied);
1869
+ try {
1870
+ if (!skipApply) {
1871
+ // Validate ops against the canonical Zod schema at the NLP → ops-engine
1872
+ // boundary. Post-planner transforms (image rewriting, UI normalization)
1873
+ // could theoretically produce malformed ops; this catches them early.
1874
+ validateOperations(resolvedPlan.ops);
1875
+ emitStatusTone("applying");
1876
+ applyStartedAtMs = Date.now();
1877
+ const hasPageStructuralOps = resolvedPlan.ops.some((op) => op.op === "create_page" || op.op === "rename_page" || op.op === "remove_page" || op.op === "move_page" || op.op === "duplicate_page");
1878
+ if (incrementalApplyEnabled && options?.onOpApplied && !hasPageStructuralOps) {
1879
+ // Snapshot the entire session draft so we can restore it wholesale after preflight.
1880
+ const sessionDraft = getSessionDraft(body.session);
1881
+ const preFlightSnapshot = new Map();
1882
+ for (const [slug, page] of sessionDraft) {
1883
+ preFlightSnapshot.set(slug, structuredClone(page));
1884
+ }
1885
+ // Validate the whole plan from the current state before progressive apply.
1886
+ const preflight = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest });
1887
+ skippedOps = preflight.skippedOps;
1888
+ // Restore pre-apply state wholesale so we can replay ops progressively.
1889
+ sessionDraft.clear();
1890
+ for (const [slug, page] of preFlightSnapshot) {
1891
+ setPage(body.session, structuredClone(page));
1892
+ }
1893
+ const total = resolvedPlan.ops.length;
1894
+ try {
1895
+ for (let index = 0; index < total; index += 1) {
1896
+ throwIfCanceled(options?.signal);
1897
+ const stepStartedAtMs = Date.now();
1898
+ const op = resolvedPlan.ops[index];
1899
+ // allowNoEffectiveChange: applying one op at a time, an op that matches
1900
+ // existing values is a benign skip — not a plan failure. Otherwise the
1901
+ // engine throws no_effective_change, which the catch below treats as a
1902
+ // hard error and rolls back the ENTIRE plan (discarding the ops that did
1903
+ // change). Common with whole-page translate when some blocks are already
1904
+ // in the target language.
1905
+ const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
1906
+ if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
1907
+ const skipReason = stepResult.skippedOps[0]?.reason ?? "unchanged_value";
1908
+ options?.onOpSkipped?.({
1909
+ index: index + 1,
1910
+ total,
1911
+ op,
1912
+ reason: skipReason
1913
+ });
1914
+ continue;
1915
+ }
1916
+ if (firstApplyMs === null) {
1917
+ firstApplyMs = Date.now() - pipelineStartedAtMs;
1918
+ stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
1919
+ ctx.chatTelemetry.push({
1920
+ id: chatRequestId,
1921
+ at: new Date().toISOString(),
1922
+ phase: "milestone",
1923
+ timelineStage: "first_op_applied",
1924
+ session: body.session,
1925
+ requestedSlug,
1926
+ effectiveSlug,
1927
+ plannerSource: source,
1928
+ modelKey,
1929
+ modelUsed,
1930
+ promptHash,
1931
+ promptExcerpt,
1932
+ promptLength: plannerMessage.length,
1933
+ totalDurationMs: firstApplyMs
1934
+ });
1935
+ }
1936
+ const previewVersion = bumpVersion(body.session);
1937
+ options.onOpApplied({
1938
+ index: index + 1,
1939
+ total,
1940
+ op,
1941
+ previewVersion,
1942
+ focusBlockId: pickFocusBlockId([op])
1943
+ });
1944
+ if (streamedApplyMinStepMs > 0 && total > 1 && index < total - 1) {
1945
+ const stepElapsedMs = Date.now() - stepStartedAtMs;
1946
+ const remainingMs = streamedApplyMinStepMs - stepElapsedMs;
1947
+ if (remainingMs > 0)
1948
+ await sleepMs(remainingMs);
1949
+ }
1950
+ }
1951
+ }
1952
+ catch (progressiveError) {
1953
+ if (isCancelError(progressiveError))
1954
+ throw progressiveError;
1955
+ options?.onRollbackStarted?.({ appliedCount: total, reason: toErrorDetail(progressiveError) });
1956
+ // Roll back to pre-plan state so we don't leave a partially-applied plan.
1957
+ sessionDraft.clear();
1958
+ for (const [slug, page] of preFlightSnapshot) {
1959
+ setPage(body.session, structuredClone(page));
1960
+ }
1961
+ options?.onRollbackDone?.({ restoredVersion: versions.get(body.session) ?? 0 });
1962
+ throw progressiveError;
1963
+ }
1964
+ }
1965
+ else {
1966
+ const applyResult = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest });
1967
+ skippedOps = applyResult.skippedOps;
1968
+ if (applyResult.appliedCount > 0 && firstApplyMs === null) {
1969
+ firstApplyMs = Date.now() - pipelineStartedAtMs;
1970
+ stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
1971
+ ctx.chatTelemetry.push({
1972
+ id: chatRequestId,
1973
+ at: new Date().toISOString(),
1974
+ phase: "milestone",
1975
+ timelineStage: "first_op_applied",
1976
+ session: body.session,
1977
+ requestedSlug,
1978
+ effectiveSlug,
1979
+ plannerSource: source,
1980
+ modelKey,
1981
+ modelUsed,
1982
+ promptHash,
1983
+ promptExcerpt,
1984
+ promptLength: plannerMessage.length,
1985
+ totalDurationMs: firstApplyMs
1986
+ });
1987
+ }
1988
+ // Deferred image resolution for create_page: emit op_applied immediately
1989
+ // so the editor navigates to the new page with a shimmer placeholder,
1990
+ // then resolve the real image and patch it in.
1991
+ const deferredImages = resolvedPlan._deferredCreatePageImages;
1992
+ if (deferredImages && deferredImages.length > 0 && options?.onOpApplied) {
1993
+ const deferredUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
1994
+ const immediatePreviewVersion = bumpVersion(body.session);
1995
+ const createOp = resolvedPlan.ops.find((op) => op.op === "create_page");
1996
+ options.onOpApplied({
1997
+ index: 1,
1998
+ total: 1,
1999
+ op: createOp ?? resolvedPlan.ops[0],
2000
+ previewVersion: immediatePreviewVersion,
2001
+ focusBlockId: pickFocusBlockId(resolvedPlan.ops),
2002
+ updatedSlug: deferredUpdatedSlug
2003
+ });
2004
+ // Resolve each deferred image and patch the Hero block
2005
+ for (const deferred of deferredImages) {
2006
+ throwIfCanceled(options?.signal);
2007
+ try {
2008
+ const deferredImageStart = Date.now();
2009
+ const imageResult = await raceCancel(resolveHeroImageForCreatePage({
2010
+ query: deferred.query,
2011
+ pageTitle: deferred.pageTitle,
2012
+ pageSlug: deferred.pageSlug,
2013
+ sectionContext: deferred.sectionContext,
2014
+ chatRequestId,
2015
+ gdriveFolderId,
2016
+ log: ctx.log,
2017
+ onStatusUpdate: options.onStatusUpdate,
2018
+ onImageProgress: options?.onImageProgress
2019
+ }), options?.signal);
2020
+ const deferredImageDurationMs = Date.now() - deferredImageStart;
2021
+ if (imageResult) {
2022
+ const patchOp = {
2023
+ op: "update_props",
2024
+ pageSlug: deferred.pageSlug,
2025
+ blockId: deferred.blockId,
2026
+ patch: { props: { imageUrl: imageResult.url, imageAlt: imageResult.alt } }
2027
+ };
2028
+ await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
2029
+ const patchVersion = bumpVersion(body.session);
2030
+ options.onOpApplied({
2031
+ index: 1,
2032
+ total: 1,
2033
+ op: patchOp,
2034
+ previewVersion: patchVersion,
2035
+ focusBlockId: deferred.blockId
2036
+ });
2037
+ const deferredBlockType = getPage(body.session, deferred.pageSlug)?.blocks?.find((b) => b.id === deferred.blockId)?.type ?? "block";
2038
+ resolvedPlan.change_log = [...resolvedPlan.change_log, `Generated AI image for ${deferredBlockType} on ${fmtSlug(deferred.pageSlug)}`];
2039
+ ctx.log.info({ event: "deferred_hero_image_resolved", chatRequestId, pageSlug: deferred.pageSlug, blockId: deferred.blockId, source: imageResult.source, durationMs: deferredImageDurationMs }, `Deferred hero image resolved and applied in ${deferredImageDurationMs}ms`);
2040
+ }
2041
+ }
2042
+ catch (err) {
2043
+ cleanupImagePlaceholders(body.session);
2044
+ if (isCancelError(err))
2045
+ throw err;
2046
+ ctx.log.warn({ event: "deferred_hero_image_failed", chatRequestId, pageSlug: deferred.pageSlug, blockId: deferred.blockId, error: toErrorDetail(err) }, "Deferred hero image resolution failed — page remains with placeholder");
2047
+ options?.onStatusUpdate?.("Image generation failed — you can try again or set an image manually.");
2048
+ }
2049
+ }
2050
+ }
2051
+ }
2052
+ // Deferred image resolution for non-create_page ops: resolve images after
2053
+ // text/structural ops are already visible in the preview, then patch them in.
2054
+ const deferredImageArgs = resolvedPlan._deferredImageArgs;
2055
+ if (resolvedPlan._deferredImageResolution && deferredImageArgs && options?.onOpApplied && deferredNativeImageCalls.length === 0) {
2056
+ try {
2057
+ const imageResolutionStartMs = Date.now();
2058
+ emitStatusTone("resolving_assets");
2059
+ const preferredImageOps = deferredImageArgs.preferredImageOps ?? [];
2060
+ // When auto-apply runs for an image-only plan, the original patches
2061
+ // had their imageUrl/imageAlt stripped. Apply shimmer placeholders
2062
+ // so the preview shows "Searching…" / "Generating…" while we resolve.
2063
+ // This mirrors the approval-flow UX in chat-pipeline.ts (pending plan block).
2064
+ if (preferredImageOps.length > 0) {
2065
+ const shimmerOps = preferredImageOps.map((imgOp) => {
2066
+ const placeholder = imgOp.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
2067
+ const patchProps = {};
2068
+ if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
2069
+ setValueAtPath(patchProps, imgOp.path, placeholder);
2070
+ }
2071
+ else {
2072
+ patchProps.imageUrl = placeholder;
2073
+ }
2074
+ return { op: "update_props", pageSlug: imgOp.pageSlug, blockId: imgOp.blockId, patch: { props: patchProps } };
2075
+ });
2076
+ try {
2077
+ await applyOpsAtomically(body.session, shimmerOps, { componentsManifest });
2078
+ const shimmerVersion = bumpVersion(body.session);
2079
+ const firstShimmer = shimmerOps[0];
2080
+ options.onOpApplied({
2081
+ index: 1,
2082
+ total: 1,
2083
+ op: firstShimmer,
2084
+ previewVersion: shimmerVersion,
2085
+ focusBlockId: firstShimmer.op === "update_props" ? firstShimmer.blockId : undefined
2086
+ });
2087
+ }
2088
+ catch (shimmerErr) {
2089
+ ctx.log.debug({ event: "deferred_image_shimmer_failed", chatRequestId, error: toErrorDetail(shimmerErr) }, "Shimmer placeholder injection failed; continuing with resolution");
2090
+ }
2091
+ }
2092
+ // Restore "pending" imageUrl/imageAlt into ops whose patch was stripped,
2093
+ // so withUnsplashHeroImage's `hasImageUrlInPatch` check passes and image
2094
+ // resolution actually runs for these ops.
2095
+ const planForResolve = structuredClone(resolvedPlan);
2096
+ for (const imgOp of preferredImageOps) {
2097
+ const op = planForResolve.ops.find((o) => o.op === "update_props" && o.blockId === imgOp.blockId && o.pageSlug === imgOp.pageSlug);
2098
+ if (!op || op.op !== "update_props")
2099
+ continue;
2100
+ const rawPatch = op.patch;
2101
+ const patchTarget = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
2102
+ ? rawPatch.props
2103
+ : rawPatch;
2104
+ if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
2105
+ if (getValueAtPath(patchTarget, imgOp.path) === undefined) {
2106
+ setValueAtPath(patchTarget, imgOp.path, "pending");
2107
+ }
2108
+ }
2109
+ else if (!Object.prototype.hasOwnProperty.call(patchTarget, "imageUrl")) {
2110
+ patchTarget.imageUrl = "pending";
2111
+ }
2112
+ }
2113
+ const resolvedImagePlan = await raceCancel(withUnsplashHeroImage({
2114
+ plan: preferredImageOps.length > 0 ? planForResolve : resolvedPlan,
2115
+ message: deferredImageArgs.message,
2116
+ slug: deferredImageArgs.slug,
2117
+ currentPage: deferredImageArgs.currentPage,
2118
+ preferredImageOps: preferredImageOps.length > 0 ? preferredImageOps : undefined,
2119
+ activeBlockId: deferredImageArgs.activeBlockId,
2120
+ activeEditablePath: deferredImageArgs.activeEditablePath,
2121
+ chatRequestId: deferredImageArgs.chatRequestId,
2122
+ gdriveFolderId: deferredImageArgs.gdriveFolderId,
2123
+ log: ctx.log,
2124
+ onStatusUpdate: options?.onStatusUpdate,
2125
+ onImageProgress: options?.onImageProgress
2126
+ }), options?.signal);
2127
+ imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
2128
+ // Find image-bearing ops that changed between the original and resolved plan
2129
+ for (const resolvedOp of resolvedImagePlan.ops) {
2130
+ if (resolvedOp.op !== "update_props")
2131
+ continue;
2132
+ const origOp = resolvedPlan.ops.find((o) => o.op === "update_props" && o.blockId === resolvedOp.blockId && o.pageSlug === resolvedOp.pageSlug);
2133
+ const resolvedPatch = resolvedOp.patch;
2134
+ const origPatch = origOp && origOp.op === "update_props" ? origOp.patch : {};
2135
+ const resolvedProps = (typeof resolvedPatch.props === "object" && resolvedPatch.props !== null && !Array.isArray(resolvedPatch.props))
2136
+ ? resolvedPatch.props : resolvedPatch;
2137
+ const origProps = (typeof origPatch.props === "object" && origPatch.props !== null && !Array.isArray(origPatch.props))
2138
+ ? origPatch.props : origPatch;
2139
+ const imageUrl = resolvedProps.imageUrl;
2140
+ if (typeof imageUrl === "string" && imageUrl.length > 0 && imageUrl !== origProps.imageUrl) {
2141
+ const patchOp = {
2142
+ op: "update_props",
2143
+ pageSlug: resolvedOp.pageSlug,
2144
+ blockId: resolvedOp.blockId,
2145
+ patch: {
2146
+ props: {
2147
+ imageUrl,
2148
+ ...(typeof resolvedProps.imageAlt === "string" ? { imageAlt: resolvedProps.imageAlt } : {})
2149
+ }
2150
+ }
2151
+ };
2152
+ await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
2153
+ const patchVersion = bumpVersion(body.session);
2154
+ options.onOpApplied({
2155
+ index: 1,
2156
+ total: 1,
2157
+ op: patchOp,
2158
+ previewVersion: patchVersion,
2159
+ focusBlockId: resolvedOp.blockId
2160
+ });
2161
+ const resolvedBlockType = getPage(body.session, resolvedOp.pageSlug)?.blocks?.find((b) => b.id === resolvedOp.blockId)?.type ?? "block";
2162
+ resolvedPlan.change_log = [...resolvedPlan.change_log, `Resolved image for ${resolvedBlockType} on ${fmtSlug(resolvedOp.pageSlug)}`];
2163
+ }
2164
+ }
2165
+ }
2166
+ catch (deferredErr) {
2167
+ cleanupImagePlaceholders(body.session);
2168
+ if (isCancelError(deferredErr))
2169
+ throw deferredErr;
2170
+ ctx.log.warn({ event: "deferred_image_resolution_failed", chatRequestId, error: toErrorDetail(deferredErr) }, "Deferred image resolution failed — preview remains without images");
2171
+ options?.onStatusUpdate?.("Image resolution failed — you can try again or set images manually.");
2172
+ }
2173
+ }
2174
+ // Deferred native image tool execution: the LLM called image.generate or
2175
+ // unsplash.search during planning, but we returned placeholders to unblock
2176
+ // text streaming. Now execute the real tools and patch results into the preview.
2177
+ if (deferredNativeImageCalls.length > 0 && options?.onOpApplied) {
2178
+ // Helper: clear shimmer placeholders left behind by a failed deferred
2179
+ // tool call so the UI doesn't get stuck on "Generating image…".
2180
+ const emitPlaceholderClearPatches = async (reason) => {
2181
+ for (const op of resolvedPlan.ops) {
2182
+ if (op.op !== "update_props")
2183
+ continue;
2184
+ const patch = op.patch;
2185
+ const props = (typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props))
2186
+ ? patch.props
2187
+ : patch;
2188
+ if (!isGeneratingPlaceholder(String(props.imageUrl ?? "")))
2189
+ continue;
2190
+ const clearOp = {
2191
+ op: "update_props",
2192
+ pageSlug: op.pageSlug,
2193
+ blockId: op.blockId,
2194
+ patch: { props: { imageUrl: "" } }
2195
+ };
2196
+ try {
2197
+ await applyOpsAtomically(body.session, [clearOp], { componentsManifest });
2198
+ const clearVersion = bumpVersion(body.session);
2199
+ options.onOpApplied({
2200
+ index: 1,
2201
+ total: 1,
2202
+ op: clearOp,
2203
+ previewVersion: clearVersion,
2204
+ focusBlockId: op.blockId
2205
+ });
2206
+ }
2207
+ catch (clearErr) {
2208
+ ctx.log.warn({ event: "deferred_native_image_clear_failed", reason, chatRequestId, error: toErrorDetail(clearErr) }, "Failed to clear shimmer placeholder after deferred image tool failure");
2209
+ }
2210
+ }
2211
+ // Also clear any stray placeholders still in session state (e.g. nested in arrays)
2212
+ cleanupImagePlaceholders(body.session);
2213
+ };
2214
+ try {
2215
+ const imageResolutionStartMs = Date.now();
2216
+ emitStatusTone("resolving_assets");
2217
+ for (const deferred of deferredNativeImageCalls) {
2218
+ try {
2219
+ const result = await raceCancel(executeToolCall({
2220
+ runtime: ctx.toolRuntime,
2221
+ toolName: deferred.toolName,
2222
+ input: deferred.input,
2223
+ context: {
2224
+ siteId: body.siteId ?? "default",
2225
+ sessionId: body.session ?? "dev",
2226
+ traceId: chatRequestId,
2227
+ plannerProvider: "anthropic",
2228
+ onStatusUpdate: options?.onStatusUpdate,
2229
+ onImageProgress: options?.onImageProgress
2230
+ },
2231
+ policy: ctx.toolRuntime?.defaultPolicy
2232
+ }), options?.signal);
2233
+ if (!result.ok) {
2234
+ ctx.log.warn({ event: "deferred_native_image_tool_error", toolName: deferred.toolName, chatRequestId, errorCode: result.error?.code, errorMessage: result.error?.message }, `Deferred ${deferred.toolName} returned error — clearing placeholder`);
2235
+ options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
2236
+ await emitPlaceholderClearPatches("tool_error");
2237
+ continue;
2238
+ }
2239
+ // Extract the real imageUrl from the tool result
2240
+ const data = result.data;
2241
+ const realImageUrl = deferred.toolName === "image.generate"
2242
+ ? data.imageUrl
2243
+ : (data.items?.[0]?.imageUrl);
2244
+ if (!realImageUrl) {
2245
+ ctx.log.warn({ event: "deferred_native_image_empty_result", toolName: deferred.toolName, chatRequestId }, `Deferred ${deferred.toolName} returned no imageUrl — clearing placeholder`);
2246
+ await emitPlaceholderClearPatches("empty_result");
2247
+ continue;
2248
+ }
2249
+ const realAlt = deferred.toolName === "image.generate"
2250
+ ? data.alt
2251
+ : (data.items?.[0]?.alt);
2252
+ // Find ops that used the placeholder URL and patch the real image in
2253
+ for (const op of resolvedPlan.ops) {
2254
+ if (op.op !== "update_props")
2255
+ continue;
2256
+ const patch = op.patch;
2257
+ const props = (typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props))
2258
+ ? patch.props
2259
+ : patch;
2260
+ if (isGeneratingPlaceholder(String(props.imageUrl ?? ""))) {
2261
+ const patchOp = {
2262
+ op: "update_props",
2263
+ pageSlug: op.pageSlug,
2264
+ blockId: op.blockId,
2265
+ patch: {
2266
+ props: {
2267
+ imageUrl: realImageUrl,
2268
+ ...(realAlt ? { imageAlt: realAlt } : {})
2269
+ }
2270
+ }
2271
+ };
2272
+ await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
2273
+ const patchVersion = bumpVersion(body.session);
2274
+ options.onOpApplied({
2275
+ index: 1,
2276
+ total: 1,
2277
+ op: patchOp,
2278
+ previewVersion: patchVersion,
2279
+ focusBlockId: op.blockId
2280
+ });
2281
+ const nativeBlockType = getPage(body.session, op.pageSlug)?.blocks?.find((b) => b.id === op.blockId)?.type ?? "block";
2282
+ resolvedPlan.change_log = [...resolvedPlan.change_log, `Generated image for ${nativeBlockType} on ${fmtSlug(op.pageSlug)}`];
2283
+ }
2284
+ }
2285
+ }
2286
+ catch (singleErr) {
2287
+ if (isCancelError(singleErr))
2288
+ throw singleErr;
2289
+ ctx.log.warn({ event: "deferred_native_image_failed", toolName: deferred.toolName, chatRequestId, error: toErrorDetail(singleErr) }, `Deferred ${deferred.toolName} failed — clearing placeholder`);
2290
+ options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
2291
+ await emitPlaceholderClearPatches("exception");
2292
+ }
2293
+ }
2294
+ imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
2295
+ }
2296
+ catch (deferredErr) {
2297
+ if (isCancelError(deferredErr))
2298
+ throw deferredErr;
2299
+ ctx.log.warn({ event: "deferred_native_image_resolution_failed", chatRequestId, error: toErrorDetail(deferredErr) }, "Deferred native image resolution failed");
2300
+ options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
2301
+ await emitPlaceholderClearPatches("outer_exception");
2302
+ }
2303
+ }
2304
+ } // end if (!skipApply)
2305
+ const undoSnapshot = skipApply && optionsOverride?.undoSnapshot
2306
+ ? optionsOverride.undoSnapshot
2307
+ : previous;
2308
+ // For create_page: push null under the new page's slug (undo = delete it).
2309
+ // For remove_page: previous was captured before apply, push it under effectiveSlug.
2310
+ // For normal edits: push previous under effectiveSlug.
2311
+ // undoTargetSlug tracks where the undo snapshot lives so the editor sends the right slug.
2312
+ let undoTargetSlug = effectiveSlug;
2313
+ if (hasCreatePage) {
2314
+ for (const op of resolvedPlan.ops) {
2315
+ if (op.op === "create_page") {
2316
+ pushUndo(body.session, op.page.slug, null);
2317
+ undoTargetSlug = op.page.slug;
2318
+ }
2319
+ }
2320
+ }
2321
+ else {
2322
+ pushUndo(body.session, effectiveSlug, undoSnapshot);
2323
+ }
2324
+ pendingClarificationBySession.delete(body.session);
2325
+ pendingApprovalPlanBySession.delete(body.session);
2326
+ const planUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
2327
+ const updatedSlug = planUpdatedSlug ?? (effectiveSlug !== requestedSlug ? effectiveSlug : undefined);
2328
+ pushRecentEdit(body.session, { slug: updatedSlug ?? effectiveSlug, summary: futureToPastTense(resolvedPlan.summary_for_user), ops: resolvedPlan.ops });
2329
+ if (body.message)
2330
+ pushChatHistory(body.session, body.message, futureToPastTense(resolvedPlan.summary_for_user));
2331
+ const previewVersion = options?.onOpApplied ? (versions.get(body.session) ?? 0) : bumpVersion(body.session);
2332
+ const versionEntrySlug = updatedSlug ?? effectiveSlug;
2333
+ const versionSnapshot = getPage(body.session, versionEntrySlug);
2334
+ pushVersionEntry(body.session, {
2335
+ version: previewVersion,
2336
+ slug: versionEntrySlug,
2337
+ summary: futureToPastTense(resolvedPlan.summary_for_user),
2338
+ opTypes: resolvedPlan.ops.map((op) => op.op),
2339
+ opCount: resolvedPlan.ops.length,
2340
+ source: "chat",
2341
+ snapshot: versionSnapshot ? structuredClone(versionSnapshot) : null
2342
+ });
2343
+ schedulePersistState(ctx.log);
2344
+ const focusBlockId = pickFocusBlockId(resolvedPlan.ops);
2345
+ const aiInsightChanges = buildAiInsightChanges({ plan: resolvedPlan, message: plannerMessage });
2346
+ const metaChangeLogEntries = buildMetaChangeLogEntries(resolvedPlan.ops);
2347
+ const opChangeLogEntries = buildOpChangeLogEntries(resolvedPlan.ops, {
2348
+ getBlockType: (slug, blockId) => {
2349
+ const page = getPage(body.session, slug);
2350
+ return page?.blocks?.find((b) => b.id === blockId)?.type;
2351
+ }
2352
+ });
2353
+ const skippedSummary = skippedOps.length > 0
2354
+ ? [`Skipped ${skippedOps.length} unchanged operation${skippedOps.length === 1 ? "" : "s"}.`]
2355
+ : [];
2356
+ applyDurationMs = applyStartedAtMs !== null ? Date.now() - applyStartedAtMs : undefined;
2357
+ ctx.chatTelemetry.push({
2358
+ id: chatRequestId,
2359
+ at: new Date().toISOString(),
2360
+ phase: "result",
2361
+ session: body.session,
2362
+ requestedSlug,
2363
+ effectiveSlug,
2364
+ plannerSource: source,
2365
+ modelKey,
2366
+ modelUsed,
2367
+ promptHash,
2368
+ promptExcerpt,
2369
+ promptLength: plannerMessage.length,
2370
+ outcome: "applied",
2371
+ intent: resolvedPlan.intent,
2372
+ opCount: resolvedPlan.ops.length,
2373
+ opTypes: resolvedPlan.ops.map((op) => op.op),
2374
+ skippedOpCount: skippedOps.length > 0 ? skippedOps.length : undefined,
2375
+ plannerTier,
2376
+ ...plannerContextTelemetryFields,
2377
+ ...usageFields,
2378
+ ...timingFields()
2379
+ });
2380
+ return {
2381
+ done: true,
2382
+ response: {
2383
+ code: 200,
2384
+ payload: withDebugPayload({
2385
+ status: "applied",
2386
+ // plan-first "soft draft": applied + revertible. The editor renders an
2387
+ // inline Keep / Revert affordance (Revert = one-step undo on undoSlug).
2388
+ ...(planFirstApplyAsDraft ? { planApplied: true } : {}),
2389
+ summary: futureToPastTense(resolvedPlan.summary_for_user),
2390
+ changes: [...opChangeLogEntries, ...metaChangeLogEntries, ...aiInsightChanges, ...skippedSummary],
2391
+ mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, updatedSlug ?? effectiveSlug),
2392
+ suggestions: resolvedPlan.suggested_next_actions ?? postEditSuggestions({ plan: resolvedPlan, current, body }),
2393
+ previewVersion,
2394
+ focusBlockId,
2395
+ updatedSlug,
2396
+ undoSlug: undoTargetSlug,
2397
+ plannerSource: source,
2398
+ modelUsed,
2399
+ modelKey
2400
+ }, {
2401
+ outcome: "applied",
2402
+ intent: resolvedPlan.intent,
2403
+ opCount: resolvedPlan.ops.length,
2404
+ opTypes: resolvedPlan.ops.map((op) => op.op),
2405
+ skippedOpCount: skippedOps.length,
2406
+ skippedOps,
2407
+ plannerTier,
2408
+ ...usageFields
2409
+ })
2410
+ }
2411
+ };
2412
+ }
2413
+ catch (error) {
2414
+ if (isCancelError(error))
2415
+ throw error;
2416
+ if (applyDurationMs === undefined && applyStartedAtMs !== null) {
2417
+ applyDurationMs = Date.now() - applyStartedAtMs;
2418
+ }
2419
+ const reason = toErrorDetail(error);
2420
+ if (isNoEffectiveChangeError(reason)) {
2421
+ pendingApprovalPlanBySession.delete(body.session);
2422
+ ctx.chatTelemetry.push({
2423
+ id: chatRequestId,
2424
+ at: new Date().toISOString(),
2425
+ phase: "result",
2426
+ session: body.session,
2427
+ requestedSlug,
2428
+ effectiveSlug,
2429
+ plannerSource: source,
2430
+ modelKey,
2431
+ modelUsed,
2432
+ promptHash,
2433
+ promptExcerpt,
2434
+ promptLength: plannerMessage.length,
2435
+ outcome: "no_effective_change",
2436
+ reason: reason.slice(0, 300),
2437
+ plannerTier,
2438
+ ...plannerContextTelemetryFields,
2439
+ ...timingFields()
2440
+ });
2441
+ return {
2442
+ done: true,
2443
+ response: {
2444
+ code: 200,
2445
+ payload: withDebugPayload({
2446
+ status: "applied",
2447
+ summary: "No changes needed. That content is already up to date.",
2448
+ changes: [],
2449
+ mentionedSlugs: [effectiveSlug],
2450
+ previewVersion: versions.get(body.session) ?? 0,
2451
+ plannerSource: source,
2452
+ modelUsed,
2453
+ modelKey
2454
+ }, { outcome: "no_effective_change", plannerTier })
2455
+ }
2456
+ };
2457
+ }
2458
+ ctx.chatTelemetry.push({
2459
+ id: chatRequestId,
2460
+ at: new Date().toISOString(),
2461
+ phase: "plan_apply_failed",
2462
+ session: body.session,
2463
+ requestedSlug,
2464
+ effectiveSlug,
2465
+ plannerSource: source,
2466
+ modelKey,
2467
+ modelUsed,
2468
+ promptHash,
2469
+ promptExcerpt,
2470
+ promptLength: plannerMessage.length,
2471
+ outcome: "apply_failed",
2472
+ reason: reason.slice(0, 300),
2473
+ reasonCategory: classifyGuardrailError(reason),
2474
+ plannerTier,
2475
+ ...timingFields()
2476
+ });
2477
+ return { done: false, reason };
2478
+ }
2479
+ };
2480
+ if (executionMode === "apply_pending_plan") {
2481
+ const pending = pendingApprovalPlanBySession.get(body.session);
2482
+ if (!pending) {
2483
+ const fallbackMessage = typeof body.message === "string" ? body.message.trim() : "";
2484
+ if (fallbackMessage.length > 0) {
2485
+ return runChatPipeline(ctx, { ...body, executionMode: "auto" }, options);
2486
+ }
2487
+ return {
2488
+ code: 409,
2489
+ payload: withDebugPayload({
2490
+ status: "needs_clarification",
2491
+ summary: "No pending plan is waiting for approval. Ask for a change first.",
2492
+ changes: [],
2493
+ previewVersion: versions.get(body.session) ?? 0,
2494
+ plannerSource,
2495
+ modelUsed,
2496
+ modelKey
2497
+ }, { outcome: "pending_plan_missing" })
2498
+ };
2499
+ }
2500
+ if (body.pendingPlanId && body.pendingPlanId !== pending.id) {
2501
+ return {
2502
+ code: 409,
2503
+ payload: withDebugPayload({
2504
+ status: "validation_error",
2505
+ summary: "Pending plan does not match the latest reviewed plan.",
2506
+ changes: [],
2507
+ validationErrors: ["Pending plan id mismatch. Refresh and approve again."],
2508
+ previewVersion: versions.get(body.session) ?? 0,
2509
+ plannerSource,
2510
+ modelUsed,
2511
+ modelKey
2512
+ }, { outcome: "pending_plan_mismatch" })
2513
+ };
2514
+ }
2515
+ // Run deferred image generation if the pending plan has image ops
2516
+ let approvalPlan = pending.plan;
2517
+ try {
2518
+ if (pending.pendingImageOps && pending.pendingImageOps.length > 0) {
2519
+ const imageResolutionStartMs = Date.now();
2520
+ emitStatusTone("resolving_assets");
2521
+ const imageMessage = pending.originalMessage ?? (typeof body.message === "string" ? body.message : "");
2522
+ const planClone = structuredClone(approvalPlan);
2523
+ // Show shimmer placeholders in the live preview while images are being generated.
2524
+ const placeholderOps = pending.pendingImageOps.map((imgOp) => {
2525
+ const placeholder = imgOp.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
2526
+ const patchProps = {};
2527
+ if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
2528
+ setValueAtPath(patchProps, imgOp.path, placeholder);
2529
+ }
2530
+ else {
2531
+ patchProps.imageUrl = placeholder;
2532
+ }
2533
+ return { op: "update_props", pageSlug: imgOp.pageSlug, blockId: imgOp.blockId, patch: { props: patchProps } };
2534
+ });
2535
+ try {
2536
+ await applyOpsAtomically(body.session, placeholderOps, { componentsManifest });
2537
+ const ver = versions.get(body.session) ?? 0;
2538
+ options?.onOpApplied?.({ index: 0, total: placeholderOps.length, op: placeholderOps[0], previewVersion: ver });
2539
+ }
2540
+ catch (e) {
2541
+ ctx.log.debug({ error: e instanceof Error ? e.message : String(e) }, "Shimmer placeholder injection failed, continuing");
2542
+ }
2543
+ // Restore placeholder imageUrl in ops that had it stripped during detection.
2544
+ // withUnsplashHeroImage checks requestedImageUrl.length > 0 to decide whether
2545
+ // to resolve an image — without this, the stripped patch causes it to skip.
2546
+ for (const imgOp of pending.pendingImageOps) {
2547
+ const op = planClone.ops.find((o) => o.op === "update_props" && o.blockId === imgOp.blockId && o.pageSlug === imgOp.pageSlug);
2548
+ if (!op || op.op !== "update_props")
2549
+ continue;
2550
+ const rawPatch = op.patch;
2551
+ const patchTarget = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
2552
+ ? rawPatch.props
2553
+ : rawPatch;
2554
+ if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
2555
+ if (getValueAtPath(patchTarget, imgOp.path) === undefined) {
2556
+ setValueAtPath(patchTarget, imgOp.path, "pending");
2557
+ }
2558
+ }
2559
+ else if (!Object.prototype.hasOwnProperty.call(patchTarget, "imageUrl")) {
2560
+ patchTarget.imageUrl = "pending";
2561
+ }
2562
+ }
2563
+ approvalPlan = await withUnsplashHeroImage({
2564
+ plan: planClone,
2565
+ message: imageMessage,
2566
+ slug: pending.effectiveSlug,
2567
+ currentPage: current,
2568
+ preferredImageOps: pending.pendingImageOps,
2569
+ activeBlockId: body.activeBlockId,
2570
+ activeEditablePath: body.activeEditablePath,
2571
+ chatRequestId,
2572
+ gdriveFolderId,
2573
+ log: ctx.log,
2574
+ onStatusUpdate: options?.onStatusUpdate,
2575
+ onImageProgress: options?.onImageProgress
2576
+ });
2577
+ imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
2578
+ }
2579
+ const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true });
2580
+ if (approvedOutcome.done)
2581
+ return approvedOutcome.response;
2582
+ return guardrailFailureResponse({ reason: approvedOutcome.reason, source: pending.source });
2583
+ }
2584
+ catch (error) {
2585
+ const reason = toErrorDetail(error);
2586
+ ctx.log.error({ event: "apply_pending_plan_error", chatRequestId, error: reason }, "Pending plan execution failed");
2587
+ pendingApprovalPlanBySession.delete(body.session);
2588
+ ctx.chatTelemetry.push({
2589
+ id: chatRequestId,
2590
+ at: new Date().toISOString(),
2591
+ phase: "result",
2592
+ session: body.session,
2593
+ requestedSlug,
2594
+ effectiveSlug,
2595
+ plannerSource: pending.source,
2596
+ modelKey: pending.modelKey,
2597
+ modelUsed: pending.modelUsed,
2598
+ promptHash,
2599
+ promptExcerpt,
2600
+ promptLength: plannerMessage.length,
2601
+ outcome: "apply_pending_plan_error",
2602
+ reason: reason.slice(0, 300),
2603
+ reasonCategory: classifyGuardrailError(reason),
2604
+ ...plannerContextTelemetryFields,
2605
+ ...timingFields()
2606
+ });
2607
+ return {
2608
+ code: 500,
2609
+ payload: withDebugPayload({
2610
+ status: "error",
2611
+ summary: "Failed to execute the approved plan. Please try again.",
2612
+ changes: [reason.slice(0, 300)],
2613
+ previewVersion: versions.get(body.session) ?? 0,
2614
+ plannerSource: pending.source,
2615
+ modelUsed: pending.modelUsed,
2616
+ modelKey: pending.modelKey
2617
+ }, { outcome: "apply_pending_plan_error" })
2618
+ };
2619
+ }
2620
+ }
2621
+ // plan-first applies as a revertible draft (see planFirstApplyAsDraft); the
2622
+ // destructive/complex gates inside respondFromPlan can still upgrade to a hold.
2623
+ const applyMode = (executionMode === "plan_only" && !planFirstApplyAsDraft) ? "plan_only" : "apply_now";
2624
+ markPlanningStart();
2625
+ emitStatusTone("planning");
2626
+ const forcedDuplicatePlan = deterministicDuplicatePagePlan({ session: body.session, message: plannerMessage, effectiveSlug });
2627
+ if (forcedDuplicatePlan) {
2628
+ markPlanningFinish();
2629
+ ctx.chatTelemetry.push({
2630
+ id: chatRequestId,
2631
+ at: new Date().toISOString(),
2632
+ phase: "forced_plan",
2633
+ session: body.session,
2634
+ requestedSlug,
2635
+ effectiveSlug,
2636
+ plannerSource,
2637
+ modelKey,
2638
+ modelUsed,
2639
+ promptHash,
2640
+ promptExcerpt,
2641
+ promptLength: plannerMessage.length,
2642
+ outcome: "forced_duplicate_page",
2643
+ intent: forcedDuplicatePlan.intent,
2644
+ opCount: forcedDuplicatePlan.ops.length,
2645
+ opTypes: forcedDuplicatePlan.ops.map((op) => op.op),
2646
+ plannerTier: "forced_deterministic",
2647
+ ...timingFields()
2648
+ });
2649
+ const forcedOutcome = await respondFromPlan(forcedDuplicatePlan, plannerSource, applyMode, undefined, "forced_deterministic");
2650
+ if (forcedOutcome.done)
2651
+ return forcedOutcome.response;
2652
+ }
2653
+ // Deterministic selected-text rewrite disabled — let AI planner handle creative rewrites.
2654
+ const forcedCreatePlan = deterministicCreatePagePlan({ session: body.session, message: plannerMessage, hasPageTemplates: !!siteContextBlock?.includes("Page templates:\n") });
2655
+ if (forcedCreatePlan) {
2656
+ markPlanningFinish();
2657
+ ctx.chatTelemetry.push({
2658
+ id: chatRequestId,
2659
+ at: new Date().toISOString(),
2660
+ phase: "forced_plan",
2661
+ session: body.session,
2662
+ requestedSlug,
2663
+ effectiveSlug,
2664
+ plannerSource,
2665
+ modelKey,
2666
+ modelUsed,
2667
+ promptHash,
2668
+ promptExcerpt,
2669
+ promptLength: plannerMessage.length,
2670
+ outcome: "forced_create_page",
2671
+ intent: forcedCreatePlan.intent,
2672
+ opCount: forcedCreatePlan.ops.length,
2673
+ opTypes: forcedCreatePlan.ops.map((op) => op.op),
2674
+ plannerTier: "forced_deterministic",
2675
+ ...timingFields()
2676
+ });
2677
+ const forcedOutcome = await respondFromPlan(forcedCreatePlan, plannerSource, applyMode, undefined, "forced_deterministic");
2678
+ if (forcedOutcome.done)
2679
+ return forcedOutcome.response;
2680
+ }
2681
+ if (plannerSource === "demo") {
2682
+ markPlanningStart();
2683
+ try {
2684
+ emitStatusTone("planning");
2685
+ const demoPlan = demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType);
2686
+ markPlanningFinish();
2687
+ const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
2688
+ if (outcome.done)
2689
+ return outcome.response;
2690
+ return guardrailFailureResponse({ reason: outcome.reason, source: "demo" });
2691
+ }
2692
+ catch (error) {
2693
+ markPlanningFinish();
2694
+ const reason = toErrorDetail(error);
2695
+ ctx.chatTelemetry.push({
2696
+ id: chatRequestId,
2697
+ at: new Date().toISOString(),
2698
+ phase: "result",
2699
+ session: body.session,
2700
+ requestedSlug,
2701
+ effectiveSlug,
2702
+ plannerSource: "demo",
2703
+ modelKey,
2704
+ modelUsed,
2705
+ promptHash,
2706
+ promptExcerpt,
2707
+ promptLength: plannerMessage.length,
2708
+ outcome: "planner_exception",
2709
+ reason: reason.slice(0, 300),
2710
+ reasonCategory: classifyGuardrailError(reason),
2711
+ plannerTier: "demo",
2712
+ ...plannerContextTelemetryFields,
2713
+ ...timingFields()
2714
+ });
2715
+ return {
2716
+ code: 500,
2717
+ payload: withDebugPayload({
2718
+ status: "error",
2719
+ summary: "Could not generate an edit plan.",
2720
+ changes: [reason.slice(0, 300)],
2721
+ previewVersion: versions.get(body.session) ?? 0,
2722
+ plannerSource: "demo",
2723
+ modelUsed,
2724
+ modelKey
2725
+ }, { outcome: "planner_exception", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "demo" })
2726
+ };
2727
+ }
2728
+ }
2729
+ // --- Vision alt-text short-circuit ---
2730
+ // Inline "Generate alt text" / "Improve accessibility" / "Make more descriptive"
2731
+ // wand on imageAlt fields. The full planner reliably misroutes this to
2732
+ // needs_clarification when the existing alt-text value is empty or looks like a
2733
+ // user instruction (a frequent shape of "previously corrupted" alt). Bypass the
2734
+ // planner with a direct vision call to get clean alt text deterministically.
2735
+ if (!contentQuery &&
2736
+ planningActiveBlockId &&
2737
+ planningActiveEditablePath &&
2738
+ isVisionAltGenerationRequest({
2739
+ message: plannerMessage,
2740
+ activeEditablePath: planningActiveEditablePath,
2741
+ activeBlockId: planningActiveBlockId,
2742
+ currentPage: current
2743
+ })) {
2744
+ const block = current.blocks.find((b) => b.id === planningActiveBlockId);
2745
+ const pathParse = parseAltPathForOp(planningActiveEditablePath);
2746
+ const imageUrl = block
2747
+ ? resolveImageUrlForAltField(block.props, planningActiveEditablePath)
2748
+ : undefined;
2749
+ if (block && pathParse && imageUrl) {
2750
+ emitStatusTone("planning");
2751
+ markPlanningStart();
2752
+ const altText = await generateAltTextFromVision({
2753
+ imageUrl,
2754
+ preferredProvider: plannerSource,
2755
+ log: ctx.log
2756
+ });
2757
+ markPlanningFinish();
2758
+ if (altText) {
2759
+ const op = pathParse.kind === "props"
2760
+ ? {
2761
+ op: "update_props",
2762
+ pageSlug: effectiveSlug,
2763
+ blockId: planningActiveBlockId,
2764
+ patch: { [pathParse.key]: altText }
2765
+ }
2766
+ : {
2767
+ op: "update_item",
2768
+ pageSlug: effectiveSlug,
2769
+ blockId: planningActiveBlockId,
2770
+ listKey: pathParse.listKey,
2771
+ index: pathParse.index,
2772
+ patch: { [pathParse.itemKey]: altText }
2773
+ };
2774
+ const visionPlan = {
2775
+ intent: "edit_plan",
2776
+ summary_for_user: "Generated alt text from the image.",
2777
+ change_log: ["Updated the image alt text."],
2778
+ ops: [op]
2779
+ };
2780
+ const visionOutcome = await respondFromPlan(visionPlan, plannerSource, applyMode, undefined, "deterministic");
2781
+ if (visionOutcome.done)
2782
+ return visionOutcome.response;
2783
+ }
2784
+ // Fall through to the regular planner if the direct vision call failed.
2785
+ }
2786
+ }
2787
+ const isHighConfidence = !contentQuery && isHighConfidenceDeterministicCase({
2788
+ message: plannerMessage,
2789
+ currentPage: current,
2790
+ activeBlockId: planningActiveBlockId,
2791
+ activeEditablePath: planningActiveEditablePath
2792
+ });
2793
+ if (isHighConfidence) {
2794
+ const deterministicIntent = inferDeterministicIntent({
2795
+ message: plannerMessage,
2796
+ currentPage: current,
2797
+ activeBlockId: planningActiveBlockId,
2798
+ activeEditablePath: planningActiveEditablePath
2799
+ });
2800
+ if (deterministicIntent) {
2801
+ emitStatusTone("planning");
2802
+ const deterministicPlan = compileDeterministicPlan({
2803
+ session: body.session,
2804
+ intent: deterministicIntent,
2805
+ message: plannerMessage,
2806
+ slug: effectiveSlug,
2807
+ currentPage: current,
2808
+ activeBlockId: planningActiveBlockId,
2809
+ activeEditablePath: planningActiveEditablePath,
2810
+ locale: body.locale
2811
+ });
2812
+ if (deterministicPlan?.intent === "edit_plan" && deterministicPlan.ops.length > 0) {
2813
+ markPlanningFinish();
2814
+ ctx.chatTelemetry.push({
2815
+ id: chatRequestId,
2816
+ at: new Date().toISOString(),
2817
+ phase: "deterministic_plan_generated",
2818
+ session: body.session,
2819
+ requestedSlug,
2820
+ effectiveSlug,
2821
+ plannerSource,
2822
+ modelKey,
2823
+ modelUsed,
2824
+ promptHash,
2825
+ promptExcerpt,
2826
+ promptLength: plannerMessage.length,
2827
+ outcome: "deterministic_plan_ready",
2828
+ intent: deterministicPlan.intent,
2829
+ opCount: deterministicPlan.ops.length,
2830
+ opTypes: deterministicPlan.ops.map((op) => op.op),
2831
+ plannerTier: "deterministic"
2832
+ });
2833
+ const deterministicOutcome = await respondFromPlan(deterministicPlan, plannerSource, applyMode, undefined, "deterministic");
2834
+ if (deterministicOutcome.done)
2835
+ return deterministicOutcome.response;
2836
+ }
2837
+ if (deterministicPlan?.intent === "needs_clarification" &&
2838
+ shouldReturnDeterministicClarification(plannerMessage)) {
2839
+ markPlanningFinish();
2840
+ const deterministicOutcome = await respondFromPlan(deterministicPlan, plannerSource, applyMode, undefined, "deterministic");
2841
+ if (deterministicOutcome.done)
2842
+ return deterministicOutcome.response;
2843
+ }
2844
+ }
2845
+ }
2846
+ // --- Compound deterministic decomposition ---
2847
+ // When a single deterministic check fails due to compound actions
2848
+ // ("remove the hero and add a CTA"), try decomposing into sub-intents.
2849
+ if (!contentQuery && !isHighConfidence) {
2850
+ const compoundPlan = tryCompoundDeterministicPlan({
2851
+ session: body.session,
2852
+ message: plannerMessage,
2853
+ slug: effectiveSlug,
2854
+ currentPage: current,
2855
+ activeBlockId: planningActiveBlockId,
2856
+ activeEditablePath: planningActiveEditablePath
2857
+ });
2858
+ if (compoundPlan?.intent === "edit_plan" && compoundPlan.ops.length > 0) {
2859
+ markPlanningFinish();
2860
+ ctx.chatTelemetry.push({
2861
+ id: chatRequestId,
2862
+ at: new Date().toISOString(),
2863
+ phase: "deterministic_plan_generated",
2864
+ session: body.session,
2865
+ requestedSlug,
2866
+ effectiveSlug,
2867
+ plannerSource,
2868
+ modelKey,
2869
+ modelUsed,
2870
+ promptHash,
2871
+ promptExcerpt,
2872
+ promptLength: plannerMessage.length,
2873
+ outcome: "compound_deterministic_plan_ready",
2874
+ intent: compoundPlan.intent,
2875
+ opCount: compoundPlan.ops.length,
2876
+ opTypes: compoundPlan.ops.map((op) => op.op),
2877
+ plannerTier: "deterministic"
2878
+ });
2879
+ const compoundOutcome = await respondFromPlan(compoundPlan, plannerSource, applyMode, undefined, "deterministic");
2880
+ if (compoundOutcome.done)
2881
+ return compoundOutcome.response;
2882
+ }
2883
+ }
2884
+ const llmIntentRouterEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_LLM_INTENT_ROUTER ?? "1").trim());
2885
+ // Skip the intent router for batch-add requests — the router's deterministic
2886
+ // compile can't generate meaningful content for "add 3 blocks" without specific
2887
+ // block types, so go straight to the full planner which can handle it.
2888
+ const batchAddSkipsRouter = isBatchAddRequest(plannerMessage);
2889
+ // The router schema has no "duplicate" action (only add|move|update|remove|
2890
+ // info|clarify), so duplicate/clone/copy requests get misrouted to update_props.
2891
+ // Full planner supports `duplicate_block` natively.
2892
+ const duplicateSkipsRouter = isDuplicateBlockRequest(plannerMessage);
2893
+ // Vision alt-text generation (inline "Generate alt text" / "Improve accessibility"
2894
+ // wand on imageAlt fields) needs the full planner so the LLM can actually look
2895
+ // at the companion image. The fast router is JSON-only and can't see images —
2896
+ // it would hallucinate alt text or echo the user's prompt verbatim.
2897
+ const visionAltSkipsRouter = isVisionAltGenerationRequest({
2898
+ message: plannerMessage,
2899
+ activeEditablePath: planningActiveEditablePath,
2900
+ activeBlockId: planningActiveBlockId,
2901
+ currentPage: current
2902
+ });
2903
+ const shouldTryLlmIntentRouter = !contentQuery &&
2904
+ !batchAddSkipsRouter &&
2905
+ !duplicateSkipsRouter &&
2906
+ !visionAltSkipsRouter &&
2907
+ llmIntentRouterEnabled &&
2908
+ shouldUseLlmIntentRouter(plannerMessage) &&
2909
+ (plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini");
2910
+ // --- Parallel intent router + full planner ---
2911
+ // When intent routing is enabled, launch both the fast intent router and the
2912
+ // full planner concurrently. The router gets a ~200ms head start. If it succeeds
2913
+ // and produces a valid plan, we abort the full planner and return immediately.
2914
+ // If it fails, the full planner is already running — no wasted time.
2915
+ const parallelPlannerEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_PARALLEL_PLANNER ?? "1").trim());
2916
+ const routerHeadStartMs = Math.max(0, Math.min(Number(process.env.CHAT_ROUTER_HEAD_START_MS ?? 200), 1000));
2917
+ // Request-scoped registry. Closures over *Impl vars so test hooks
2918
+ // (setGeneratePlanWith*ForTests) still take effect.
2919
+ const plannerRegistry = createPlannerRegistry({
2920
+ openai: {
2921
+ source: "openai",
2922
+ supportsNativeTools: false,
2923
+ parseIntent: (a) => {
2924
+ const { log: _log, ...rest } = a;
2925
+ return parseIntentWithOpenAIImpl(rest);
2926
+ },
2927
+ generatePlan: (a) => {
2928
+ const { onStatusUpdate: _s, onImageProgress: _i, log: _l, ...rest } = a;
2929
+ return generatePlanWithOpenAIImpl(rest);
2930
+ },
2931
+ },
2932
+ anthropic: {
2933
+ source: "anthropic",
2934
+ supportsNativeTools: true,
2935
+ parseIntent: (a) => parseIntentWithAnthropicImpl(a),
2936
+ generatePlan: (a) => generatePlanWithAnthropicImpl(a),
2937
+ },
2938
+ gemini: {
2939
+ source: "gemini",
2940
+ supportsNativeTools: true,
2941
+ parseIntent: (a) => parseIntentWithGeminiImpl(a),
2942
+ generatePlan: (a) => generatePlanWithGeminiImpl(a),
2943
+ },
2944
+ });
2945
+ // `plannerSource === "demo"` is handled earlier; here it's always a real planner.
2946
+ const planner = plannerRegistry.get(plannerSource);
2947
+ const supportsNativeTools = planner.supportsNativeTools;
2948
+ const generatePlanImpl = planner.generatePlan;
2949
+ const maxPlanningAttempts = 3;
2950
+ let initialPlan = null;
2951
+ let routerDetectedInfo = false;
2952
+ const planningErrors = [];
2953
+ if (shouldTryLlmIntentRouter && parallelPlannerEnabled) {
2954
+ const routerModel = ctx.modelLookup[provider]?.fast ??
2955
+ ctx.modelLookup[provider]?.balanced ??
2956
+ modelUsed;
2957
+ // AbortController for the full planner — allows router success to cancel it
2958
+ const fullPlannerAbort = new AbortController();
2959
+ const combinedSignal = options?.signal
2960
+ ? AbortSignal.any([options.signal, fullPlannerAbort.signal])
2961
+ : fullPlannerAbort.signal;
2962
+ // Track router completion for complexity downgrade
2963
+ let routerComplexity = null;
2964
+ // Intent router promise
2965
+ emitStatusTone("understanding");
2966
+ const routerPromise = (async () => {
2967
+ const routedIntent = await planner.parseIntent({
2968
+ message: plannerMessage,
2969
+ slug: effectiveSlug,
2970
+ currentPage: current,
2971
+ activeBlockId: planningActiveBlockId,
2972
+ activeBlockType: body.activeBlockType,
2973
+ activeEditablePath: planningActiveEditablePath,
2974
+ model: routerModel,
2975
+ log: ctx.log
2976
+ });
2977
+ const routedPlan = compileDeterministicPlan({
2978
+ session: body.session,
2979
+ intent: routedIntent,
2980
+ message: plannerMessage,
2981
+ slug: effectiveSlug,
2982
+ currentPage: current,
2983
+ activeBlockId: planningActiveBlockId,
2984
+ activeEditablePath: planningActiveEditablePath,
2985
+ locale: body.locale
2986
+ });
2987
+ routerComplexity = routedIntent.complexity ?? null;
2988
+ if (routedIntent.action === "info") {
2989
+ routerDetectedInfo = true;
2990
+ }
2991
+ if (routedPlan?.intent === "edit_plan" && routedPlan.ops.length > 0) {
2992
+ return { plan: routedPlan, outcome: "llm_router_plan_ready" };
2993
+ }
2994
+ if (routedPlan?.intent === "needs_clarification" && shouldReturnDeterministicClarification(plannerMessage)) {
2995
+ return { plan: routedPlan, outcome: "llm_router_needs_clarification" };
2996
+ }
2997
+ return null; // Router didn't produce a usable result
2998
+ })();
2999
+ // Full planner promise — starts after head-start delay.
3000
+ // When the regex heuristic already suspects a simple edit, we await the
3001
+ // router fully (adds ~200-500ms) to get the LLM complexity signal — worth
3002
+ // it because the fast model saves 5-10s on the main plan.
3003
+ // Exception: if the model is already "fast" (heuristic already selected it),
3004
+ // skip the await — the router can't downgrade further.
3005
+ const likelySimple = shouldPreferFastModelForMessage(plannerMessage);
3006
+ const alreadyFastModel = modelKey === "fast";
3007
+ const fullPlannerPromise = (async () => {
3008
+ if (likelySimple && !alreadyFastModel) {
3009
+ // Await the router to get its complexity verdict before choosing model
3010
+ try {
3011
+ await routerPromise;
3012
+ }
3013
+ catch { /* router failure is non-fatal */ }
3014
+ }
3015
+ else if (routerHeadStartMs > 0) {
3016
+ throwIfCanceled(combinedSignal);
3017
+ await sleepMs(routerHeadStartMs);
3018
+ }
3019
+ throwIfCanceled(combinedSignal);
3020
+ // Use router's complexity signal to downgrade model when appropriate
3021
+ let plannerModel = modelUsed;
3022
+ if (!forceSonnet && routerComplexity === "simple" && ctx.modelLookup[provider]?.fast) {
3023
+ plannerModel = ctx.modelLookup[provider].fast;
3024
+ ctx.log.info({ event: "complexity_downgrade", from: modelUsed, to: plannerModel }, "Router signaled simple — downgrading planner to fast model");
3025
+ routingDecision = {
3026
+ from: modelKey,
3027
+ to: "fast",
3028
+ reason: "simple_downgrade",
3029
+ complexity: "simple"
3030
+ };
3031
+ }
3032
+ else if (routerComplexity === "complex") {
3033
+ // PHASE 1: shadow-only. Log what we *would* upgrade to but don't
3034
+ // change the model. Gated behind CHAT_AUTO_UPGRADE_COMPLEX in a
3035
+ // follow-up PR. Lets us validate router accuracy before paying for
3036
+ // the reasoning tier.
3037
+ const reasoningModel = ctx.modelLookup[provider]?.reasoning;
3038
+ if (reasoningModel) {
3039
+ ctx.log.info({
3040
+ event: "complexity_upgrade_shadow",
3041
+ from: modelUsed,
3042
+ wouldUpgradeTo: reasoningModel,
3043
+ modelKey,
3044
+ wouldUpgradeToKey: "reasoning",
3045
+ provider,
3046
+ promptLength: plannerMessage.length
3047
+ }, "Router signaled complex — shadow-logging would-be upgrade to reasoning model (PR1: not applied)");
3048
+ routingDecision = {
3049
+ from: modelKey,
3050
+ to: "reasoning",
3051
+ reason: "shadow",
3052
+ complexity: "complex"
3053
+ };
3054
+ }
3055
+ }
3056
+ markPlanningStart();
3057
+ emitStatusTone("thinking");
3058
+ const result = await raceCancel(generatePlanImpl({
3059
+ message: plannerMessage,
3060
+ slug: effectiveSlug,
3061
+ currentPage: current,
3062
+ contextPack: plannerContext,
3063
+ model: plannerModel,
3064
+ locale: body.locale,
3065
+ imageSourceChoiceOpen,
3066
+ history: isLightweightEdit ? [] : plannerChatHistory,
3067
+ siteContextBlock: isLightweightEdit ? undefined : siteContextBlock,
3068
+ toolRuntime: supportsNativeTools && !isLightweightEdit ? ctx.toolRuntime : undefined,
3069
+ toolCallContext: supportsNativeTools && !isLightweightEdit
3070
+ ? {
3071
+ siteId: body.siteId ?? "default",
3072
+ sessionId: body.session ?? "dev",
3073
+ traceId: chatRequestId,
3074
+ gdriveFolderId
3075
+ }
3076
+ : undefined,
3077
+ onStatusUpdate: options?.onStatusUpdate,
3078
+ onImageProgress: options?.onImageProgress,
3079
+ onToolExecution: supportsNativeTools && !isLightweightEdit
3080
+ ? (event) => {
3081
+ if (event.ok && !event.deferred && event.toolName === "unsplash.search")
3082
+ usedNativeUnsplashTool = true;
3083
+ if (event.ok && !event.deferred && event.toolName === "image.generate")
3084
+ usedNativeImageTool = true;
3085
+ ctx.chatTelemetry.push({
3086
+ id: chatRequestId,
3087
+ at: new Date().toISOString(),
3088
+ phase: "tool_call",
3089
+ session: body.session ?? "dev",
3090
+ requestedSlug,
3091
+ effectiveSlug,
3092
+ plannerSource,
3093
+ modelKey,
3094
+ modelUsed,
3095
+ promptHash,
3096
+ promptExcerpt,
3097
+ promptLength: plannerMessage.length,
3098
+ toolName: event.toolName,
3099
+ toolOk: event.ok,
3100
+ toolLatencyMs: event.latencyMs,
3101
+ toolAttempts: event.attempts,
3102
+ toolErrorCode: event.errorCode,
3103
+ correlationId: event.traceId,
3104
+ outcome: event.ok ? "tool_ok" : "tool_error",
3105
+ ...timingFields()
3106
+ });
3107
+ }
3108
+ : undefined,
3109
+ log: ctx.log,
3110
+ onToken: onPlanningToken,
3111
+ onThinking: options?.onThinking,
3112
+ onFieldDraft: options?.onFieldDraft,
3113
+ onSummaryChunk: options?.onSummaryChunk,
3114
+ onChangeLogEntry: options?.onChangeLogEntry,
3115
+ onPlannedOp: incrementalPlanStreamEnabled
3116
+ ? (op, index) => {
3117
+ markFirstStructuredProgress();
3118
+ options?.onPlannedOp?.({ op, index });
3119
+ }
3120
+ : undefined,
3121
+ componentsManifest,
3122
+ lightweight: routerComplexity === "simple" || likelySimple,
3123
+ signal: combinedSignal,
3124
+ // Disable thinking on router-downgraded simple plans — thinking only makes
3125
+ // sense on the full planner path.
3126
+ thinking: routerComplexity === "simple" ? undefined : thinkingConfig
3127
+ }), combinedSignal);
3128
+ return result;
3129
+ })();
3130
+ // Helper to extract plan result from the full planner promise
3131
+ const consumeFullPlannerResult = (plannerResult) => {
3132
+ initialPlan = plannerResult.plan;
3133
+ planUsage = plannerResult.usage;
3134
+ if (plannerResult.deferredNativeImageCalls?.length) {
3135
+ deferredNativeImageCalls = plannerResult.deferredNativeImageCalls;
3136
+ }
3137
+ if (plannerResult.schemaContext) {
3138
+ plannerContextTelemetryFields.contractMode = plannerResult.schemaContext.contractMode;
3139
+ plannerContextTelemetryFields.contractBytes = plannerResult.schemaContext.contractBytes;
3140
+ plannerContextTelemetryFields.contractBlockCount = plannerResult.schemaContext.contractBlockCount;
3141
+ plannerContextTelemetryFields.strictJsonEnabled = plannerResult.schemaContext.strictJsonEnabled;
3142
+ }
3143
+ markPlanningFinish();
3144
+ emitStatusTone("validating");
3145
+ };
3146
+ // Race: router vs full planner
3147
+ try {
3148
+ const routerResult = await Promise.race([
3149
+ routerPromise.then((r) => ({ source: "router", result: r })),
3150
+ fullPlannerPromise.then((r) => ({ source: "planner", result: r }))
3151
+ ]);
3152
+ if (routerResult.source === "router" && routerResult.result) {
3153
+ // Router won with a usable plan — quality gate before using it
3154
+ const { plan: routedPlan, outcome } = routerResult.result;
3155
+ // Quality gate: if the message requests content generation but the
3156
+ // router plan only has default/shallow props, wait briefly for the
3157
+ // full planner which can produce richer content.
3158
+ if (isRouterPlanTooShallow(plannerMessage, routedPlan)) {
3159
+ ctx.log.info({ event: "router_plan_too_shallow" }, "Router plan shallow for content-generating message — waiting for full planner");
3160
+ try {
3161
+ const betterResult = await Promise.race([
3162
+ fullPlannerPromise.then((r) => ({ got: "planner", result: r })),
3163
+ sleepMs(routerHeadStartMs).then(() => ({ got: "timeout", result: null }))
3164
+ ]);
3165
+ if (betterResult.got === "planner" && betterResult.result) {
3166
+ consumeFullPlannerResult(betterResult.result);
3167
+ suppressCancelOnly(routerPromise, ctx.log, "router_after_quality_gate");
3168
+ // Skip to post-race handling with initialPlan set
3169
+ }
3170
+ else {
3171
+ // Planner didn't finish in time — fall through to use router plan below
3172
+ }
3173
+ }
3174
+ catch (qualityGateErr) {
3175
+ if (isCancelError(qualityGateErr))
3176
+ throw qualityGateErr;
3177
+ ctx.log.warn({ event: "quality_gate_planner_error", error: toErrorDetail(qualityGateErr) }, "Quality gate: full planner errored — using router plan");
3178
+ }
3179
+ }
3180
+ // If full planner didn't beat the router (or quality gate didn't fire), use router plan
3181
+ if (!initialPlan) {
3182
+ markPlanningFinish();
3183
+ emitStatusTone("planning");
3184
+ ctx.chatTelemetry.push({
3185
+ id: chatRequestId,
3186
+ at: new Date().toISOString(),
3187
+ phase: "deterministic_plan_generated",
3188
+ session: body.session,
3189
+ requestedSlug,
3190
+ effectiveSlug,
3191
+ plannerSource,
3192
+ modelKey,
3193
+ modelUsed,
3194
+ promptHash,
3195
+ promptExcerpt,
3196
+ promptLength: plannerMessage.length,
3197
+ outcome,
3198
+ intent: routedPlan.intent,
3199
+ opCount: routedPlan.ops.length,
3200
+ opTypes: routedPlan.ops.map((op) => op.op),
3201
+ plannerTier: "llm_intent_router",
3202
+ ...timingFields()
3203
+ });
3204
+ const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
3205
+ if (routedOutcome.done) {
3206
+ // For needs_clarification: only short-circuit for deterministic scenarios
3207
+ // (page deletes, renames, etc.). Otherwise fall through to the full planner —
3208
+ // the router may have refused a valid batch-add request like "populate this page".
3209
+ if (routedPlan.intent === "needs_clarification" && !shouldReturnDeterministicClarification(plannerMessage)) {
3210
+ // Fall through — wait for the full planner result below
3211
+ }
3212
+ else {
3213
+ // Success — abort the full planner (fire and forget)
3214
+ fullPlannerAbort.abort("router_succeeded");
3215
+ suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_router_success");
3216
+ return routedOutcome.response;
3217
+ }
3218
+ }
3219
+ }
3220
+ // Router plan failed validation or was a non-deterministic clarification — fall through to wait for full planner
3221
+ }
3222
+ if (routerResult.source === "router") {
3223
+ if (routerDetectedInfo) {
3224
+ // Info query — abort the in-flight planner (it has wrong context)
3225
+ fullPlannerAbort.abort("router_info_rebuild");
3226
+ suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_router_info");
3227
+ // Fall through to post-race context rebuild below
3228
+ }
3229
+ else {
3230
+ // Router returned null or failed validation — await the full planner
3231
+ try {
3232
+ const plannerResult = await fullPlannerPromise;
3233
+ consumeFullPlannerResult(plannerResult);
3234
+ }
3235
+ catch (plannerFallbackError) {
3236
+ if (isCancelError(plannerFallbackError))
3237
+ throw plannerFallbackError;
3238
+ // Full planner also failed — fall through to retry loop
3239
+ }
3240
+ }
3241
+ }
3242
+ else {
3243
+ // Full planner won the race — use its result
3244
+ consumeFullPlannerResult(routerResult.result);
3245
+ // Let router finish in the background (no side effects)
3246
+ suppressCancelOnly(routerPromise, ctx.log, "router_after_planner_won");
3247
+ }
3248
+ }
3249
+ catch (parallelError) {
3250
+ if (isCancelError(parallelError))
3251
+ throw parallelError;
3252
+ // Both failed — fall through to sequential full planner retry loop
3253
+ fullPlannerAbort.abort("parallel_error");
3254
+ suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_parallel_error");
3255
+ suppressCancelOnly(routerPromise, ctx.log, "router_after_parallel_error");
3256
+ ctx.log.warn({ err: toErrorDetail(parallelError), event: "parallel_planner_error" }, "Parallel intent router + planner failed, falling back to sequential planner");
3257
+ }
3258
+ }
3259
+ else if (shouldTryLlmIntentRouter) {
3260
+ // Sequential intent router (parallel disabled via env)
3261
+ try {
3262
+ emitStatusTone("understanding");
3263
+ const routerModel = ctx.modelLookup[provider]?.fast ??
3264
+ ctx.modelLookup[provider]?.balanced ??
3265
+ modelUsed;
3266
+ const routedIntent = await planner.parseIntent({
3267
+ message: plannerMessage,
3268
+ slug: effectiveSlug,
3269
+ currentPage: current,
3270
+ activeBlockId: planningActiveBlockId,
3271
+ activeBlockType: body.activeBlockType,
3272
+ activeEditablePath: planningActiveEditablePath,
3273
+ model: routerModel,
3274
+ log: ctx.log
3275
+ });
3276
+ emitStatusTone("planning");
3277
+ const routedPlan = compileDeterministicPlan({
3278
+ session: body.session,
3279
+ intent: routedIntent,
3280
+ message: plannerMessage,
3281
+ slug: effectiveSlug,
3282
+ currentPage: current,
3283
+ activeBlockId: planningActiveBlockId,
3284
+ activeEditablePath: planningActiveEditablePath,
3285
+ locale: body.locale
3286
+ });
3287
+ if (routedIntent.action === "info") {
3288
+ routerDetectedInfo = true;
3289
+ }
3290
+ if (routedPlan?.intent === "edit_plan" && routedPlan.ops.length > 0) {
3291
+ markPlanningFinish();
3292
+ ctx.chatTelemetry.push({
3293
+ id: chatRequestId,
3294
+ at: new Date().toISOString(),
3295
+ phase: "deterministic_plan_generated",
3296
+ session: body.session,
3297
+ requestedSlug,
3298
+ effectiveSlug,
3299
+ plannerSource,
3300
+ modelKey,
3301
+ modelUsed,
3302
+ promptHash,
3303
+ promptExcerpt,
3304
+ promptLength: plannerMessage.length,
3305
+ outcome: "llm_router_plan_ready",
3306
+ intent: routedPlan.intent,
3307
+ opCount: routedPlan.ops.length,
3308
+ opTypes: routedPlan.ops.map((op) => op.op),
3309
+ plannerTier: "llm_intent_router",
3310
+ ...timingFields()
3311
+ });
3312
+ const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
3313
+ if (routedOutcome.done)
3314
+ return routedOutcome.response;
3315
+ }
3316
+ if (routedPlan?.intent === "needs_clarification" && shouldReturnDeterministicClarification(plannerMessage)) {
3317
+ markPlanningFinish();
3318
+ ctx.chatTelemetry.push({
3319
+ id: chatRequestId,
3320
+ at: new Date().toISOString(),
3321
+ phase: "deterministic_plan_generated",
3322
+ session: body.session,
3323
+ requestedSlug,
3324
+ effectiveSlug,
3325
+ plannerSource,
3326
+ modelKey,
3327
+ modelUsed,
3328
+ promptHash,
3329
+ promptExcerpt,
3330
+ promptLength: plannerMessage.length,
3331
+ outcome: "llm_router_needs_clarification",
3332
+ intent: routedPlan.intent,
3333
+ opCount: 0,
3334
+ opTypes: [],
3335
+ plannerTier: "llm_intent_router",
3336
+ ...timingFields()
3337
+ });
3338
+ const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
3339
+ if (routedOutcome.done)
3340
+ return routedOutcome.response;
3341
+ }
3342
+ }
3343
+ catch {
3344
+ // If router fails, fall back to the full planner.
3345
+ }
3346
+ }
3347
+ // Router detected a content query the regex missed — rebuild context with full props
3348
+ // so the full planner retry loop has the page content it needs for content_answer.
3349
+ if (routerDetectedInfo && !initialPlan && !contentQuery) {
3350
+ plannerContext = plannerContextPack({
3351
+ session: body.session,
3352
+ slug: effectiveSlug,
3353
+ message: plannerMessage,
3354
+ currentPage: current,
3355
+ activeBlockId: planningActiveBlockId,
3356
+ activeBlockType: body.activeBlockType,
3357
+ activeEditablePath: planningActiveEditablePath,
3358
+ includeFullProps: true,
3359
+ attachments: body.attachments
3360
+ });
3361
+ isLightweightEdit = false;
3362
+ }
3363
+ // --- Continuation chain decomposition ---
3364
+ // If the message looks like a multi-step request and we haven't already entered a chain,
3365
+ // decompose it into steps and execute only the first one through the normal planner.
3366
+ if (!initialPlan &&
3367
+ executionMode === "auto" &&
3368
+ !continuationChainBySession.has(body.session) &&
3369
+ isMultiStepCandidate(plannerMessage) &&
3370
+ (plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini")) {
3371
+ try {
3372
+ emitStatusTone("breaking_down");
3373
+ // Always use OpenAI for decomposition — it's a lightweight JSON task that
3374
+ // doesn't need the full Anthropic planner, and avoids model/provider mismatch.
3375
+ const decomposerModel = ctx.modelLookup.openai?.fast ??
3376
+ ctx.modelLookup.openai?.balanced ??
3377
+ ctx.modelLookup[provider]?.fast ??
3378
+ modelUsed;
3379
+ const decomposition = await decomposeRequest({
3380
+ message: plannerMessage,
3381
+ currentPage: current,
3382
+ slug: effectiveSlug,
3383
+ model: decomposerModel,
3384
+ siteContextBlock,
3385
+ locale: body.locale
3386
+ });
3387
+ if (decomposition.steps.length > 1) {
3388
+ const chain = {
3389
+ id: randomUUID(),
3390
+ steps: decomposition.steps,
3391
+ stepLabels: decomposition.labels,
3392
+ currentStep: 0,
3393
+ totalSteps: decomposition.steps.length,
3394
+ originalMessage: plannerMessage,
3395
+ effectiveSlug,
3396
+ siteContextBlock
3397
+ };
3398
+ continuationChainBySession.set(body.session, chain);
3399
+ // Execute step 0 through normal planner
3400
+ chain.currentStep = 1;
3401
+ const stepResult = await runChatPipeline(ctx, {
3402
+ ...body,
3403
+ message: decomposition.steps[0],
3404
+ executionMode: "auto"
3405
+ }, options);
3406
+ // Attach continuation info for remaining steps
3407
+ if (stepResult.code === 200 && "status" in stepResult.payload && stepResult.payload.status !== "error") {
3408
+ const payload = stepResult.payload;
3409
+ payload.continuation = {
3410
+ chainId: chain.id,
3411
+ currentStep: 2, // 1-indexed for display
3412
+ totalSteps: chain.totalSteps,
3413
+ nextStepLabel: chain.stepLabels[1]
3414
+ };
3415
+ }
3416
+ return stepResult;
3417
+ }
3418
+ // If 1 step, fall through to normal flow
3419
+ }
3420
+ catch (decompositionError) {
3421
+ if (isCancelError(decompositionError))
3422
+ throw decompositionError;
3423
+ ctx.log.warn({ err: toErrorDetail(decompositionError), event: "decomposition_failed" }, "Request decomposition failed, falling back to normal planner");
3424
+ // Fall through to normal flow
3425
+ }
3426
+ }
3427
+ // Guard: if the planner returned content_answer for a message that clearly
3428
+ // requests an edit, discard the plan so the retry loop runs with the
3429
+ // original (non-downgraded) model. This prevents the fast model from
3430
+ // hallucinating "already set" when the user explicitly asks to update text.
3431
+ if (initialPlan?.intent === "content_answer" &&
3432
+ /\b(update|change|set|replace|make|rewrite|edit|rename|translate|fix)\b/i.test(plannerMessage)) {
3433
+ ctx.log.info({ event: "content_answer_rejected_for_edit", model: modelUsed }, "Rejected content_answer for an edit request — retrying with full model");
3434
+ initialPlan = null;
3435
+ }
3436
+ markPlanningStart();
3437
+ // Streamed per-op apply: apply ops as they stream from the LLM, so the user
3438
+ // sees changes at ~800ms intervals instead of waiting for the full plan.
3439
+ const streamedPerOpApplyEnabled = incrementalApplyEnabled &&
3440
+ incrementalPlanStreamEnabled &&
3441
+ !/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
3442
+ Boolean(options?.onOpApplied);
3443
+ const streamApplyState = {
3444
+ appliedCount: 0,
3445
+ failedAtIndex: null,
3446
+ hasStructuralOps: false,
3447
+ /** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
3448
+ rollbackSnapshots: new Map()
3449
+ };
3450
+ // Skip the planning loop if the parallel race already produced an initialPlan
3451
+ for (let attempt = initialPlan ? maxPlanningAttempts + 1 : 1; attempt <= maxPlanningAttempts; attempt += 1) {
3452
+ throwIfCanceled(options?.signal);
3453
+ planningAttempts = attempt;
3454
+ try {
3455
+ if (attempt === 1)
3456
+ emitStatusTone("thinking");
3457
+ else
3458
+ emitStatus(`Retrying plan generation (${attempt}/${maxPlanningAttempts})...`);
3459
+ const result = await raceCancel(generatePlanImpl({
3460
+ message: plannerMessage,
3461
+ slug: effectiveSlug,
3462
+ currentPage: current,
3463
+ contextPack: plannerContext,
3464
+ model: modelUsed,
3465
+ locale: body.locale,
3466
+ imageSourceChoiceOpen,
3467
+ history: isLightweightEdit ? [] : plannerChatHistory,
3468
+ siteContextBlock: isLightweightEdit ? undefined : siteContextBlock,
3469
+ toolRuntime: supportsNativeTools && !isLightweightEdit ? ctx.toolRuntime : undefined,
3470
+ toolCallContext: supportsNativeTools && !isLightweightEdit
3471
+ ? {
3472
+ siteId: body.siteId ?? "default",
3473
+ sessionId: body.session ?? "dev",
3474
+ traceId: chatRequestId,
3475
+ gdriveFolderId
3476
+ }
3477
+ : undefined,
3478
+ onStatusUpdate: options?.onStatusUpdate,
3479
+ onImageProgress: options?.onImageProgress,
3480
+ onToolExecution: supportsNativeTools && !isLightweightEdit
3481
+ ? (event) => {
3482
+ if (event.ok && !event.deferred && event.toolName === "unsplash.search")
3483
+ usedNativeUnsplashTool = true;
3484
+ if (event.ok && !event.deferred && event.toolName === "image.generate")
3485
+ usedNativeImageTool = true;
3486
+ ctx.chatTelemetry.push({
3487
+ id: chatRequestId,
3488
+ at: new Date().toISOString(),
3489
+ phase: "tool_call",
3490
+ session: body.session ?? "dev",
3491
+ requestedSlug,
3492
+ effectiveSlug,
3493
+ plannerSource,
3494
+ modelKey,
3495
+ modelUsed,
3496
+ promptHash,
3497
+ promptExcerpt,
3498
+ promptLength: plannerMessage.length,
3499
+ toolName: event.toolName,
3500
+ toolOk: event.ok,
3501
+ toolLatencyMs: event.latencyMs,
3502
+ toolAttempts: event.attempts,
3503
+ toolErrorCode: event.errorCode,
3504
+ correlationId: event.traceId,
3505
+ outcome: event.ok ? "tool_ok" : "tool_error",
3506
+ ...timingFields()
3507
+ });
3508
+ }
3509
+ : undefined,
3510
+ log: ctx.log,
3511
+ onToken: onPlanningToken,
3512
+ onThinking: options?.onThinking,
3513
+ thinking: thinkingConfig,
3514
+ onFieldDraft: options?.onFieldDraft,
3515
+ onSummaryChunk: options?.onSummaryChunk,
3516
+ onChangeLogEntry: options?.onChangeLogEntry,
3517
+ onPlannedOp: incrementalPlanStreamEnabled
3518
+ ? async (op, index) => {
3519
+ markFirstStructuredProgress();
3520
+ options?.onPlannedOp?.({ op, index });
3521
+ // Streamed per-op apply: validate and apply each op as it streams
3522
+ // in from the LLM, so the user sees changes progressively instead
3523
+ // of waiting for the entire plan.
3524
+ if (streamedPerOpApplyEnabled && options?.onOpApplied) {
3525
+ try {
3526
+ // Snapshot the target page before we touch it (once per slug)
3527
+ const opSlug = ("pageSlug" in op && typeof op.pageSlug === "string") ? op.pageSlug : effectiveSlug;
3528
+ if (!streamApplyState.rollbackSnapshots.has(opSlug)) {
3529
+ const existingPage = getPage(body.session, opSlug);
3530
+ streamApplyState.rollbackSnapshots.set(opSlug, existingPage ? structuredClone(existingPage) : null);
3531
+ }
3532
+ // Skip ops that need the full plan context before applying:
3533
+ // - Page-level structural ops (create_page, rename_page, etc.) can't
3534
+ // be applied piecemeal because they affect navigation/history state.
3535
+ // - remove_block requires the complete op list so the tier-1
3536
+ // destructive-action gate (bulk deletes, majority-wipe) can evaluate
3537
+ // the full scope before any blocks are deleted from the preview.
3538
+ // Applying remove_block mid-stream would erase blocks before the gate
3539
+ // runs, then surface an "Approve" card for already-deleted content.
3540
+ const isStructural = op.op === "create_page" || op.op === "rename_page" || op.op === "remove_page" || op.op === "move_page" || op.op === "duplicate_page" || op.op === "remove_block";
3541
+ if (isStructural) {
3542
+ streamApplyState.hasStructuralOps = true;
3543
+ return;
3544
+ }
3545
+ // Once a page-affecting structural op (create/duplicate/rename/remove/move
3546
+ // on a page) has been deferred earlier in this stream, defer ALL subsequent
3547
+ // ops too. Downstream update_props/add_block ops may target a page slug that
3548
+ // hasn't materialized yet (e.g. update_props on /season-recipes after a
3549
+ // deferred duplicate_page /recipes → /season-recipes); applying them
3550
+ // mid-stream would throw "Page not found", trigger a costly rollback +
3551
+ // full re-apply + repair loop, and frequently leave only the structural op
3552
+ // surviving the LLM's repair attempt.
3553
+ if (streamApplyState.hasStructuralOps) {
3554
+ return;
3555
+ }
3556
+ validateOperations([op]);
3557
+ // allowNoEffectiveChange: an op that matches existing values (e.g. a
3558
+ // block already in the target translation language) is a benign skip,
3559
+ // not a failure. Without it the engine throws no_effective_change, which
3560
+ // would set failedAtIndex and force a full rollback + re-apply of the
3561
+ // whole plan — discarding the blocks that genuinely changed.
3562
+ const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
3563
+ if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
3564
+ options?.onOpSkipped?.({
3565
+ index,
3566
+ total: index, // total unknown during streaming
3567
+ op,
3568
+ reason: stepResult.skippedOps[0]?.reason ?? "unchanged_value"
3569
+ });
3570
+ return;
3571
+ }
3572
+ streamApplyState.appliedCount += 1;
3573
+ if (firstApplyMs === null) {
3574
+ firstApplyMs = Date.now() - pipelineStartedAtMs;
3575
+ stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
3576
+ }
3577
+ const previewVersion = bumpVersion(body.session);
3578
+ options.onOpApplied({
3579
+ index,
3580
+ total: index, // total unknown during streaming
3581
+ op,
3582
+ previewVersion,
3583
+ focusBlockId: pickFocusBlockId([op])
3584
+ });
3585
+ }
3586
+ catch (streamApplyErr) {
3587
+ // If an op fails, mark for full rollback+re-apply after streaming
3588
+ streamApplyState.failedAtIndex = index;
3589
+ ctx.log.warn({ event: "streamed_op_apply_failed", chatRequestId, opIndex: index, error: toErrorDetail(streamApplyErr) }, "Streamed op apply failed — will re-apply full plan after streaming");
3590
+ }
3591
+ }
3592
+ }
3593
+ : undefined,
3594
+ componentsManifest,
3595
+ lightweight: shouldPreferFastModelForMessage(plannerMessage),
3596
+ signal: options?.signal
3597
+ }), options?.signal);
3598
+ initialPlan = result.plan;
3599
+ planUsage = result.usage;
3600
+ if (result.schemaContext) {
3601
+ plannerContextTelemetryFields.contractMode = result.schemaContext.contractMode;
3602
+ plannerContextTelemetryFields.contractBytes = result.schemaContext.contractBytes;
3603
+ plannerContextTelemetryFields.contractBlockCount = result.schemaContext.contractBlockCount;
3604
+ plannerContextTelemetryFields.strictJsonEnabled = result.schemaContext.strictJsonEnabled;
3605
+ }
3606
+ markPlanningFinish();
3607
+ emitStatusTone("validating");
3608
+ break;
3609
+ }
3610
+ catch (error) {
3611
+ if (isCancelError(error))
3612
+ throw error;
3613
+ const reason = toErrorDetail(error);
3614
+ // API-level errors (rate limits, auth, quota) — surface immediately, don't retry
3615
+ const errorMsg = error instanceof Error ? error.message : "";
3616
+ const isApiError = /usage limit|rate limit|quota|unauthorized|billing/i.test(errorMsg) || /\b40[0-13]\b/.test(errorMsg);
3617
+ if (isApiError) {
3618
+ markPlanningFinish();
3619
+ const providerLabel = plannerSource === "anthropic" ? "Anthropic" : plannerSource === "openai" ? "OpenAI" : plannerSource === "gemini" ? "Google Gemini" : "The AI provider";
3620
+ const consoleUrl = plannerSource === "anthropic" ? "https://console.anthropic.com/settings/billing" : plannerSource === "openai" ? "https://platform.openai.com/usage" : plannerSource === "gemini" ? "https://console.cloud.google.com/billing" : "";
3621
+ const consoleLink = consoleUrl ? ` [Check your ${providerLabel} console](${consoleUrl}).` : "";
3622
+ const userSummary = /usage limit/i.test(errorMsg)
3623
+ ? `${providerLabel} API usage limit has been reached.${consoleLink} You can also switch to a different provider.`
3624
+ : /rate limit/i.test(errorMsg)
3625
+ ? `${providerLabel} is rate-limiting requests. Please wait a moment and try again.`
3626
+ : /unauthorized|api.key|authentication/i.test(errorMsg)
3627
+ ? `${providerLabel} API key is invalid or expired.${consoleLink} Please check your API key configuration.`
3628
+ : `${providerLabel} error: ${errorMsg.slice(0, 200)}`;
3629
+ ctx.log.error({ event: "api_error", model: modelUsed, error: errorMsg.slice(0, 300) }, userSummary);
3630
+ return {
3631
+ code: 503,
3632
+ payload: withDebugPayload({
3633
+ status: "error",
3634
+ summary: userSummary,
3635
+ changes: [],
3636
+ previewVersion: versions.get(body.session) ?? 0,
3637
+ plannerSource,
3638
+ modelUsed,
3639
+ modelKey
3640
+ }, { outcome: "api_error", reason: errorMsg.slice(0, 300) })
3641
+ };
3642
+ }
3643
+ const reasonCategory = isPlannerOutputError(error) ? error.reasonCategory : classifyGuardrailError(reason);
3644
+ ctx.log.warn({ event: "plan_attempt_failed", attempt, model: modelUsed, reason: reason.slice(0, 300) }, `Planning attempt ${attempt} failed`);
3645
+ ctx.chatTelemetry.push({
3646
+ id: chatRequestId,
3647
+ at: new Date().toISOString(),
3648
+ phase: "plan_attempt_failed",
3649
+ session: body.session,
3650
+ requestedSlug,
3651
+ effectiveSlug,
3652
+ plannerSource,
3653
+ modelKey,
3654
+ modelUsed,
3655
+ promptHash,
3656
+ promptExcerpt,
3657
+ promptLength: plannerMessage.length,
3658
+ outcome: `attempt_${attempt}_failed`,
3659
+ reason: reason.slice(0, 300),
3660
+ reasonCategory,
3661
+ plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
3662
+ plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
3663
+ ...timingFields()
3664
+ });
3665
+ if (isPlannerOutputError(error) && !error.retryable) {
3666
+ markPlanningFinish();
3667
+ ctx.chatTelemetry.push({
3668
+ id: chatRequestId,
3669
+ at: new Date().toISOString(),
3670
+ phase: "result",
3671
+ session: body.session,
3672
+ requestedSlug,
3673
+ effectiveSlug,
3674
+ plannerSource,
3675
+ modelKey,
3676
+ modelUsed,
3677
+ promptHash,
3678
+ promptExcerpt,
3679
+ promptLength: plannerMessage.length,
3680
+ outcome: reasonCategory === "planner_refusal" ? "planning_refusal" : "planning_incomplete",
3681
+ reason: reason.slice(0, 300),
3682
+ reasonCategory,
3683
+ plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
3684
+ plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
3685
+ plannerTier: "full_llm",
3686
+ ...plannerContextTelemetryFields,
3687
+ ...timingFields()
3688
+ });
3689
+ if (reasonCategory === "planner_refusal") {
3690
+ return {
3691
+ code: 200,
3692
+ payload: withDebugPayload({
3693
+ status: "needs_clarification",
3694
+ summary: "I can’t help with that request as written. Please rephrase and I can try a safe alternative.",
3695
+ changes: [],
3696
+ suggestions: [
3697
+ "Describe the same goal in safer, neutral terms.",
3698
+ "Limit the request to website content edits only.",
3699
+ "Ask for a high-level rewrite without sensitive details."
3700
+ ],
3701
+ previewVersion: versions.get(body.session) ?? 0,
3702
+ plannerSource,
3703
+ modelUsed,
3704
+ modelKey
3705
+ }, { outcome: "planning_refusal", reasonCategory })
3706
+ };
3707
+ }
3708
+ return {
3709
+ code: 500,
3710
+ payload: withDebugPayload({
3711
+ status: "error",
3712
+ summary: "I couldn’t finish generating a plan. Please try again.",
3713
+ changes: [],
3714
+ validationErrors: [reason.slice(0, 300)],
3715
+ previewVersion: versions.get(body.session) ?? 0,
3716
+ plannerSource,
3717
+ modelUsed,
3718
+ modelKey
3719
+ }, { outcome: "planning_incomplete", reasonCategory, reason: reason.slice(0, 300), plannerTier: "full_llm" })
3720
+ };
3721
+ }
3722
+ planningErrors.push(`Attempt ${attempt} planning failed: ${reason}`);
3723
+ if (attempt === maxPlanningAttempts) {
3724
+ markPlanningFinish();
3725
+ ctx.chatTelemetry.push({
3726
+ id: chatRequestId,
3727
+ at: new Date().toISOString(),
3728
+ phase: "result",
3729
+ session: body.session,
3730
+ requestedSlug,
3731
+ effectiveSlug,
3732
+ plannerSource,
3733
+ modelKey,
3734
+ modelUsed,
3735
+ promptHash,
3736
+ promptExcerpt,
3737
+ promptLength: plannerMessage.length,
3738
+ outcome: "planning_exhausted",
3739
+ reason: reason.slice(0, 300),
3740
+ reasonCategory,
3741
+ plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
3742
+ plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
3743
+ plannerTier: "full_llm",
3744
+ ...plannerContextTelemetryFields,
3745
+ ...timingFields()
3746
+ });
3747
+ return {
3748
+ code: 500,
3749
+ payload: withDebugPayload({
3750
+ status: "error",
3751
+ summary: "Could not generate an edit plan.",
3752
+ changes: [reason.slice(0, 300)],
3753
+ validationErrors: planningErrors.slice(-3),
3754
+ previewVersion: versions.get(body.session) ?? 0,
3755
+ plannerSource,
3756
+ modelUsed,
3757
+ modelKey
3758
+ }, { outcome: "planning_exhausted", reasonCategory, reason: reason.slice(0, 300), plannerTier: "full_llm" })
3759
+ };
3760
+ }
3761
+ }
3762
+ }
3763
+ if (!initialPlan) {
3764
+ markPlanningFinish();
3765
+ ctx.chatTelemetry.push({
3766
+ id: chatRequestId,
3767
+ at: new Date().toISOString(),
3768
+ phase: "result",
3769
+ session: body.session,
3770
+ requestedSlug,
3771
+ effectiveSlug,
3772
+ plannerSource,
3773
+ modelKey,
3774
+ modelUsed,
3775
+ promptHash,
3776
+ promptExcerpt,
3777
+ promptLength: plannerMessage.length,
3778
+ outcome: "planning_missing",
3779
+ plannerTier: "full_llm",
3780
+ ...plannerContextTelemetryFields,
3781
+ ...timingFields()
3782
+ });
3783
+ return {
3784
+ code: 500,
3785
+ payload: withDebugPayload({
3786
+ status: "error",
3787
+ summary: "Could not generate an edit plan.",
3788
+ changes: [],
3789
+ validationErrors: planningErrors.slice(-3),
3790
+ previewVersion: versions.get(body.session) ?? 0,
3791
+ plannerSource,
3792
+ modelUsed,
3793
+ modelKey
3794
+ }, { outcome: "planning_missing", plannerTier: "full_llm" })
3795
+ };
3796
+ }
3797
+ // If streamed per-op apply already applied all ops successfully and no structural
3798
+ // ops are pending, skip the normal apply step in respondFromPlan.
3799
+ const streamedApplyComplete = streamedPerOpApplyEnabled &&
3800
+ streamApplyState.appliedCount > 0 &&
3801
+ streamApplyState.failedAtIndex === null &&
3802
+ !streamApplyState.hasStructuralOps;
3803
+ // If streamed apply partially failed, roll back and let respondFromPlan re-apply everything
3804
+ if (streamedPerOpApplyEnabled && streamApplyState.failedAtIndex !== null && streamApplyState.rollbackSnapshots.size > 0) {
3805
+ const rolledBackSlugs = [];
3806
+ for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
3807
+ if (snapshot) {
3808
+ setPage(body.session, { ...snapshot, slug });
3809
+ }
3810
+ else {
3811
+ removePage(body.session, slug);
3812
+ }
3813
+ rolledBackSlugs.push(slug);
3814
+ }
3815
+ ctx.log.warn({ event: "streamed_op_rollback", chatRequestId, appliedCount: streamApplyState.appliedCount, failedAtIndex: streamApplyState.failedAtIndex, rolledBackSlugs }, `Rolled back ${rolledBackSlugs.length} page(s) after streamed op failure at index ${streamApplyState.failedAtIndex}`);
3816
+ }
3817
+ const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
3818
+ ? { preApplied: true, undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined }
3819
+ : undefined, "full_llm");
3820
+ if (initialOutcome.done)
3821
+ return initialOutcome.response;
3822
+ if (!isDeterministicRepairEligible(initialOutcome.reason)) {
3823
+ return guardrailFailureResponse({ reason: initialOutcome.reason, source: plannerSource });
3824
+ }
3825
+ let repairedPlan;
3826
+ try {
3827
+ throwIfCanceled(options?.signal);
3828
+ emitStatusTone("repairing");
3829
+ ctx.chatTelemetry.push({
3830
+ id: chatRequestId,
3831
+ at: new Date().toISOString(),
3832
+ phase: "repair_attempt",
3833
+ session: body.session,
3834
+ requestedSlug,
3835
+ effectiveSlug,
3836
+ plannerSource,
3837
+ modelKey,
3838
+ modelUsed,
3839
+ promptHash,
3840
+ promptExcerpt,
3841
+ promptLength: plannerMessage.length,
3842
+ outcome: "repair_started",
3843
+ reason: initialOutcome.reason.slice(0, 300),
3844
+ reasonCategory: classifyGuardrailError(initialOutcome.reason),
3845
+ ...timingFields()
3846
+ });
3847
+ planningAttempts += 1;
3848
+ plannerContextTelemetryFields.schemaRetryUsed = true;
3849
+ const repairFeedback = /full-page translation coverage/i.test(initialOutcome.reason)
3850
+ ? `${initialOutcome.reason}. Repair for translation completeness: include missing translated text fields for list children across all affected blocks. Preserve links/hrefs unchanged.`
3851
+ : /explicit cta target coverage/i.test(initialOutcome.reason)
3852
+ ? `${initialOutcome.reason}. Repair for multi-target CTA completeness: update both hero and footer CTA text targets requested by the user, keep all CTA links/hrefs unchanged, and preserve user constraints such as punctuation bans.`
3853
+ : buildDeterministicRepairFeedback(initialOutcome.reason);
3854
+ const repairResult = await raceCancel(generatePlanImpl({
3855
+ message: plannerMessage,
3856
+ slug: effectiveSlug,
3857
+ currentPage: current,
3858
+ contextPack: plannerContext,
3859
+ model: modelUsed,
3860
+ locale: body.locale,
3861
+ imageSourceChoiceOpen,
3862
+ history: plannerChatHistory,
3863
+ feedback: repairFeedback,
3864
+ forceFullSchemaContracts: true,
3865
+ siteContextBlock,
3866
+ onToken: onPlanningToken,
3867
+ onThinking: options?.onThinking,
3868
+ thinking: thinkingConfig,
3869
+ onFieldDraft: options?.onFieldDraft,
3870
+ onSummaryChunk: options?.onSummaryChunk,
3871
+ onChangeLogEntry: options?.onChangeLogEntry,
3872
+ onPlannedOp: incrementalPlanStreamEnabled
3873
+ ? (op, index) => {
3874
+ markFirstStructuredProgress();
3875
+ options?.onPlannedOp?.({ op, index });
3876
+ }
3877
+ : undefined,
3878
+ signal: options?.signal
3879
+ }), options?.signal);
3880
+ repairedPlan = repairResult.plan;
3881
+ planUsage = repairResult.usage;
3882
+ if (repairResult.schemaContext) {
3883
+ plannerContextTelemetryFields.contractMode = repairResult.schemaContext.contractMode;
3884
+ plannerContextTelemetryFields.contractBytes = repairResult.schemaContext.contractBytes;
3885
+ plannerContextTelemetryFields.contractBlockCount = repairResult.schemaContext.contractBlockCount;
3886
+ plannerContextTelemetryFields.strictJsonEnabled = repairResult.schemaContext.strictJsonEnabled;
3887
+ }
3888
+ markPlanningFinish();
3889
+ ctx.chatTelemetry.push({
3890
+ id: chatRequestId,
3891
+ at: new Date().toISOString(),
3892
+ phase: "repair_generated",
3893
+ session: body.session,
3894
+ requestedSlug,
3895
+ effectiveSlug,
3896
+ plannerSource,
3897
+ modelKey,
3898
+ modelUsed,
3899
+ promptHash,
3900
+ promptExcerpt,
3901
+ promptLength: plannerMessage.length,
3902
+ outcome: "repair_plan_generated",
3903
+ intent: repairedPlan.intent,
3904
+ opCount: repairedPlan.ops.length,
3905
+ opTypes: repairedPlan.ops.map((op) => op.op),
3906
+ ...timingFields()
3907
+ });
3908
+ }
3909
+ catch (error) {
3910
+ if (isCancelError(error))
3911
+ throw error;
3912
+ markPlanningFinish();
3913
+ const reason = toErrorDetail(error);
3914
+ ctx.chatTelemetry.push({
3915
+ id: chatRequestId,
3916
+ at: new Date().toISOString(),
3917
+ phase: "result",
3918
+ session: body.session,
3919
+ requestedSlug,
3920
+ effectiveSlug,
3921
+ plannerSource,
3922
+ modelKey,
3923
+ modelUsed,
3924
+ promptHash,
3925
+ promptExcerpt,
3926
+ promptLength: plannerMessage.length,
3927
+ outcome: "repair_failed",
3928
+ reason: reason.slice(0, 300),
3929
+ reasonCategory: classifyGuardrailError(reason),
3930
+ ...plannerContextTelemetryFields,
3931
+ ...timingFields()
3932
+ });
3933
+ return {
3934
+ code: 400,
3935
+ payload: withDebugPayload({
3936
+ status: "validation_error",
3937
+ summary: "I could not apply that change safely.",
3938
+ changes: [],
3939
+ validationErrors: [formatValidationError(reason)],
3940
+ previewVersion: versions.get(body.session) ?? 0,
3941
+ plannerSource,
3942
+ modelUsed,
3943
+ modelKey
3944
+ }, { outcome: "repair_failed", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "full_llm" })
3945
+ };
3946
+ }
3947
+ const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode);
3948
+ if (repairedOutcome.done)
3949
+ return repairedOutcome.response;
3950
+ const repairedReason = repairedOutcome.reason;
3951
+ const ctaCoverageRepairFailed = /explicit cta target coverage/i.test(repairedReason);
3952
+ ctx.chatTelemetry.push({
3953
+ id: chatRequestId,
3954
+ at: new Date().toISOString(),
3955
+ phase: "result",
3956
+ session: body.session,
3957
+ requestedSlug,
3958
+ effectiveSlug,
3959
+ plannerSource,
3960
+ modelKey,
3961
+ modelUsed,
3962
+ promptHash,
3963
+ promptExcerpt,
3964
+ promptLength: plannerMessage.length,
3965
+ outcome: "repair_failed",
3966
+ reason: repairedReason.slice(0, 300),
3967
+ reasonCategory: "schema_violation",
3968
+ ...plannerContextTelemetryFields,
3969
+ ...timingFields()
3970
+ });
3971
+ if (ctaCoverageRepairFailed) {
3972
+ return {
3973
+ code: 200,
3974
+ payload: withDebugPayload({
3975
+ status: "needs_clarification",
3976
+ summary: "Please confirm the exact CTA wording for both the hero CTA and footer CTA so I can apply both safely.",
3977
+ changes: [],
3978
+ mentionedSlugs: [effectiveSlug],
3979
+ previewVersion: versions.get(body.session) ?? 0,
3980
+ plannerSource,
3981
+ modelUsed,
3982
+ modelKey
3983
+ }, { outcome: "needs_clarification", reasonCategory: "schema_violation" })
3984
+ };
3985
+ }
3986
+ return {
3987
+ code: 400,
3988
+ payload: withDebugPayload({
3989
+ status: "validation_error",
3990
+ summary: "I could not apply that change safely.",
3991
+ changes: [],
3992
+ validationErrors: [formatValidationError(repairedReason)],
3993
+ previewVersion: versions.get(body.session) ?? 0,
3994
+ plannerSource,
3995
+ modelUsed,
3996
+ modelKey
3997
+ }, { outcome: "repair_failed", reasonCategory: "schema_violation", reason: repairedReason.slice(0, 300), plannerTier: "full_llm" })
3998
+ };
3999
+ }