@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.
- package/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- 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
|
+
}
|