@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,1361 @@
1
+ import OpenAI from "openai";
2
+ import { z } from "zod";
3
+ import { allowedBlockTypes, blockSchemas, editPlanSchema } from "@avocadostudio-ai/shared";
4
+ import { buildIntentParserSystemPrompt, buildPlannerSystemPrompt } from "./prompts.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Singleton OpenAI client — reuses connection pool across requests.
7
+ // ---------------------------------------------------------------------------
8
+ const LLM_TIMEOUT_MS = 120_000;
9
+ let _openaiSingleton = null;
10
+ function getOpenAIClient() {
11
+ if (!_openaiSingleton) {
12
+ _openaiSingleton = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
13
+ }
14
+ return _openaiSingleton;
15
+ }
16
+ /** Reset the singleton (useful for tests that swap API keys). */
17
+ export function resetOpenAIClient() {
18
+ _openaiSingleton = null;
19
+ }
20
+ import { extractAudienceTarget, blockContractsSummary, fetchImageAsBase64, resolveAttachmentsForLlm, pageMetaContractSummary, intentSchema } from "../nlp/deterministic-planner.js";
21
+ import { isBatchAddRequest, isBatchRemoveRequest, isBatchReorderRequest, isPageWideRewriteRequest, isDuplicateAndModifyRequest, requestsPlanFirst } from "../nlp/intent-detection.js";
22
+ import { extractJsonObject, inferBlockTypeFromText, normalizePlanCandidate, repairAndParseJsonWithMeta } from "../nlp/plan-normalizer.js";
23
+ import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
24
+ import { intentJsonSchema } from "./plan-json-schema.js";
25
+ import { PlannerError, isPlannerError } from "../errors.js";
26
+ // Re-export for backward compatibility
27
+ export { PlannerError, isPlannerError };
28
+ /** @deprecated Use `PlannerError` instead. */
29
+ export const PlannerOutputError = PlannerError;
30
+ /** @deprecated Use `isPlannerError` instead. */
31
+ export const isPlannerOutputError = isPlannerError;
32
+ const rawPlanCandidateSchema = z.looseObject({
33
+ intent: z.enum(["edit_plan", "needs_clarification", "content_answer"]).optional().catch(undefined),
34
+ summary_for_user: z.string().optional(),
35
+ change_log: z.union([z.array(z.string()), z.string()]).optional(),
36
+ ops: z.array(z.record(z.string(), z.unknown())).optional(),
37
+ suggested_next_actions: z.union([z.array(z.string()), z.string()]).optional()
38
+ });
39
+ function asObject(value) {
40
+ return value && typeof value === "object" ? value : null;
41
+ }
42
+ function toPlannerError(category, message, retryable = false) {
43
+ return new PlannerError(message, { reasonCategory: category, retryable });
44
+ }
45
+ function extractCompletionRefusal(completion) {
46
+ const choices = asObject(completion)?.choices;
47
+ if (!Array.isArray(choices) || choices.length === 0)
48
+ return null;
49
+ const choice = asObject(choices[0]);
50
+ const message = asObject(choice?.message);
51
+ const refusal = message?.refusal;
52
+ return typeof refusal === "string" && refusal.trim().length > 0 ? refusal.trim() : null;
53
+ }
54
+ function extractResponsesOutcome(response) {
55
+ const directRefusal = asObject(response)?.refusal;
56
+ if (typeof directRefusal === "string" && directRefusal.trim().length > 0) {
57
+ return { refusal: directRefusal.trim(), incomplete: false };
58
+ }
59
+ const status = asObject(response)?.status;
60
+ if (status === "incomplete")
61
+ return { refusal: null, incomplete: true };
62
+ const output = asObject(response)?.output;
63
+ if (Array.isArray(output)) {
64
+ for (const item of output) {
65
+ const refusal = asObject(item)?.refusal;
66
+ if (typeof refusal === "string" && refusal.trim().length > 0)
67
+ return { refusal: refusal.trim(), incomplete: false };
68
+ const itemStatus = asObject(item)?.status;
69
+ if (itemStatus === "incomplete")
70
+ return { refusal: null, incomplete: true };
71
+ }
72
+ }
73
+ return { refusal: null, incomplete: false };
74
+ }
75
+ export function isChatStrictPrimaryOpMode() {
76
+ return /^(1|true|yes|on)$/i.test((process.env.CHAT_STRICT_PRIMARY_OP_MODE ?? "").trim());
77
+ }
78
+ export function isPageWideTranslationRequest(message) {
79
+ const lower = message.toLowerCase();
80
+ const asksTranslation = /\btranslate\b/.test(lower) ||
81
+ /\btranslation\b/.test(lower) ||
82
+ /\blocaliz/.test(lower) ||
83
+ /\bgerman\b/.test(lower) ||
84
+ /\bdeutsch\b/.test(lower);
85
+ if (!asksTranslation)
86
+ return false;
87
+ return (/\b(this|the|entire|whole|full)\s+(\w+\s+)*page\b/.test(lower) ||
88
+ /\bwhole\s+site\b/.test(lower) ||
89
+ /\ball\s+sections?\b/.test(lower) ||
90
+ /\btranslate\s+(\w+\s+)*page\b/.test(lower));
91
+ }
92
+ const EDIT_PLAN_SHAPE_HINT = {
93
+ intent: "edit_plan | needs_clarification",
94
+ summary_for_user: "string",
95
+ change_log: ["string"],
96
+ ops: ["Operation[]"],
97
+ suggested_next_actions: ["string (optional, 2-4 items)"]
98
+ };
99
+ export function isStrictJsonResponseEnabled() {
100
+ return /^(1|true|yes|on)$/i.test((process.env.CHAT_STRICT_JSON_RESPONSE ?? "").trim());
101
+ }
102
+ export function isAdaptiveSchemaContextEnabled() {
103
+ return /^(1|true|yes|on)$/i.test((process.env.CHAT_ADAPTIVE_SCHEMA_CONTEXT ?? "").trim());
104
+ }
105
+ function schemaBudgetBytes() {
106
+ const parsed = Number(process.env.CHAT_SCHEMA_BUDGET_BYTES ?? "9000");
107
+ if (!Number.isFinite(parsed) || parsed <= 0)
108
+ return 9000;
109
+ return Math.max(512, Math.trunc(parsed));
110
+ }
111
+ function bytesForPayload(value) {
112
+ try {
113
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
114
+ }
115
+ catch {
116
+ return Number.MAX_SAFE_INTEGER;
117
+ }
118
+ }
119
+ function uniqueBlockTypes(values) {
120
+ const known = new Set(allowedBlockTypes.map((type) => type.toLowerCase()));
121
+ const out = [];
122
+ const seen = new Set();
123
+ for (const value of values) {
124
+ if (typeof value !== "string" || value.length === 0)
125
+ continue;
126
+ const normalized = value.toLowerCase();
127
+ if (!known.has(normalized) || seen.has(normalized))
128
+ continue;
129
+ seen.add(normalized);
130
+ const canonical = allowedBlockTypes.find((type) => type.toLowerCase() === normalized);
131
+ if (canonical)
132
+ out.push(canonical);
133
+ }
134
+ return out;
135
+ }
136
+ function pickTargetBlockTypes(args) {
137
+ const selectedType = typeof args.contextPack.selected.blockType === "string" ? args.contextPack.selected.blockType : null;
138
+ const inferredFromMessage = inferBlockTypeFromText(args.message);
139
+ const referencedTypes = [
140
+ args.contextPack.resolvedReferences.target?.type,
141
+ args.contextPack.resolvedReferences.anchor?.type,
142
+ ...args.contextPack.resolvedReferences.mentionedBlocks.map((item) => item.type)
143
+ ];
144
+ return uniqueBlockTypes([selectedType, inferredFromMessage, ...referencedTypes]);
145
+ }
146
+ function shouldUseMinimalContracts(message) {
147
+ const lower = message.toLowerCase();
148
+ if (/\b(seo|meta|metadata|og\s*image|open\s*graph)\b/.test(lower))
149
+ return false;
150
+ if (/\b(translate|translation|localiz)\b/.test(lower))
151
+ return false;
152
+ if (/\b(create|add|insert|build|generate|update|change|edit|rewrite|rephrase)\b/.test(lower))
153
+ return false;
154
+ return (/\b(remove|delete|move|reorder|rename)\b/.test(lower) ||
155
+ /\b(remove|delete|move|reorder|rename)\b.*\bpage\b/.test(lower));
156
+ }
157
+ function shouldUseFullContracts(args) {
158
+ if (args.forceFullContracts)
159
+ return true;
160
+ if (args.pageWideTranslation || args.batchOverride)
161
+ return true;
162
+ const lower = args.message.toLowerCase();
163
+ const structuralGeneration = /\b(create|add|insert|build|generate)\b/.test(lower) &&
164
+ !/\b(page|meta|seo)\b/.test(lower) &&
165
+ args.targetBlockTypes.length === 0;
166
+ if (structuralGeneration)
167
+ return true;
168
+ return false;
169
+ }
170
+ function buildContractsForMode(args) {
171
+ const payload = {
172
+ knownBlockTypes: args.knownBlockTypes,
173
+ editPlanShape: EDIT_PLAN_SHAPE_HINT
174
+ };
175
+ const allContracts = args.allContracts;
176
+ if (args.mode === "full") {
177
+ payload.blockContracts = allContracts;
178
+ }
179
+ else if (args.mode === "targeted") {
180
+ const filtered = {};
181
+ for (const type of args.targetBlockTypes) {
182
+ if (type in allContracts)
183
+ filtered[type] = allContracts[type];
184
+ }
185
+ if (Object.keys(filtered).length > 0)
186
+ payload.blockContracts = filtered;
187
+ }
188
+ if (args.includePageMetaContract)
189
+ payload.pageMetaContract = pageMetaContractSummary();
190
+ return payload;
191
+ }
192
+ export function buildPlannerSchemaContext(args) {
193
+ const strictJsonEnabled = isStrictJsonResponseEnabled();
194
+ const targetBlockTypes = pickTargetBlockTypes({ message: args.message, contextPack: args.contextPack });
195
+ const includePageMetaContract = /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/i.test(args.message) || /\d{2,3}\s*char/i.test(args.message);
196
+ const knownTypes = args.effectiveBlockTypes
197
+ ?? (args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : Object.keys(blockSchemas));
198
+ const allContracts = blockContractsSummary(args.componentsManifest);
199
+ if (!isAdaptiveSchemaContextEnabled()) {
200
+ const payload = args.legacyIncludeContracts
201
+ ? {
202
+ blockContracts: allContracts,
203
+ pageMetaContract: pageMetaContractSummary(),
204
+ knownBlockTypes: knownTypes,
205
+ editPlanShape: EDIT_PLAN_SHAPE_HINT
206
+ }
207
+ : {};
208
+ const contractBlockCount = payload.blockContracts ? Object.keys(payload.blockContracts).length : 0;
209
+ return {
210
+ payload,
211
+ meta: {
212
+ contractMode: args.legacyIncludeContracts ? "full" : "minimal",
213
+ contractBytes: bytesForPayload(payload),
214
+ contractBlockCount,
215
+ targetBlockTypes,
216
+ strictJsonEnabled
217
+ }
218
+ };
219
+ }
220
+ const forceFullContracts = args.forceFullContracts === true;
221
+ const preferredMode = shouldUseFullContracts({
222
+ message: args.message,
223
+ batchOverride: args.batchOverride,
224
+ pageWideTranslation: args.pageWideTranslation,
225
+ forceFullContracts,
226
+ targetBlockTypes
227
+ })
228
+ ? "full"
229
+ : shouldUseMinimalContracts(args.message)
230
+ ? "minimal"
231
+ : "targeted";
232
+ const fallbackOrder = preferredMode === "full"
233
+ ? ["full", "targeted", "minimal"]
234
+ : preferredMode === "targeted"
235
+ ? ["targeted", "minimal"]
236
+ : ["minimal"];
237
+ const budget = schemaBudgetBytes();
238
+ let selectedMode = fallbackOrder[0];
239
+ let selectedPayload = buildContractsForMode({
240
+ mode: selectedMode,
241
+ targetBlockTypes,
242
+ includePageMetaContract,
243
+ allContracts,
244
+ knownBlockTypes: knownTypes
245
+ });
246
+ let selectedBytes = bytesForPayload(selectedPayload);
247
+ for (const mode of fallbackOrder) {
248
+ const payload = buildContractsForMode({ mode, targetBlockTypes, includePageMetaContract, allContracts, knownBlockTypes: knownTypes });
249
+ const bytes = bytesForPayload(payload);
250
+ selectedMode = mode;
251
+ selectedPayload = payload;
252
+ selectedBytes = bytes;
253
+ if (bytes <= budget)
254
+ break;
255
+ }
256
+ const contractBlockCount = selectedPayload.blockContracts ? Object.keys(selectedPayload.blockContracts).length : 0;
257
+ return {
258
+ payload: selectedPayload,
259
+ meta: {
260
+ contractMode: selectedMode,
261
+ contractBytes: selectedBytes,
262
+ contractBlockCount,
263
+ targetBlockTypes,
264
+ strictJsonEnabled
265
+ }
266
+ };
267
+ }
268
+ export function openAIChatOptionsForModel(model) {
269
+ // o-series and gpt-5 family reject temperature in chat.completions; omit to use model default.
270
+ const lower = model.toLowerCase();
271
+ if (lower.startsWith("o1") || lower.startsWith("o3") || lower.startsWith("o4") || lower.startsWith("gpt-5"))
272
+ return {};
273
+ return { temperature: 0 };
274
+ }
275
+ export function isResponsesOnlyModel(_model) {
276
+ // No current OpenAI model requires the Responses API exclusively; all supported models
277
+ // use chat.completions. Update this if a future model mandates the Responses API.
278
+ return false;
279
+ }
280
+ export function openAiModelSupportsPdfFiles(model) {
281
+ // chat.completions accepts PDF `file` content parts on the gpt-4o / gpt-4.1 /
282
+ // gpt-5 families. Other models (o-series reasoning, legacy) reject them and
283
+ // would fail the planning call — callers drop PDF parts for those.
284
+ return /gpt-4o|gpt-4\.1|gpt-5/i.test(model);
285
+ }
286
+ export function extractResponsesOutputText(response) {
287
+ const direct = response?.output_text;
288
+ if (typeof direct === "string" && direct.length > 0)
289
+ return direct;
290
+ const output = response?.output;
291
+ if (!Array.isArray(output))
292
+ return "";
293
+ const chunks = [];
294
+ for (const item of output) {
295
+ if (!item || typeof item !== "object")
296
+ continue;
297
+ const content = item.content;
298
+ if (!Array.isArray(content))
299
+ continue;
300
+ for (const part of content) {
301
+ if (!part || typeof part !== "object")
302
+ continue;
303
+ if (part.type === "output_text" && typeof part.text === "string")
304
+ chunks.push(part.text);
305
+ }
306
+ }
307
+ return chunks.join("");
308
+ }
309
+ export function extractSummaryFromPlanBuffer(raw) {
310
+ // Extract summary_for_user value (may be partial if string is still open)
311
+ let summary = null;
312
+ const summaryKey = '"summary_for_user"';
313
+ const summaryKeyIdx = raw.indexOf(summaryKey);
314
+ if (summaryKeyIdx >= 0) {
315
+ const afterKey = raw.indexOf(":", summaryKeyIdx + summaryKey.length);
316
+ if (afterKey >= 0) {
317
+ const quoteStart = raw.indexOf('"', afterKey + 1);
318
+ if (quoteStart >= 0) {
319
+ // Scan for closing quote respecting escapes
320
+ let escape = false;
321
+ let closeIdx = -1;
322
+ for (let i = quoteStart + 1; i < raw.length; i++) {
323
+ const ch = raw[i];
324
+ if (escape) {
325
+ escape = false;
326
+ continue;
327
+ }
328
+ if (ch === "\\") {
329
+ escape = true;
330
+ continue;
331
+ }
332
+ if (ch === '"') {
333
+ closeIdx = i;
334
+ break;
335
+ }
336
+ }
337
+ if (closeIdx >= 0) {
338
+ // Closed string — use JSON.parse for correct decoding (handles \uXXXX etc.)
339
+ try {
340
+ summary = JSON.parse(raw.slice(quoteStart, closeIdx + 1));
341
+ }
342
+ catch {
343
+ // Fallback: return raw content without quotes
344
+ summary = raw.slice(quoteStart + 1, closeIdx);
345
+ }
346
+ }
347
+ else {
348
+ // Still open (partial/streaming) — manual decode is acceptable
349
+ let value = "";
350
+ let esc = false;
351
+ for (let i = quoteStart + 1; i < raw.length; i++) {
352
+ const ch = raw[i];
353
+ if (esc) {
354
+ if (ch === "n")
355
+ value += "\n";
356
+ else if (ch === "t")
357
+ value += "\t";
358
+ else if (ch === "r")
359
+ value += "\r";
360
+ else
361
+ value += ch;
362
+ esc = false;
363
+ continue;
364
+ }
365
+ if (ch === "\\") {
366
+ esc = true;
367
+ continue;
368
+ }
369
+ value += ch;
370
+ }
371
+ if (value.length > 0)
372
+ summary = value;
373
+ }
374
+ }
375
+ }
376
+ }
377
+ // Extract change_log string entries
378
+ const changeLog = [];
379
+ const clKey = '"change_log"';
380
+ const clKeyIdx = raw.indexOf(clKey);
381
+ if (clKeyIdx >= 0) {
382
+ const arrStart = raw.indexOf("[", clKeyIdx + clKey.length);
383
+ if (arrStart >= 0) {
384
+ let inString = false;
385
+ let escape = false;
386
+ let strStart = -1;
387
+ let depth = 0;
388
+ for (let i = arrStart; i < raw.length; i++) {
389
+ const ch = raw[i];
390
+ if (inString) {
391
+ if (escape) {
392
+ escape = false;
393
+ continue;
394
+ }
395
+ if (ch === "\\") {
396
+ escape = true;
397
+ continue;
398
+ }
399
+ if (ch === '"') {
400
+ inString = false;
401
+ if (depth === 1 && strStart >= 0) {
402
+ try {
403
+ const parsed = JSON.parse(raw.slice(strStart, i + 1));
404
+ changeLog.push(parsed);
405
+ }
406
+ catch { /* partial */ }
407
+ strStart = -1;
408
+ }
409
+ }
410
+ continue;
411
+ }
412
+ if (ch === '"') {
413
+ inString = true;
414
+ strStart = i;
415
+ continue;
416
+ }
417
+ if (ch === "[") {
418
+ depth++;
419
+ continue;
420
+ }
421
+ if (ch === "]") {
422
+ if (--depth <= 0)
423
+ break;
424
+ continue;
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return { summary, changeLog };
430
+ }
431
+ export function extractOpsFromPlanBuffer(raw, emittedCount) {
432
+ const opsKeyIdx = raw.indexOf('"ops"');
433
+ if (opsKeyIdx < 0)
434
+ return { nextEmittedCount: emittedCount, newOps: [] };
435
+ const arrStart = raw.indexOf("[", opsKeyIdx);
436
+ if (arrStart < 0)
437
+ return { nextEmittedCount: emittedCount, newOps: [] };
438
+ const extracted = [];
439
+ let inString = false;
440
+ let escape = false;
441
+ let arrDepth = 0;
442
+ let objDepth = 0;
443
+ let objStart = -1;
444
+ for (let i = arrStart; i < raw.length; i += 1) {
445
+ const ch = raw[i];
446
+ if (inString) {
447
+ if (escape) {
448
+ escape = false;
449
+ }
450
+ else if (ch === "\\") {
451
+ escape = true;
452
+ }
453
+ else if (ch === '"') {
454
+ inString = false;
455
+ }
456
+ continue;
457
+ }
458
+ if (ch === '"') {
459
+ inString = true;
460
+ continue;
461
+ }
462
+ if (ch === "[") {
463
+ arrDepth += 1;
464
+ continue;
465
+ }
466
+ if (ch === "]") {
467
+ if (arrDepth > 0)
468
+ arrDepth -= 1;
469
+ if (arrDepth === 0)
470
+ break;
471
+ continue;
472
+ }
473
+ if (arrDepth !== 1)
474
+ continue;
475
+ if (ch === "{") {
476
+ if (objDepth === 0)
477
+ objStart = i;
478
+ objDepth += 1;
479
+ continue;
480
+ }
481
+ if (ch === "}") {
482
+ if (objDepth > 0)
483
+ objDepth -= 1;
484
+ if (objDepth === 0 && objStart >= 0) {
485
+ const json = raw.slice(objStart, i + 1);
486
+ try {
487
+ const parsed = JSON.parse(json);
488
+ extracted.push(parsed);
489
+ }
490
+ catch {
491
+ // Ignore partial/invalid object fragments while streaming.
492
+ }
493
+ objStart = -1;
494
+ }
495
+ }
496
+ }
497
+ if (extracted.length <= emittedCount) {
498
+ return { nextEmittedCount: emittedCount, newOps: [] };
499
+ }
500
+ return {
501
+ nextEmittedCount: extracted.length,
502
+ newOps: extracted.slice(emittedCount)
503
+ };
504
+ }
505
+ function decodeJsonStringFragment(fragment) {
506
+ let out = "";
507
+ for (let i = 0; i < fragment.length; i += 1) {
508
+ const ch = fragment[i];
509
+ if (ch !== "\\") {
510
+ out += ch;
511
+ continue;
512
+ }
513
+ const next = fragment[i + 1];
514
+ if (!next)
515
+ break;
516
+ i += 1;
517
+ if (next === "n")
518
+ out += "\n";
519
+ else if (next === "t")
520
+ out += "\t";
521
+ else if (next === "r")
522
+ out += "\r";
523
+ else if (next === "b")
524
+ out += "\b";
525
+ else if (next === "f")
526
+ out += "\f";
527
+ else if (next === '"' || next === "\\" || next === "/")
528
+ out += next;
529
+ else if (next === "u") {
530
+ const hex = fragment.slice(i + 1, i + 5);
531
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) {
532
+ out += String.fromCharCode(Number.parseInt(hex, 16));
533
+ i += 4;
534
+ }
535
+ }
536
+ else {
537
+ out += next;
538
+ }
539
+ }
540
+ return out;
541
+ }
542
+ function parseJsonStringValueFragment(rawValue, closed) {
543
+ if (closed) {
544
+ try {
545
+ return JSON.parse(`"${rawValue}"`);
546
+ }
547
+ catch {
548
+ // Fall through to permissive decoder for malformed edge fragments.
549
+ }
550
+ }
551
+ return decodeJsonStringFragment(rawValue);
552
+ }
553
+ function findFirstObjectLiteral(raw, fromIndex) {
554
+ let inString = false;
555
+ let escape = false;
556
+ let depth = 0;
557
+ let objStart = -1;
558
+ for (let i = fromIndex; i < raw.length; i += 1) {
559
+ const ch = raw[i];
560
+ if (inString) {
561
+ if (escape)
562
+ escape = false;
563
+ else if (ch === "\\")
564
+ escape = true;
565
+ else if (ch === '"')
566
+ inString = false;
567
+ continue;
568
+ }
569
+ if (ch === '"') {
570
+ inString = true;
571
+ continue;
572
+ }
573
+ if (ch === "{") {
574
+ if (depth === 0)
575
+ objStart = i;
576
+ depth += 1;
577
+ continue;
578
+ }
579
+ if (ch === "}") {
580
+ if (depth > 0)
581
+ depth -= 1;
582
+ if (depth === 0 && objStart >= 0) {
583
+ return { body: raw.slice(objStart + 1, i), closed: true };
584
+ }
585
+ continue;
586
+ }
587
+ }
588
+ if (objStart >= 0) {
589
+ return { body: raw.slice(objStart + 1), closed: false };
590
+ }
591
+ return null;
592
+ }
593
+ function extractUpdatePropsDraftsFromPartialOp(raw, opIndex) {
594
+ // Primary path: lenient-parse the in-flight op and scope it exactly like a
595
+ // complete op, so nested fields (cards[i].title …) STREAM incrementally as the
596
+ // model types them instead of bursting all at once when the op's closing brace
597
+ // lands. flattenPatchToScopedDrafts owns the path scoping (the part that once
598
+ // drifted), so a lenient mis-parse can only DROP fields — never mis-scope them —
599
+ // and the strict complete-op extractor corrects everything at op-close.
600
+ const parsed = parsePartialJson(raw);
601
+ if (parsed && typeof parsed === "object") {
602
+ const op = parsed;
603
+ if (op.op === undefined || op.op === "") {
604
+ // "op" key not streamed yet: assume update_props unless it looks like an
605
+ // update_item (listKey/index present), which can't be scoped without the op
606
+ // type — matching the pre-existing "ignore update_item-like prefix" rule.
607
+ if (!("listKey" in op) && !("index" in op))
608
+ op.op = "update_props";
609
+ }
610
+ const drafts = scopeOpDrafts(op, opIndex);
611
+ if (drafts.length > 0)
612
+ return drafts;
613
+ }
614
+ // Fallback: the original depth-0 regex extractor — defensive, for any prefix the
615
+ // lenient parser can't turn into a usable op (guarantees no regression).
616
+ return extractDepth0DraftsFromPartialOp(raw, opIndex);
617
+ }
618
+ function extractDepth0DraftsFromPartialOp(raw, opIndex) {
619
+ let isUpdateItem = false;
620
+ const opMatch = /"op"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/.exec(raw);
621
+ if (opMatch) {
622
+ const opClosed = opMatch[0].endsWith('"');
623
+ const opName = parseJsonStringValueFragment(opMatch[1] ?? "", opClosed).trim();
624
+ if (opName && opName !== "update_props" && opName !== "update_item")
625
+ return [];
626
+ isUpdateItem = opName === "update_item";
627
+ }
628
+ else if (/"(?:listKey|index)"\s*:/.test(raw)) {
629
+ // Ignore update_item-like partial ops before "op" arrives — we can't scope
630
+ // their fields (or even confirm the op type) until the "op" key lands.
631
+ return [];
632
+ }
633
+ const blockIdMatch = /"blockId"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/.exec(raw);
634
+ if (!blockIdMatch)
635
+ return [];
636
+ const blockIdClosed = blockIdMatch[0].endsWith('"');
637
+ const blockId = parseJsonStringValueFragment(blockIdMatch[1] ?? "", blockIdClosed).trim();
638
+ if (!blockId)
639
+ return [];
640
+ const pageSlugMatch = /"pageSlug"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(raw);
641
+ const pageSlug = pageSlugMatch
642
+ ? parseJsonStringValueFragment(pageSlugMatch[1] ?? "", true).trim() || undefined
643
+ : undefined;
644
+ const patchKeyIdx = raw.indexOf('"patch"');
645
+ if (patchKeyIdx < 0)
646
+ return [];
647
+ const patchObj = findFirstObjectLiteral(raw, patchKeyIdx);
648
+ if (!patchObj)
649
+ return [];
650
+ // For update_item, scope the streamed patch fields under the addressed list item
651
+ // (cards[0].title …) so each card field types into its own renderer node. Read
652
+ // listKey/index from the op HEADER only (before "patch") so a stray "index" key
653
+ // inside the patch can't be mistaken for the item position. Both must be present
654
+ // and the index positional — an itemId-only op has no resolvable DOM target.
655
+ let pathPrefix = "";
656
+ if (isUpdateItem) {
657
+ const header = raw.slice(0, patchKeyIdx);
658
+ const listKeyMatch = /"listKey"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(header);
659
+ const indexMatch = /"index"\s*:\s*(\d+)/.exec(header);
660
+ if (!listKeyMatch || !indexMatch)
661
+ return [];
662
+ const listKey = parseJsonStringValueFragment(listKeyMatch[1] ?? "", true).trim();
663
+ if (!listKey)
664
+ return [];
665
+ pathPrefix = `${listKey}[${Number(indexMatch[1])}]`;
666
+ }
667
+ // Only stream TOP-LEVEL scalar props from the still-incomplete op. For an
668
+ // update_props op, nested fields (a card's title inside `cards: [...]`) can't be
669
+ // given their scoped editablePath ("cards[0].title") reliably from truncated
670
+ // JSON, and the old flat regex emitted them as bare keys — colliding with the
671
+ // block's own top-level "title" node and misplacing the overlay. The scoped
672
+ // nested drafts are emitted by the complete-op extractor the moment this op's
673
+ // closing brace arrives (still mid-stream). An update_item's patch keys ARE the
674
+ // item's own scalar fields, so prefixing them with `cards[0]` is unambiguous and
675
+ // they stream progressively, field by field.
676
+ return extractTopLevelStringDraftsFromBody(patchObj.body, opIndex, blockId, pageSlug, pathPrefix);
677
+ }
678
+ /**
679
+ * Emit `"key":"value"` drafts only at the top level (depth 0) of a possibly
680
+ * truncated patch-object body. Anything inside a nested object or array is
681
+ * skipped — see {@link flattenPatchToScopedDrafts} for how nested list fields get
682
+ * their scoped paths once the op fully parses.
683
+ */
684
+ function extractTopLevelStringDraftsFromBody(body, opIndex, blockId, pageSlug, pathPrefix = "") {
685
+ const base = pageSlug ? { pageSlug } : {};
686
+ const drafts = [];
687
+ const n = body.length;
688
+ let i = 0;
689
+ let depth = 0;
690
+ const readString = (start) => {
691
+ let j = start;
692
+ let esc = false;
693
+ while (j < n) {
694
+ const c = body[j];
695
+ if (esc)
696
+ esc = false;
697
+ else if (c === "\\")
698
+ esc = true;
699
+ else if (c === '"')
700
+ return { raw: body.slice(start, j), end: j, closed: true };
701
+ j++;
702
+ }
703
+ return { raw: body.slice(start, n), end: n, closed: false };
704
+ };
705
+ while (i < n) {
706
+ const ch = body[i];
707
+ if (ch === "{" || ch === "[") {
708
+ depth += 1;
709
+ i += 1;
710
+ continue;
711
+ }
712
+ if (ch === "}" || ch === "]") {
713
+ depth = Math.max(0, depth - 1);
714
+ i += 1;
715
+ continue;
716
+ }
717
+ if (ch !== '"') {
718
+ i += 1;
719
+ continue;
720
+ }
721
+ const key = readString(i + 1);
722
+ if (depth !== 0) {
723
+ // Nested string (key or value) — skip it wholesale.
724
+ if (!key.closed)
725
+ break;
726
+ i = key.end + 1;
727
+ continue;
728
+ }
729
+ if (!key.closed)
730
+ break; // truncated key — no value yet
731
+ const editablePath = parseJsonStringValueFragment(key.raw, true).trim();
732
+ i = key.end + 1;
733
+ while (i < n && /\s/.test(body[i]))
734
+ i += 1;
735
+ if (body[i] !== ":")
736
+ continue;
737
+ i += 1;
738
+ while (i < n && /\s/.test(body[i]))
739
+ i += 1;
740
+ if (i >= n)
741
+ break;
742
+ if (body[i] === '"') {
743
+ const val = readString(i + 1);
744
+ const value = parseJsonStringValueFragment(val.raw, val.closed);
745
+ if (editablePath) {
746
+ const scopedPath = pathPrefix ? `${pathPrefix}.${editablePath}` : editablePath;
747
+ drafts.push({ opIndex, blockId, editablePath: scopedPath, value, ...base });
748
+ }
749
+ i = val.closed ? val.end + 1 : n;
750
+ }
751
+ // Non-string values (objects, arrays, numbers) are left for the main loop to
752
+ // walk: `{`/`[` bump depth so their contents are skipped here.
753
+ }
754
+ return drafts;
755
+ }
756
+ /**
757
+ * Flatten an update_props patch into field drafts whose `editablePath` matches the
758
+ * renderer's `data-editable-target` attributes, so the live-draft overlay writes
759
+ * each value onto the right node:
760
+ * - top-level string prop → bare key ("title")
761
+ * - string field of an → scoped index path ("cards[0].title")
762
+ * array-of-objects prop (matches CardGrid/Tabs/Footer renderers)
763
+ * Plain nested objects (e.g. "meta") and arrays of primitives have no editable
764
+ * targets and are skipped. Without scoping, every card's bare "title" collapsed
765
+ * onto the grid's own "title" node and the card translations were misplaced.
766
+ *
767
+ * `pathPrefix` scopes the whole patch under an addressed list item — passed as
768
+ * `cards[0]` for an `update_item` op so its bare `title`/`description` patch keys
769
+ * become `cards[0].title` etc. (an update_props patch passes "" → unscoped).
770
+ */
771
+ function flattenPatchToScopedDrafts(patch, opIndex, blockId, pageSlug, pathPrefix = "") {
772
+ const base = pageSlug ? { pageSlug } : {};
773
+ const drafts = [];
774
+ // Walk an object's fields, emitting a draft per string value with a path that
775
+ // mirrors the renderer's data-editable-target. We descend ARRAYS (and their
776
+ // element objects, recursively — e.g. site-header links[i].children[j].label),
777
+ // and arrays of strings keep the bare index form (table headers[i]). We never
778
+ // descend a plain object value (e.g. "meta"): those carry no editable targets.
779
+ const emitObject = (obj, prefix) => {
780
+ for (const [key, value] of Object.entries(obj)) {
781
+ const path = prefix ? `${prefix}.${key}` : key;
782
+ if (typeof value === "string") {
783
+ drafts.push({ opIndex, blockId, editablePath: path, value, ...base });
784
+ }
785
+ else if (Array.isArray(value)) {
786
+ value.forEach((item, idx) => {
787
+ const itemPath = `${path}[${idx}]`;
788
+ if (typeof item === "string") {
789
+ drafts.push({ opIndex, blockId, editablePath: itemPath, value: item, ...base });
790
+ }
791
+ else if (item && typeof item === "object" && !Array.isArray(item)) {
792
+ emitObject(item, itemPath);
793
+ }
794
+ });
795
+ }
796
+ // non-string scalars and plain nested objects have no editable target → skip
797
+ }
798
+ };
799
+ emitObject(patch, pathPrefix);
800
+ return drafts;
801
+ }
802
+ /**
803
+ * Lenient recursive-descent JSON parser for a TRUNCATED stream prefix.
804
+ *
805
+ * Returns the largest valid prefix as a JS value: a trailing in-flight string is
806
+ * kept as its decoded-so-far value (so the field still streams), while a dangling
807
+ * key (no colon/value yet) is dropped. Used only on the still-incomplete op so its
808
+ * nested fields can be scoped via flattenPatchToScopedDrafts as they arrive. It
809
+ * never throws — worst case it returns less, and the strict complete-op parser
810
+ * corrects everything the moment the op's closing brace lands.
811
+ */
812
+ function parsePartialJson(text) {
813
+ let i = 0;
814
+ const n = text.length;
815
+ const skipWs = () => { while (i < n && /\s/.test(text[i]))
816
+ i += 1; };
817
+ const parseString = () => {
818
+ i += 1; // consume opening quote
819
+ let out = "";
820
+ while (i < n) {
821
+ const c = text[i];
822
+ if (c === "\\") {
823
+ if (i + 1 >= n) {
824
+ i = n;
825
+ return { value: out, complete: false };
826
+ }
827
+ const esc = text[i + 1];
828
+ if (esc === "u") {
829
+ if (i + 6 > n) {
830
+ i = n;
831
+ return { value: out, complete: false };
832
+ }
833
+ const code = parseInt(text.slice(i + 2, i + 6), 16);
834
+ if (!Number.isNaN(code))
835
+ out += String.fromCharCode(code);
836
+ i += 6;
837
+ continue;
838
+ }
839
+ const map = { '"': '"', "\\": "\\", "/": "/", n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" };
840
+ out += map[esc] ?? esc;
841
+ i += 2;
842
+ continue;
843
+ }
844
+ if (c === '"') {
845
+ i += 1;
846
+ return { value: out, complete: true };
847
+ }
848
+ out += c;
849
+ i += 1;
850
+ }
851
+ return { value: out, complete: false }; // truncated string — keep what we have
852
+ };
853
+ const parseValue = () => {
854
+ skipWs();
855
+ if (i >= n)
856
+ return null;
857
+ const ch = text[i];
858
+ if (ch === '"')
859
+ return parseString();
860
+ if (ch === "{")
861
+ return parseObject();
862
+ if (ch === "[")
863
+ return parseArray();
864
+ // literal: number / true / false / null
865
+ const start = i;
866
+ while (i < n && !/[\s,}\]]/.test(text[i]))
867
+ i += 1;
868
+ const token = text.slice(start, i);
869
+ const atEof = i >= n;
870
+ if (token === "true")
871
+ return { value: true, complete: true };
872
+ if (token === "false")
873
+ return { value: false, complete: true };
874
+ if (token === "null")
875
+ return { value: null, complete: true };
876
+ if (token.length > 0 && !Number.isNaN(Number(token)))
877
+ return { value: Number(token), complete: !atEof };
878
+ return null; // unrecognized garbage
879
+ };
880
+ function parseObject() {
881
+ i += 1; // consume {
882
+ const obj = {};
883
+ while (true) {
884
+ skipWs();
885
+ if (i >= n)
886
+ return { value: obj, complete: false };
887
+ if (text[i] === "}") {
888
+ i += 1;
889
+ return { value: obj, complete: true };
890
+ }
891
+ if (text[i] === ",") {
892
+ i += 1;
893
+ continue;
894
+ }
895
+ if (text[i] !== '"')
896
+ return { value: obj, complete: false }; // unexpected
897
+ const key = parseString();
898
+ if (!key.complete)
899
+ return { value: obj, complete: false }; // dangling key → drop
900
+ skipWs();
901
+ if (i >= n || text[i] !== ":")
902
+ return { value: obj, complete: false }; // key w/o value → drop
903
+ i += 1; // consume :
904
+ const val = parseValue();
905
+ if (val === null)
906
+ return { value: obj, complete: false }; // value not started → drop key
907
+ obj[key.value] = val.value;
908
+ if (!val.complete)
909
+ return { value: obj, complete: false }; // value truncated → keep, stop
910
+ }
911
+ }
912
+ function parseArray() {
913
+ i += 1; // consume [
914
+ const arr = [];
915
+ while (true) {
916
+ skipWs();
917
+ if (i >= n)
918
+ return { value: arr, complete: false };
919
+ if (text[i] === "]") {
920
+ i += 1;
921
+ return { value: arr, complete: true };
922
+ }
923
+ if (text[i] === ",") {
924
+ i += 1;
925
+ continue;
926
+ }
927
+ const val = parseValue();
928
+ if (val === null)
929
+ return { value: arr, complete: false };
930
+ arr.push(val.value);
931
+ if (!val.complete)
932
+ return { value: arr, complete: false };
933
+ }
934
+ }
935
+ const result = parseValue();
936
+ return result ? result.value : null;
937
+ }
938
+ /**
939
+ * Scope a parsed update_props / update_item op into field drafts whose paths match
940
+ * the renderer's data-editable-target. Shared by the complete-op (strict parse) and
941
+ * partial-op (lenient parse) extractors so both produce identical scoping.
942
+ *
943
+ * update_item gets the same scoped treatment as a nested update_props array field:
944
+ * a single-item edit streamed as `update_item{listKey:"cards",index:0,patch:{…}}`
945
+ * lights up cards[0].title / cards[0].description. Only a positional `index` maps
946
+ * to a DOM node — an itemId-only op can't be resolved without the page doc, so it
947
+ * emits nothing (the final apply still translates it correctly).
948
+ */
949
+ function scopeOpDrafts(parsed, opIndex) {
950
+ if (!parsed || typeof parsed !== "object")
951
+ return [];
952
+ const op = parsed;
953
+ if (op.op !== "update_props" && op.op !== "update_item")
954
+ return [];
955
+ const blockId = String(op.blockId ?? "").trim();
956
+ if (!blockId)
957
+ return [];
958
+ const patch = op.patch && typeof op.patch === "object" && !Array.isArray(op.patch)
959
+ ? op.patch
960
+ : null;
961
+ if (!patch)
962
+ return [];
963
+ const pageSlug = typeof op.pageSlug === "string" && op.pageSlug.trim().length > 0 ? op.pageSlug.trim() : undefined;
964
+ if (op.op === "update_item") {
965
+ const listKey = String(op.listKey ?? "").trim();
966
+ const index = op.index;
967
+ if (!listKey || typeof index !== "number" || !Number.isInteger(index) || index < 0)
968
+ return [];
969
+ return flattenPatchToScopedDrafts(patch, opIndex, blockId, pageSlug, `${listKey}[${index}]`);
970
+ }
971
+ return flattenPatchToScopedDrafts(patch, opIndex, blockId, pageSlug);
972
+ }
973
+ function extractUpdatePropsDraftsFromCompleteOp(raw, opIndex) {
974
+ try {
975
+ return scopeOpDrafts(JSON.parse(raw), opIndex);
976
+ }
977
+ catch {
978
+ return [];
979
+ }
980
+ }
981
+ export function extractUpdatePropsFieldDraftsFromPlanBuffer(raw) {
982
+ const opsKeyIdx = raw.indexOf('"ops"');
983
+ if (opsKeyIdx < 0)
984
+ return [];
985
+ const arrStart = raw.indexOf("[", opsKeyIdx);
986
+ if (arrStart < 0)
987
+ return [];
988
+ const objectSnippets = [];
989
+ let inString = false;
990
+ let escape = false;
991
+ let arrDepth = 0;
992
+ let objDepth = 0;
993
+ let objStart = -1;
994
+ let objectCount = 0;
995
+ let arrayClosed = false;
996
+ for (let i = arrStart; i < raw.length; i += 1) {
997
+ const ch = raw[i];
998
+ if (inString) {
999
+ if (escape) {
1000
+ escape = false;
1001
+ }
1002
+ else if (ch === "\\") {
1003
+ escape = true;
1004
+ }
1005
+ else if (ch === '"') {
1006
+ inString = false;
1007
+ }
1008
+ continue;
1009
+ }
1010
+ if (ch === '"') {
1011
+ inString = true;
1012
+ continue;
1013
+ }
1014
+ if (ch === "[") {
1015
+ arrDepth += 1;
1016
+ continue;
1017
+ }
1018
+ if (ch === "]") {
1019
+ if (arrDepth > 0)
1020
+ arrDepth -= 1;
1021
+ if (arrDepth === 0) {
1022
+ arrayClosed = true;
1023
+ break;
1024
+ }
1025
+ continue;
1026
+ }
1027
+ if (arrDepth !== 1)
1028
+ continue;
1029
+ if (ch === "{") {
1030
+ if (objDepth === 0)
1031
+ objStart = i;
1032
+ objDepth += 1;
1033
+ continue;
1034
+ }
1035
+ if (ch === "}") {
1036
+ if (objDepth > 0)
1037
+ objDepth -= 1;
1038
+ if (objDepth === 0 && objStart >= 0) {
1039
+ objectCount += 1;
1040
+ objectSnippets.push({ opIndex: objectCount, snippet: raw.slice(objStart, i + 1), complete: true });
1041
+ objStart = -1;
1042
+ }
1043
+ }
1044
+ }
1045
+ if (!arrayClosed && arrDepth >= 1 && objDepth > 0 && objStart >= 0) {
1046
+ objectCount += 1;
1047
+ objectSnippets.push({ opIndex: objectCount, snippet: raw.slice(objStart), complete: false });
1048
+ }
1049
+ const drafts = [];
1050
+ for (const entry of objectSnippets) {
1051
+ if (entry.complete) {
1052
+ drafts.push(...extractUpdatePropsDraftsFromCompleteOp(entry.snippet, entry.opIndex));
1053
+ }
1054
+ else {
1055
+ drafts.push(...extractUpdatePropsDraftsFromPartialOp(entry.snippet, entry.opIndex));
1056
+ }
1057
+ }
1058
+ return drafts;
1059
+ }
1060
+ export async function parseIntentWithOpenAI(args) {
1061
+ const client = args.client ?? getOpenAIClient();
1062
+ const system = buildIntentParserSystemPrompt();
1063
+ const user = {
1064
+ request: args.message,
1065
+ slug: args.slug,
1066
+ activeBlockId: args.activeBlockId ?? null,
1067
+ activeBlockType: args.activeBlockType ?? null,
1068
+ activeEditablePath: args.activeEditablePath ?? null,
1069
+ availableBlockTypes: allowedBlockTypes,
1070
+ blocks: args.currentPage.blocks.map((b) => ({ id: b.id, type: b.type, props: Object.keys(b.props) }))
1071
+ };
1072
+ const completion = await client.chat.completions.create({
1073
+ model: args.model,
1074
+ ...openAIChatOptionsForModel(args.model),
1075
+ response_format: {
1076
+ type: "json_schema",
1077
+ json_schema: { name: "IntentResult", schema: intentJsonSchema, strict: true }
1078
+ },
1079
+ messages: [
1080
+ { role: "system", content: system },
1081
+ { role: "user", content: JSON.stringify(user) }
1082
+ ]
1083
+ }, { signal: AbortSignal.timeout(LLM_TIMEOUT_MS) });
1084
+ const raw = completion.choices[0]?.message?.content ?? "";
1085
+ if (!raw.trim())
1086
+ throw new Error("Intent parser did not return JSON");
1087
+ // json_schema strict mode guarantees valid JSON matching our schema
1088
+ const normalized = JSON.parse(raw);
1089
+ // Nulls from the schema become undefined for Zod optional fields
1090
+ for (const key of Object.keys(normalized)) {
1091
+ if (normalized[key] === null)
1092
+ delete normalized[key];
1093
+ }
1094
+ const parsed = intentSchema.safeParse(normalized);
1095
+ if (!parsed.success) {
1096
+ const issue = parsed.error.issues[0];
1097
+ const detail = issue?.message ?? "Invalid intent parser output";
1098
+ const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
1099
+ throw new Error(`${detail}${at}`);
1100
+ }
1101
+ return parsed.data;
1102
+ }
1103
+ export async function generatePlanWithOpenAI(args) {
1104
+ const client = args.client ?? getOpenAIClient();
1105
+ const effectiveSignal = args.signal
1106
+ ? AbortSignal.any([args.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)])
1107
+ : AbortSignal.timeout(LLM_TIMEOUT_MS);
1108
+ const effectiveBlockTypes = args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : allowedBlockTypes;
1109
+ const batchOverride = isBatchAddRequest(args.message) || isBatchRemoveRequest(args.message) || isBatchReorderRequest(args.message) || isPageWideRewriteRequest(args.message) || isDuplicateAndModifyRequest(args.message);
1110
+ const pageWideRewrite = isPageWideRewriteRequest(args.message);
1111
+ const planFirstMode = requestsPlanFirst(args.message);
1112
+ const pageWideTranslation = isPageWideTranslationRequest(args.message);
1113
+ const chatStrictPrimaryOpMode = isChatStrictPrimaryOpMode() && !batchOverride && !pageWideTranslation;
1114
+ const selectedBlockId = String(args.contextPack.selected.blockId ?? "");
1115
+ const audienceHint = extractAudienceTarget(args.message);
1116
+ const explicitOtherReference = selectedBlockId.length > 0 &&
1117
+ Array.isArray(args.contextPack.resolvedReferences.mentionedBlocks) &&
1118
+ args.contextPack.resolvedReferences.mentionedBlocks.some((entry) => entry && typeof entry === "object" && "id" in entry && entry.id !== selectedBlockId);
1119
+ const system = buildPlannerSystemPrompt({
1120
+ provider: "openai",
1121
+ lightweight: !!args.lightweight,
1122
+ selectedBlockId,
1123
+ explicitOtherReference: !!explicitOtherReference,
1124
+ chatStrictPrimaryOpMode,
1125
+ pageWideTranslation,
1126
+ pageWideRewrite,
1127
+ planFirstMode,
1128
+ effectiveBlockTypes,
1129
+ siteContextBlock: args.siteContextBlock,
1130
+ imageUrlForVision: args.contextPack.selected?.imageUrlForVision,
1131
+ editablePath: args.contextPack.selected?.editablePath,
1132
+ blockId: args.contextPack.selected?.blockId,
1133
+ locale: args.locale,
1134
+ imageSourceChoiceOpen: args.imageSourceChoiceOpen,
1135
+ attachmentCount: args.contextPack.attachments?.length,
1136
+ });
1137
+ const lowerMsg = args.message.toLowerCase();
1138
+ const includeContracts = !args.lightweight && (batchOverride ||
1139
+ pageWideTranslation ||
1140
+ /\b(create|add|insert|build|generate)\b/.test(lowerMsg) ||
1141
+ /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/.test(lowerMsg) ||
1142
+ /\d{2,3}\s*char/i.test(args.message) ||
1143
+ // Multi-field updates need block contracts to know valid prop names
1144
+ (lowerMsg.match(/['''"""\u201C\u201D\u2018\u2019]/g)?.length ?? 0) >= 4);
1145
+ const schemaContext = args.lightweight
1146
+ ? {
1147
+ payload: {},
1148
+ meta: {
1149
+ contractMode: "minimal",
1150
+ contractBytes: 0,
1151
+ contractBlockCount: 0,
1152
+ targetBlockTypes: [],
1153
+ strictJsonEnabled: false
1154
+ }
1155
+ }
1156
+ : buildPlannerSchemaContext({
1157
+ message: args.message,
1158
+ contextPack: args.contextPack,
1159
+ batchOverride,
1160
+ pageWideTranslation,
1161
+ legacyIncludeContracts: includeContracts,
1162
+ forceFullContracts: args.forceFullSchemaContracts,
1163
+ componentsManifest: args.componentsManifest
1164
+ });
1165
+ const user = {
1166
+ request: args.message,
1167
+ audienceHint: audienceHint ?? null,
1168
+ slug: args.slug,
1169
+ contextPack: args.contextPack,
1170
+ ...schemaContext.payload,
1171
+ feedback: args.feedback ?? null
1172
+ };
1173
+ const imageUrlForVision = typeof args.contextPack.selected?.imageUrlForVision === "string"
1174
+ ? args.contextPack.selected.imageUrlForVision
1175
+ : null;
1176
+ const imageBase64 = imageUrlForVision ? await fetchImageAsBase64(imageUrlForVision) : null;
1177
+ const visionImageUrl = imageBase64
1178
+ ? `data:${imageBase64.mediaType};base64,${imageBase64.base64}`
1179
+ : imageUrlForVision;
1180
+ // User-attached files: images as image_url parts, PDFs as `file` parts with
1181
+ // inline base64 data. The `file` content part is only accepted by the gpt-4o /
1182
+ // gpt-4.1 / gpt-5 families on chat.completions; sending it to other models
1183
+ // fails the whole call, so we drop PDF parts (images still go through) when
1184
+ // the active model isn't known to support file inputs.
1185
+ const resolvedAttachments = await resolveAttachmentsForLlm(args.contextPack.attachments);
1186
+ const allowPdfFileParts = openAiModelSupportsPdfFiles(args.model);
1187
+ const attachmentParts = resolvedAttachments
1188
+ .filter((attachment) => attachment.kind !== "pdf" || allowPdfFileParts)
1189
+ .map((attachment) => attachment.kind === "pdf"
1190
+ ? { type: "file", file: { filename: attachment.name ?? "document.pdf", file_data: `data:application/pdf;base64,${attachment.base64}` } }
1191
+ : { type: "image_url", image_url: { url: `data:${attachment.mediaType};base64,${attachment.base64}`, detail: "auto" } });
1192
+ const visionParts = visionImageUrl
1193
+ ? [{ type: "image_url", image_url: { url: visionImageUrl, detail: "low" } }]
1194
+ : [];
1195
+ const openaiUserContent = visionParts.length > 0 || attachmentParts.length > 0
1196
+ ? [...visionParts, ...attachmentParts, { type: "text", text: JSON.stringify(user) }]
1197
+ : JSON.stringify(user);
1198
+ let raw = "";
1199
+ let usage = { ...ZERO_USAGE };
1200
+ let streamedOpsCount = 0;
1201
+ let lastSummaryLen = 0;
1202
+ let emittedChangeLogCount = 0;
1203
+ const emittedFieldDraftByKey = new Map();
1204
+ if (isResponsesOnlyModel(args.model)) {
1205
+ const response = await client.responses.create({
1206
+ model: args.model,
1207
+ instructions: system,
1208
+ input: JSON.stringify(user)
1209
+ }, { signal: effectiveSignal });
1210
+ const responsesOutcome = extractResponsesOutcome(response);
1211
+ if (responsesOutcome.refusal) {
1212
+ throw toPlannerError("planner_refusal", `Model refused planning output: ${responsesOutcome.refusal}`);
1213
+ }
1214
+ if (responsesOutcome.incomplete) {
1215
+ throw toPlannerError("incomplete_output", "Model returned incomplete planning output");
1216
+ }
1217
+ raw = extractResponsesOutputText(response);
1218
+ usage = extractUsage(response);
1219
+ if (args.onToken && raw.length > 0)
1220
+ args.onToken(raw);
1221
+ }
1222
+ else if (args.onToken) {
1223
+ const stream = await client.chat.completions.create({
1224
+ model: args.model,
1225
+ ...openAIChatOptionsForModel(args.model),
1226
+ stream: true,
1227
+ response_format: { type: "json_object" },
1228
+ messages: [
1229
+ { role: "system", content: system },
1230
+ ...(args.history ?? []),
1231
+ { role: "user", content: openaiUserContent }
1232
+ ],
1233
+ }, { signal: effectiveSignal });
1234
+ let sawRefusal = false;
1235
+ for await (const chunk of stream) {
1236
+ const deltaRefusal = chunk?.choices?.[0]?.delta?.refusal;
1237
+ if (typeof deltaRefusal === "string" && deltaRefusal.trim().length > 0) {
1238
+ sawRefusal = true;
1239
+ raw += deltaRefusal;
1240
+ continue;
1241
+ }
1242
+ const delta = chunk.choices[0]?.delta?.content;
1243
+ if (typeof delta !== "string" || delta.length === 0)
1244
+ continue;
1245
+ raw += delta;
1246
+ args.onToken(delta);
1247
+ if (args.onFieldDraft) {
1248
+ const fieldDrafts = extractUpdatePropsFieldDraftsFromPlanBuffer(raw);
1249
+ for (const draft of fieldDrafts) {
1250
+ const key = `${draft.opIndex}:${draft.blockId}:${draft.editablePath}`;
1251
+ const prev = emittedFieldDraftByKey.get(key);
1252
+ if (prev === draft.value)
1253
+ continue;
1254
+ emittedFieldDraftByKey.set(key, draft.value);
1255
+ args.onFieldDraft({ blockId: draft.blockId, editablePath: draft.editablePath, value: draft.value, ...(draft.pageSlug ? { pageSlug: draft.pageSlug } : {}) });
1256
+ }
1257
+ }
1258
+ if (args.onSummaryChunk || args.onChangeLogEntry) {
1259
+ const extracted = extractSummaryFromPlanBuffer(raw);
1260
+ if (extracted.summary && extracted.summary.length > lastSummaryLen) {
1261
+ args.onSummaryChunk?.(extracted.summary.slice(lastSummaryLen));
1262
+ lastSummaryLen = extracted.summary.length;
1263
+ }
1264
+ for (let i = emittedChangeLogCount; i < extracted.changeLog.length; i++) {
1265
+ args.onChangeLogEntry?.(extracted.changeLog[i]);
1266
+ }
1267
+ emittedChangeLogCount = extracted.changeLog.length;
1268
+ }
1269
+ if (args.onPlannedOp) {
1270
+ const next = extractOpsFromPlanBuffer(raw, streamedOpsCount);
1271
+ streamedOpsCount = next.nextEmittedCount;
1272
+ for (let idx = 0; idx < next.newOps.length; idx += 1) {
1273
+ args.onPlannedOp(next.newOps[idx], streamedOpsCount - next.newOps.length + idx + 1);
1274
+ }
1275
+ }
1276
+ }
1277
+ if (sawRefusal) {
1278
+ throw toPlannerError("planner_refusal", `Model refused planning output: ${raw.slice(0, 300)}`);
1279
+ }
1280
+ if (raw.trim().length === 0) {
1281
+ throw toPlannerError("incomplete_output", "Model returned no planning output");
1282
+ }
1283
+ // Streaming doesn't return per-chunk usage; leave as zero
1284
+ }
1285
+ else {
1286
+ const completion = await client.chat.completions.create({
1287
+ model: args.model,
1288
+ ...openAIChatOptionsForModel(args.model),
1289
+ response_format: { type: "json_object" },
1290
+ messages: [
1291
+ { role: "system", content: system },
1292
+ ...(args.history ?? []),
1293
+ { role: "user", content: openaiUserContent }
1294
+ ],
1295
+ }, { signal: effectiveSignal });
1296
+ const refusal = extractCompletionRefusal(completion);
1297
+ if (refusal) {
1298
+ throw toPlannerError("planner_refusal", `Model refused planning output: ${refusal}`);
1299
+ }
1300
+ raw = completion.choices[0]?.message?.content ?? "";
1301
+ if (raw.trim().length === 0) {
1302
+ throw toPlannerError("incomplete_output", "Model returned no planning output");
1303
+ }
1304
+ usage = extractUsage(completion);
1305
+ }
1306
+ const jsonText = extractJsonObject(raw);
1307
+ if (!jsonText) {
1308
+ throw toPlannerError("malformed_output", "Model did not return JSON", true);
1309
+ }
1310
+ let parsedJson;
1311
+ try {
1312
+ parsedJson = JSON.parse(jsonText);
1313
+ }
1314
+ catch (err) {
1315
+ // Attempt multiple repair strategies before giving up
1316
+ try {
1317
+ const meta = repairAndParseJsonWithMeta(jsonText);
1318
+ parsedJson = meta.parsed;
1319
+ if (meta.strategy !== "none") {
1320
+ console.warn(`[planner] JSON repaired via ${meta.strategy}${meta.mutationCount ? ` (${meta.mutationCount} mutations)` : ""}${meta.discardedBytes ? ` (${meta.discardedBytes}B discarded)` : ""}`);
1321
+ }
1322
+ }
1323
+ catch {
1324
+ const posMatch = err instanceof SyntaxError ? /position (\d+)/i.exec(err.message) : null;
1325
+ const pos = posMatch?.[1];
1326
+ const snippet = pos ? jsonText.slice(Math.max(0, Number(pos) - 30), Number(pos) + 30) : jsonText.slice(0, 200);
1327
+ throw toPlannerError("malformed_output", `Model returned malformed JSON: ${err.message}. Near: …${snippet}…`, true);
1328
+ }
1329
+ }
1330
+ const rawCandidateResult = rawPlanCandidateSchema.safeParse(parsedJson);
1331
+ if (!rawCandidateResult.success) {
1332
+ const first = rawCandidateResult.error.issues[0];
1333
+ const at = first?.path?.length ? ` at ${first.path.join(".")}` : "";
1334
+ const detail = first?.message ?? "Raw planner output shape is invalid";
1335
+ throw toPlannerError("malformed_output", `${detail}${at}`, true);
1336
+ }
1337
+ const parsed = normalizePlanCandidate(rawCandidateResult.data, {
1338
+ defaultSlug: args.slug,
1339
+ currentPage: args.currentPage,
1340
+ userMessage: args.message
1341
+ });
1342
+ const planResult = editPlanSchema.safeParse(parsed);
1343
+ if (!planResult.success) {
1344
+ const first = planResult.error.issues[0];
1345
+ const message = first?.message ?? "Invalid model output";
1346
+ const path = first?.path?.length ? ` at ${first.path.join(".")}` : "";
1347
+ const sample = JSON.stringify(parsed).slice(0, 700);
1348
+ throw toPlannerError("schema_violation", `${message}${path}. Parsed sample: ${sample}`, true);
1349
+ }
1350
+ if (chatStrictPrimaryOpMode && planResult.data.intent === "edit_plan" && planResult.data.ops.length > 1) {
1351
+ return {
1352
+ plan: {
1353
+ ...planResult.data,
1354
+ ops: [planResult.data.ops[0]]
1355
+ },
1356
+ usage,
1357
+ schemaContext: schemaContext.meta
1358
+ };
1359
+ }
1360
+ return { plan: planResult.data, usage, schemaContext: schemaContext.meta };
1361
+ }