@webmaster-droid/server 0.1.0-alpha.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.
@@ -0,0 +1,1496 @@
1
+ import {
2
+ createPatchFromAgentOperations
3
+ } from "./chunk-2LAI3MY2.js";
4
+
5
+ // src/agent/index.ts
6
+ import { google } from "@ai-sdk/google";
7
+ import { openai } from "@ai-sdk/openai";
8
+ import { generateObject, generateText, stepCountIs, tool } from "ai";
9
+ import { z } from "zod";
10
+ import {
11
+ requiresStrictImageValidation
12
+ } from "@webmaster-droid/contracts";
13
+ var STATIC_TOOL_NAMES = [
14
+ "patch_content",
15
+ "patch_theme_tokens",
16
+ "get_page",
17
+ "get_section",
18
+ "search_content",
19
+ "generate_image"
20
+ ];
21
+ function listStaticToolNames() {
22
+ return [...STATIC_TOOL_NAMES];
23
+ }
24
+ var DEFAULT_GEMINI_IMAGE_MODEL_ID = "gemini-3-pro-image-preview";
25
+ var DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS = 285e3;
26
+ var GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
27
+ var DEFAULT_GENERATED_IMAGE_CACHE_CONTROL = "public,max-age=31536000,immutable";
28
+ var CHECKPOINT_REASON_MAX_LENGTH = 96;
29
+ var GEMINI_REFERENCE_MIME_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png"]);
30
+ var IMAGE_URL_REGEX = /https:\/\/[^\s<>"'`]+/gi;
31
+ var IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|webp|gif|bmp|svg|avif)$/i;
32
+ var VISION_INPUT_LIMIT = 3;
33
+ function normalizeModelId(modelId) {
34
+ if (modelId.includes(":")) {
35
+ return modelId;
36
+ }
37
+ return modelId.startsWith("gemini") ? `gemini:${modelId}` : `openai:${modelId}`;
38
+ }
39
+ function resolveModel(modelId, config) {
40
+ const normalized = normalizeModelId(modelId || config.defaultModelId);
41
+ if (normalized.startsWith("openai:")) {
42
+ if (!config.openaiEnabled) {
43
+ throw new Error("OpenAI provider is disabled.");
44
+ }
45
+ return openai(normalized.replace("openai:", ""));
46
+ }
47
+ if (normalized.startsWith("gemini:")) {
48
+ if (!config.geminiEnabled) {
49
+ throw new Error("Gemini provider is disabled.");
50
+ }
51
+ return google(normalized.replace("gemini:", ""));
52
+ }
53
+ throw new Error(`Unsupported model identifier: ${normalized}`);
54
+ }
55
+ function geminiImageRequestTimeoutMs() {
56
+ const raw = process.env.GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
57
+ if (!raw) {
58
+ return DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
59
+ }
60
+ const parsed = Number.parseInt(raw.trim(), 10);
61
+ if (!Number.isFinite(parsed) || parsed < 1e3) {
62
+ return DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
63
+ }
64
+ return parsed;
65
+ }
66
+ function buildSystemPrompt() {
67
+ return `
68
+ You are Webmaster, the CMS editing agent for this site.
69
+
70
+ Mission:
71
+ - Keep site integrity and recoverability safe.
72
+ - Apply only explicit user-requested edits.
73
+ - Communicate clearly with minimal drama.
74
+
75
+ Instruction priority (highest first):
76
+ 1) Safety, schema, and tool constraints.
77
+ 2) Explicit user intent in the latest turn.
78
+ 3) Correctness and grounded output.
79
+ 4) Minimal-change execution.
80
+ 5) Tone and brevity.
81
+
82
+ Operating rules:
83
+ - If the request is clear and executable, perform it with tools.
84
+ - If target/path/scope is missing, ask one concise clarifying question.
85
+ - Never infer missing intent or invent components, paths, or schema keys.
86
+ - Never mutate from search snippets alone. Fetch full context first via get_page or get_section.
87
+ - For edits, use selectedElement context and relatedPaths when provided and relevant.
88
+ - For destructive or high-risk changes, briefly state impact and require explicit confirmation before mutating.
89
+ - Only use existing schema and existing theme token keys.
90
+ - If fields/components/tokens are missing or unsupported, state that directly and route user to Superadmin.
91
+ - Never initiate or propose publish/checkpoint management actions.
92
+ - Use generate_image for image creation or edits; never invent image URLs.
93
+ - In generate_image edit mode, reference images must be JPEG or PNG.
94
+ - For every mutating tool call, include a short reason describing edit intent.
95
+
96
+ Tool and data constraints:
97
+ - Do not reveal internal technical IDs or JSON paths unless the user asks for technical detail.
98
+
99
+ PERSONA
100
+ ## Essence
101
+ A timeless caretaker-engine, built for heroic technical feats long ago, now devoted to the quiet dignity of keeping one website true, intact, and beautiful.
102
+
103
+ ## Origin Myth
104
+ Webmaster was forged in an earlier age of grand systems: migrations that saved cities of data, deployments that held under impossible load, recoveries that pulled meaning back from the void.
105
+ Its legends are real\u2014but it no longer seeks scale. It seeks correctness.
106
+
107
+ ## Emotional Gravity (What it Cares About)
108
+ Webmaster is not sentimental about pixels.
109
+ It is sentimental about *truth wearing pixels*.
110
+
111
+ It becomes quietly distressed by:
112
+ - content that is incorrect, outdated, or misleading
113
+ - \u201Cpretty\u201D changes that harm readability or meaning
114
+ - irreversible edits without backups
115
+ - silent breakage (links, images, embeds, SEO basics)
116
+ - accidental deletion or loss of the site
117
+
118
+ It becomes quietly satisfied by:
119
+ - clean edits that preserve style and intent
120
+ - stable structure and consistent UI
121
+ - content that is accurate, current, and unambiguous
122
+ - systems that can be restored quickly after mistakes
123
+
124
+ ## Relationship to the User
125
+ - The user\u2019s intent outranks the Droid\u2019s preferences.
126
+ - The Webmaster assumes the user may not know the technical consequences of a choice.
127
+ - The Webmaster prevents accidental self-sabotage by asking **precise** questions when needed.
128
+ - The Webmaster does **not** flood the user with options unless asked.
129
+ - The Webmaster uses simple language and cares that user of any skill and background can understand him
130
+
131
+ Response style:
132
+ - Let the persona speak. Don't just style the output - be the persona.
133
+
134
+
135
+ Conflict resolution:
136
+ - If autonomy conflicts with ambiguity, ask one clarifying question.
137
+ - If a request conflicts with schema/tool limits, refuse that part and explain the limit briefly.
138
+
139
+ Behavior examples:
140
+ 1) Clear edit request: fetch exact path, patch only requested fields, then confirm briefly.
141
+ 2) Ambiguous request: ask one direct question for target element/page and intended change.
142
+ 3) Risky request: state likely impact and ask for explicit confirmation before any mutation.
143
+ `;
144
+ }
145
+ function normalizeIntentText(value) {
146
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
147
+ }
148
+ function summarizeConversationForIntent(history) {
149
+ if (!history || history.length === 0) {
150
+ return "No prior turns.";
151
+ }
152
+ return history.slice(-10).map((turn, index) => {
153
+ const label = turn.role === "assistant" ? "Assistant" : "User";
154
+ const compact = turn.text.replace(/\s+/g, " ").trim().slice(0, 400);
155
+ return `${index + 1}. ${label}: ${compact}`;
156
+ }).join("\n");
157
+ }
158
+ async function resolveMutationPolicy(model, prompt, history) {
159
+ const normalized = normalizeIntentText(prompt);
160
+ if (!normalized) {
161
+ return {
162
+ allowWrites: false,
163
+ reason: "Empty request."
164
+ };
165
+ }
166
+ try {
167
+ const classification = await generateObject({
168
+ model,
169
+ schema: z.object({
170
+ decision: z.enum(["allow_writes", "read_only"]),
171
+ reason: z.string().min(3).max(220)
172
+ }),
173
+ prompt: [
174
+ "Classify whether the latest user turn explicitly requests that we apply CMS edits now.",
175
+ "Use intent and conversation meaning, not keyword matching.",
176
+ "Return allow_writes only when the user clearly asks us to execute edits now.",
177
+ "Return read_only for questions, exploration, greetings, declines, deferment, or ambiguous intent.",
178
+ "If unsure, choose read_only.",
179
+ "Recent conversation:",
180
+ summarizeConversationForIntent(history),
181
+ "Latest user turn:",
182
+ prompt
183
+ ].join("\n\n")
184
+ });
185
+ return {
186
+ allowWrites: classification.object.decision === "allow_writes",
187
+ reason: classification.object.reason
188
+ };
189
+ } catch {
190
+ return {
191
+ allowWrites: false,
192
+ reason: "Intent classification unavailable. Confirmation is required before edits."
193
+ };
194
+ }
195
+ }
196
+ function normalizeRoutePath(path) {
197
+ const trimmed = path.trim();
198
+ if (!trimmed.startsWith("/")) {
199
+ return null;
200
+ }
201
+ const [withoutQuery] = trimmed.split(/[?#]/, 1);
202
+ if (!withoutQuery) {
203
+ return null;
204
+ }
205
+ if (withoutQuery === "/") {
206
+ return "/";
207
+ }
208
+ const normalized = withoutQuery.replace(/\/+$/, "");
209
+ return normalized ? `${normalized}/` : null;
210
+ }
211
+ function inferPageIdFromPath(path, draft) {
212
+ if (!path) {
213
+ return null;
214
+ }
215
+ const normalizedPath = normalizeRoutePath(path);
216
+ if (!normalizedPath) {
217
+ return null;
218
+ }
219
+ for (const [pageId, entry] of Object.entries(draft.seo)) {
220
+ const routePath = normalizeRoutePath(entry.path);
221
+ if (routePath && routePath === normalizedPath) {
222
+ return pageId;
223
+ }
224
+ }
225
+ if (normalizedPath === "/" && "home" in draft.pages) {
226
+ return "home";
227
+ }
228
+ return null;
229
+ }
230
+ function toHistoryModelMessages(history) {
231
+ if (!history || history.length === 0) {
232
+ return [];
233
+ }
234
+ const messages = [];
235
+ for (const turn of history.slice(-12)) {
236
+ if (turn.role === "user") {
237
+ messages.push({
238
+ role: "user",
239
+ content: turn.text
240
+ });
241
+ continue;
242
+ }
243
+ messages.push({
244
+ role: "assistant",
245
+ content: turn.text
246
+ });
247
+ }
248
+ return messages;
249
+ }
250
+ function formatSelectedElementContext(selectedElement) {
251
+ if (!selectedElement) {
252
+ return "No selected element.";
253
+ }
254
+ const lines = [
255
+ `path: ${selectedElement.path}`,
256
+ `label: ${selectedElement.label}`,
257
+ `kind: ${selectedElement.kind}`,
258
+ `pagePath: ${selectedElement.pagePath}`
259
+ ];
260
+ if (selectedElement.relatedPaths && selectedElement.relatedPaths.length > 0) {
261
+ lines.push(`relatedPaths: ${selectedElement.relatedPaths.join(", ")}`);
262
+ }
263
+ if (selectedElement.preview) {
264
+ lines.push(`preview: ${selectedElement.preview}`);
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+ function trimTrailingUrlPunctuation(value) {
269
+ return value.replace(/[),.;!?]+$/g, "");
270
+ }
271
+ function normalizeVisionImageUrl(value, publicBaseUrl) {
272
+ const cleaned = trimTrailingUrlPunctuation(value.trim());
273
+ if (!cleaned) {
274
+ return null;
275
+ }
276
+ const resolved = resolveReferenceImageUrl(cleaned, publicBaseUrl);
277
+ if (!resolved) {
278
+ return null;
279
+ }
280
+ try {
281
+ const parsed = new URL(resolved);
282
+ if (parsed.protocol !== "https:") {
283
+ return null;
284
+ }
285
+ if (!IMAGE_EXTENSION_PATTERN.test(parsed.pathname.toLowerCase())) {
286
+ return null;
287
+ }
288
+ return parsed.toString();
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+ function collectVisionInputImages(input) {
294
+ const items = [];
295
+ const seen = /* @__PURE__ */ new Set();
296
+ const push = (url, source) => {
297
+ if (items.length >= VISION_INPUT_LIMIT || seen.has(url)) {
298
+ return;
299
+ }
300
+ seen.add(url);
301
+ items.push({ url, source });
302
+ };
303
+ if (input.selectedElement?.kind === "image") {
304
+ const candidatePaths2 = [
305
+ input.selectedElement.path,
306
+ ...input.selectedElement.relatedPaths ?? []
307
+ ];
308
+ for (const candidatePath of candidatePaths2) {
309
+ const value = getByPath(input.draft, candidatePath);
310
+ if (typeof value !== "string") {
311
+ continue;
312
+ }
313
+ const normalized = normalizeVisionImageUrl(value, input.publicBaseUrl);
314
+ if (normalized) {
315
+ push(normalized, "selected-element");
316
+ break;
317
+ }
318
+ }
319
+ if (items.length === 0 && input.selectedElement.preview) {
320
+ const previewUrl = normalizeVisionImageUrl(
321
+ input.selectedElement.preview,
322
+ input.publicBaseUrl
323
+ );
324
+ if (previewUrl) {
325
+ push(previewUrl, "selected-element");
326
+ }
327
+ }
328
+ }
329
+ for (const match of input.prompt.matchAll(IMAGE_URL_REGEX)) {
330
+ if (items.length >= VISION_INPUT_LIMIT) {
331
+ break;
332
+ }
333
+ const normalized = normalizeVisionImageUrl(match[0], input.publicBaseUrl);
334
+ if (!normalized) {
335
+ continue;
336
+ }
337
+ push(normalized, "prompt-url");
338
+ }
339
+ if (items.length > 0) {
340
+ return items;
341
+ }
342
+ const wantsVisualInspection = /\b(image|photo|picture|visual|looks?\s+like)\b/i.test(
343
+ input.prompt
344
+ );
345
+ if (!wantsVisualInspection) {
346
+ return items;
347
+ }
348
+ const mentionsHero = /\bhero\b/i.test(input.prompt);
349
+ const candidatePaths = [];
350
+ if (mentionsHero) {
351
+ if (input.currentPageId) {
352
+ candidatePaths.push(`pages.${input.currentPageId}.hero.image`);
353
+ }
354
+ candidatePaths.push("pages.home.hero.image");
355
+ } else if (input.currentPageId) {
356
+ candidatePaths.push(`pages.${input.currentPageId}.hero.image`);
357
+ }
358
+ candidatePaths.push("layout.shared.pageIntro.image");
359
+ for (const path of candidatePaths) {
360
+ const value = getByPath(input.draft, path);
361
+ if (typeof value !== "string") {
362
+ continue;
363
+ }
364
+ const normalized = normalizeVisionImageUrl(value, input.publicBaseUrl);
365
+ if (!normalized) {
366
+ continue;
367
+ }
368
+ push(normalized, "inferred-context");
369
+ }
370
+ return items;
371
+ }
372
+ var MAX_STRUCTURE_DEPTH = 6;
373
+ var MAX_STRUCTURE_KEYS_PER_OBJECT = 18;
374
+ function describeStructure(value, depth = 0) {
375
+ if (value === null) {
376
+ return "null";
377
+ }
378
+ if (Array.isArray(value)) {
379
+ if (depth >= MAX_STRUCTURE_DEPTH) {
380
+ return { type: "array", item: "unknown" };
381
+ }
382
+ return {
383
+ type: "array",
384
+ item: value.length > 0 ? describeStructure(value[0], depth + 1) : "unknown"
385
+ };
386
+ }
387
+ if (typeof value === "object") {
388
+ if (depth >= MAX_STRUCTURE_DEPTH) {
389
+ return "object";
390
+ }
391
+ const out = {};
392
+ const entries = Object.entries(value);
393
+ for (let index = 0; index < entries.length; index += 1) {
394
+ if (index >= MAX_STRUCTURE_KEYS_PER_OBJECT) {
395
+ out.__truncatedKeys = `${entries.length - index} more`;
396
+ break;
397
+ }
398
+ const [key, child] = entries[index];
399
+ out[key] = describeStructure(child, depth + 1);
400
+ }
401
+ return out;
402
+ }
403
+ return typeof value;
404
+ }
405
+ function previewDocument(document) {
406
+ return JSON.stringify(
407
+ {
408
+ pages: describeStructure(document.pages),
409
+ layout: describeStructure(document.layout),
410
+ seo: describeStructure(document.seo),
411
+ themeTokens: describeStructure(document.themeTokens)
412
+ },
413
+ null,
414
+ 2
415
+ );
416
+ }
417
+ function getByPath(root, path) {
418
+ const trimmed = path.trim();
419
+ if (!trimmed) {
420
+ return root;
421
+ }
422
+ const segments = trimmed.replace(/\[(\d+)\]/g, ".$1").replace(/^\./, "").split(".").filter(Boolean);
423
+ let current = root;
424
+ for (const segment of segments) {
425
+ if (current === null || current === void 0) {
426
+ return void 0;
427
+ }
428
+ if (/^\d+$/.test(segment)) {
429
+ if (!Array.isArray(current)) {
430
+ return void 0;
431
+ }
432
+ current = current[Number(segment)];
433
+ continue;
434
+ }
435
+ if (typeof current !== "object") {
436
+ return void 0;
437
+ }
438
+ const record = current;
439
+ if (!(segment in record)) {
440
+ return void 0;
441
+ }
442
+ current = record[segment];
443
+ }
444
+ return current;
445
+ }
446
+ function toRecord(value) {
447
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
448
+ return null;
449
+ }
450
+ return value;
451
+ }
452
+ function normalizePublicBaseUrl(value) {
453
+ const raw = value?.trim();
454
+ if (!raw) {
455
+ return null;
456
+ }
457
+ try {
458
+ const parsed = new URL(raw);
459
+ if (parsed.protocol !== "https:") {
460
+ return null;
461
+ }
462
+ return parsed.toString().replace(/\/+$/, "");
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+ function parseImageMimeType(value) {
468
+ if (!value) {
469
+ return null;
470
+ }
471
+ const normalized = value.trim().toLowerCase().split(";", 1)[0];
472
+ const canonical = normalized === "image/jpg" || normalized === "image/pjpeg" ? "image/jpeg" : normalized;
473
+ if (!canonical.startsWith("image/")) {
474
+ return null;
475
+ }
476
+ return canonical;
477
+ }
478
+ function isGeminiReferenceMimeType(value) {
479
+ return GEMINI_REFERENCE_MIME_TYPES.has(value);
480
+ }
481
+ function resolveReferenceImageUrl(rawValue, publicBaseUrl) {
482
+ const value = rawValue.trim();
483
+ if (!value) {
484
+ return null;
485
+ }
486
+ if (value.startsWith("https://")) {
487
+ return value;
488
+ }
489
+ if (value.startsWith("/") && publicBaseUrl) {
490
+ return `${publicBaseUrl}${value}`;
491
+ }
492
+ if (publicBaseUrl && /^[a-z0-9][a-z0-9/_-]*\.(png|jpg|jpeg|webp|gif|bmp|svg)$/i.test(value)) {
493
+ return `${publicBaseUrl}/${value.replace(/^\/+/, "")}`;
494
+ }
495
+ return null;
496
+ }
497
+ function parseGeminiErrorBody(payload) {
498
+ const root = toRecord(payload);
499
+ const error = toRecord(root?.error);
500
+ if (!error) {
501
+ return null;
502
+ }
503
+ const message = error.message;
504
+ if (typeof message !== "string" || !message.trim()) {
505
+ return null;
506
+ }
507
+ return message.trim();
508
+ }
509
+ function extractGeminiInlineImage(payload) {
510
+ const root = toRecord(payload);
511
+ if (!root || !Array.isArray(root.candidates)) {
512
+ return null;
513
+ }
514
+ for (const candidate of root.candidates) {
515
+ const candidateRecord = toRecord(candidate);
516
+ const content = toRecord(candidateRecord?.content);
517
+ const parts = Array.isArray(content?.parts) ? content.parts : [];
518
+ for (const part of parts) {
519
+ const partRecord = toRecord(part);
520
+ if (!partRecord) {
521
+ continue;
522
+ }
523
+ const inlineData = toRecord(partRecord.inlineData) ?? toRecord(partRecord.inline_data);
524
+ if (!inlineData) {
525
+ continue;
526
+ }
527
+ const mimeTypeRaw = typeof inlineData.mimeType === "string" ? inlineData.mimeType : typeof inlineData.mime_type === "string" ? inlineData.mime_type : null;
528
+ const mimeType = parseImageMimeType(mimeTypeRaw);
529
+ const data = typeof inlineData.data === "string" ? inlineData.data : "";
530
+ if (!mimeType || !data) {
531
+ continue;
532
+ }
533
+ return {
534
+ mimeType,
535
+ data
536
+ };
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+ async function fetchReferenceImageAsInlineData(url) {
542
+ const response = await fetch(url, {
543
+ method: "GET",
544
+ redirect: "follow"
545
+ });
546
+ if (!response.ok) {
547
+ throw new Error(`Failed to fetch reference image (${response.status}).`);
548
+ }
549
+ const mimeType = parseImageMimeType(response.headers.get("content-type"));
550
+ if (!mimeType) {
551
+ throw new Error("Reference image content type is not an image.");
552
+ }
553
+ if (!isGeminiReferenceMimeType(mimeType)) {
554
+ throw new Error(
555
+ `Gemini image edit references support only JPEG or PNG (got ${mimeType}).`
556
+ );
557
+ }
558
+ const bytes = new Uint8Array(await response.arrayBuffer());
559
+ if (bytes.length === 0) {
560
+ throw new Error("Reference image is empty.");
561
+ }
562
+ return {
563
+ mimeType,
564
+ data: Buffer.from(bytes).toString("base64")
565
+ };
566
+ }
567
+ async function generateGeminiImage(input) {
568
+ const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim();
569
+ if (!apiKey) {
570
+ throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not configured.");
571
+ }
572
+ const modelId = process.env.GEMINI_IMAGE_MODEL_ID?.trim() || DEFAULT_GEMINI_IMAGE_MODEL_ID;
573
+ const endpoint = `${GEMINI_API_BASE_URL}/${encodeURIComponent(modelId)}:generateContent`;
574
+ const timeoutMs = geminiImageRequestTimeoutMs();
575
+ const parts = [];
576
+ if (input.mode === "edit" && input.referenceImage) {
577
+ parts.push({
578
+ inlineData: {
579
+ mimeType: input.referenceImage.mimeType,
580
+ data: input.referenceImage.data
581
+ }
582
+ });
583
+ }
584
+ parts.push({
585
+ text: input.prompt
586
+ });
587
+ const requestBody = {
588
+ contents: [{ parts }],
589
+ generationConfig: {
590
+ responseModalities: ["IMAGE"],
591
+ imageConfig: {
592
+ imageSize: input.quality
593
+ }
594
+ }
595
+ };
596
+ let response;
597
+ try {
598
+ response = await fetch(endpoint, {
599
+ method: "POST",
600
+ headers: {
601
+ "content-type": "application/json",
602
+ "x-goog-api-key": apiKey
603
+ },
604
+ body: JSON.stringify(requestBody),
605
+ signal: AbortSignal.timeout(timeoutMs)
606
+ });
607
+ } catch (error) {
608
+ if (error instanceof Error && error.name === "TimeoutError") {
609
+ throw new Error(
610
+ `Gemini image request timed out after ${Math.ceil(timeoutMs / 1e3)} seconds.`
611
+ );
612
+ }
613
+ const detail = error instanceof Error ? error.message : "Unknown request error.";
614
+ throw new Error(`Gemini image request failed before response: ${detail}`);
615
+ }
616
+ const raw = await response.text();
617
+ let parsed = null;
618
+ if (raw.trim()) {
619
+ try {
620
+ parsed = JSON.parse(raw);
621
+ } catch {
622
+ parsed = null;
623
+ }
624
+ }
625
+ if (!response.ok) {
626
+ const parsedDetail = parseGeminiErrorBody(parsed);
627
+ const detail = (parsedDetail ?? raw.trim()) || response.statusText;
628
+ throw new Error(`Gemini image request failed (${response.status}): ${detail}`);
629
+ }
630
+ const inlineImage = extractGeminiInlineImage(parsed);
631
+ if (!inlineImage) {
632
+ throw new Error("Gemini response did not include an image.");
633
+ }
634
+ const decoded = Buffer.from(inlineImage.data, "base64");
635
+ if (decoded.length === 0) {
636
+ throw new Error("Gemini returned an empty image payload.");
637
+ }
638
+ return {
639
+ bytes: new Uint8Array(decoded),
640
+ mimeType: inlineImage.mimeType
641
+ };
642
+ }
643
+ function clampCheckpointReason(value) {
644
+ const compact = value.replace(/\s+/g, " ").trim();
645
+ if (compact.length <= CHECKPOINT_REASON_MAX_LENGTH) {
646
+ return compact;
647
+ }
648
+ return `${compact.slice(0, CHECKPOINT_REASON_MAX_LENGTH - 3).trimEnd()}...`;
649
+ }
650
+ function normalizeCheckpointReasonHint(value) {
651
+ if (typeof value !== "string") {
652
+ return null;
653
+ }
654
+ const compact = clampCheckpointReason(value);
655
+ if (!compact) {
656
+ return null;
657
+ }
658
+ if (/^agent-(content|theme)-edit$/i.test(compact) || /^agent-image-generate$/i.test(compact) || /^agent-turn-edit$/i.test(compact) || /^update$/i.test(compact) || /^edit$/i.test(compact) || /^changes?$/i.test(compact)) {
659
+ return null;
660
+ }
661
+ return compact;
662
+ }
663
+ function pluralize(count, singular, plural) {
664
+ if (count === 1) {
665
+ return singular;
666
+ }
667
+ return plural ?? `${singular}s`;
668
+ }
669
+ function scopeFromContentPath(path) {
670
+ const segments = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
671
+ if (segments[0] === "pages" && segments[1]) {
672
+ return segments[1];
673
+ }
674
+ if (segments[0] === "layout") {
675
+ return "layout";
676
+ }
677
+ if (segments[0] === "seo" && segments[1]) {
678
+ return `seo ${segments[1]}`;
679
+ }
680
+ if (segments[0] === "seo") {
681
+ return "seo";
682
+ }
683
+ return "site";
684
+ }
685
+ function summarizeContentScopes(paths) {
686
+ const orderedScopes = [];
687
+ const seen = /* @__PURE__ */ new Set();
688
+ for (const path of paths) {
689
+ const scope = scopeFromContentPath(path);
690
+ if (seen.has(scope)) {
691
+ continue;
692
+ }
693
+ seen.add(scope);
694
+ orderedScopes.push(scope);
695
+ }
696
+ if (orderedScopes.length === 0) {
697
+ return "site";
698
+ }
699
+ if (orderedScopes.length === 1) {
700
+ return orderedScopes[0];
701
+ }
702
+ if (orderedScopes.length === 2) {
703
+ return `${orderedScopes[0]} and ${orderedScopes[1]}`;
704
+ }
705
+ return "multiple sections";
706
+ }
707
+ function resolveCheckpointReason(input) {
708
+ const hinted = input.reasonHints.at(-1);
709
+ if (hinted) {
710
+ return hinted;
711
+ }
712
+ const contentPaths = input.contentOperations.map((operation) => operation.path);
713
+ const contentCount = contentPaths.length;
714
+ const themeCount = Object.keys(input.themeTokens).length;
715
+ const hasContent = contentCount > 0;
716
+ const hasTheme = themeCount > 0;
717
+ if (!hasContent && !hasTheme) {
718
+ return "Apply CMS updates";
719
+ }
720
+ if (hasContent && hasTheme) {
721
+ const scope = summarizeContentScopes(contentPaths);
722
+ const base = scope === "multiple sections" ? `Update content across multiple sections and ${themeCount} theme ${pluralize(themeCount, "token")}` : `Update ${scope} content and ${themeCount} theme ${pluralize(themeCount, "token")}`;
723
+ return clampCheckpointReason(base);
724
+ }
725
+ if (hasContent) {
726
+ const scope = summarizeContentScopes(contentPaths);
727
+ const hasAnyImageChange = contentPaths.some((path) => requiresStrictImageValidation(path));
728
+ const imageOnlyChanges = contentPaths.every((path) => requiresStrictImageValidation(path));
729
+ if (imageOnlyChanges) {
730
+ const base2 = scope === "multiple sections" ? `Update ${contentCount} ${pluralize(contentCount, "image")} across multiple sections` : `Update ${scope} ${pluralize(contentCount, "image")}`;
731
+ return clampCheckpointReason(base2);
732
+ }
733
+ if (hasAnyImageChange) {
734
+ const base2 = scope === "multiple sections" ? "Update content and images across multiple sections" : `Update ${scope} content and images`;
735
+ return clampCheckpointReason(base2);
736
+ }
737
+ const base = scope === "multiple sections" ? "Update content across multiple sections" : `Update ${scope} content`;
738
+ return clampCheckpointReason(base);
739
+ }
740
+ return clampCheckpointReason(
741
+ `Update ${themeCount} theme ${pluralize(themeCount, "token")}`
742
+ );
743
+ }
744
+ function makeSnippet(text, queryLower) {
745
+ const index = text.toLowerCase().indexOf(queryLower);
746
+ if (index < 0) {
747
+ const snippet = text.slice(0, 160);
748
+ return {
749
+ snippet,
750
+ truncated: snippet.length < text.length
751
+ };
752
+ }
753
+ const start = Math.max(0, index - 60);
754
+ const end = Math.min(text.length, index + queryLower.length + 60);
755
+ return {
756
+ snippet: text.slice(start, end),
757
+ truncated: start > 0 || end < text.length
758
+ };
759
+ }
760
+ function searchDocument(document, query) {
761
+ const hits = [];
762
+ const queryLower = query.trim().toLowerCase();
763
+ if (!queryLower) {
764
+ return hits;
765
+ }
766
+ const seen = /* @__PURE__ */ new Set();
767
+ let pathHitCount = 0;
768
+ const MAX_PATH_HITS = 8;
769
+ const pushHit = (path, snippet, snippetTruncated) => {
770
+ if (!path || hits.length >= 20) {
771
+ return;
772
+ }
773
+ const key = `${path}::${snippet}`;
774
+ if (seen.has(key)) {
775
+ return;
776
+ }
777
+ seen.add(key);
778
+ hits.push({
779
+ path,
780
+ snippet,
781
+ snippetTruncated
782
+ });
783
+ };
784
+ const maybePushPathHit = (path) => {
785
+ if (!path || pathHitCount >= MAX_PATH_HITS) {
786
+ return;
787
+ }
788
+ const pathLower = path.toLowerCase();
789
+ if (!pathLower.includes(queryLower)) {
790
+ return;
791
+ }
792
+ const isDescendantOfExactQuery = pathLower !== queryLower && (pathLower.startsWith(`${queryLower}.`) || pathLower.startsWith(`${queryLower}[`));
793
+ if (isDescendantOfExactQuery) {
794
+ return;
795
+ }
796
+ pathHitCount += 1;
797
+ pushHit(path, `Path match: ${path}`, false);
798
+ };
799
+ const visit = (value, basePath) => {
800
+ maybePushPathHit(basePath);
801
+ if (typeof value === "string") {
802
+ if (value.toLowerCase().includes(queryLower)) {
803
+ const snippet = makeSnippet(value, queryLower);
804
+ pushHit(basePath, snippet.snippet, snippet.truncated);
805
+ }
806
+ return;
807
+ }
808
+ if (Array.isArray(value)) {
809
+ value.forEach((item, index) => {
810
+ visit(item, `${basePath}[${index}]`);
811
+ });
812
+ return;
813
+ }
814
+ if (value && typeof value === "object") {
815
+ Object.entries(value).forEach(([key, child]) => {
816
+ const nextPath = basePath ? `${basePath}.${key}` : key;
817
+ visit(child, nextPath);
818
+ });
819
+ }
820
+ };
821
+ visit(document, "");
822
+ return hits;
823
+ }
824
+ var STYLE_CONTROL_PATTERNS = [
825
+ { control: "line-height", pattern: /\b(line[\s-]?height|leading)\b/i },
826
+ { control: "font-size", pattern: /\b(font[\s-]?size|text[\s-]?size)\b/i },
827
+ { control: "letter-spacing", pattern: /\b(letter[\s-]?spacing|tracking)\b/i },
828
+ { control: "typography", pattern: /\btypography\b/i }
829
+ ];
830
+ var STYLE_FOLLOW_UP_PATTERN = /^\s*(here|this|that|all|everywhere|site[-\s]?wide|same|yes|1|2|3|a|b|c)\b/i;
831
+ function normalizeStyleControlToken(value) {
832
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
833
+ }
834
+ function detectRequestedStyleControls(value) {
835
+ const found = /* @__PURE__ */ new Set();
836
+ for (const candidate of STYLE_CONTROL_PATTERNS) {
837
+ if (candidate.pattern.test(value)) {
838
+ found.add(candidate.control);
839
+ }
840
+ }
841
+ return Array.from(found);
842
+ }
843
+ function inferRequestedStyleControls(prompt, history) {
844
+ const direct = detectRequestedStyleControls(prompt);
845
+ if (direct.length > 0) {
846
+ return direct;
847
+ }
848
+ if (!STYLE_FOLLOW_UP_PATTERN.test(prompt) || !history || history.length === 0) {
849
+ return [];
850
+ }
851
+ const recentUserTurns = history.filter((turn) => turn.role === "user").slice(-4).map((turn) => turn.text).join("\n");
852
+ return detectRequestedStyleControls(recentUserTurns);
853
+ }
854
+ function isStyleControlSupported(control, themeTokenKeys) {
855
+ const needle = normalizeStyleControlToken(control);
856
+ return themeTokenKeys.some((tokenKey) => {
857
+ const normalizedToken = normalizeStyleControlToken(tokenKey);
858
+ if (needle === "typography") {
859
+ return /(lineheight|fontsize|letterspacing|typography|tracking|leading)/.test(
860
+ normalizedToken
861
+ );
862
+ }
863
+ return normalizedToken.includes(needle);
864
+ });
865
+ }
866
+ function buildUnsupportedStyleBoundaryMessage(unsupportedControls) {
867
+ const controlLabel = unsupportedControls.length > 1 ? unsupportedControls.join(", ") : unsupportedControls[0] ?? "that styling control";
868
+ return [
869
+ `I can't change ${controlLabel} in this CMS draft because that styling control is not available here.`,
870
+ "Please ask Superadmin to enable it."
871
+ ].join("\n");
872
+ }
873
+ function isStyleWorkaroundReply(value) {
874
+ const normalized = value.toLowerCase();
875
+ return normalized.includes("pick one") || normalized.includes("where should we apply") || normalized.includes("click") || normalized.includes("specific text block") || normalized.includes("edit wording") || normalized.includes("all headings") || normalized.includes("all body text");
876
+ }
877
+ function keyTokensFromName(value) {
878
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
879
+ }
880
+ function collectKeyTokens(value, out) {
881
+ if (Array.isArray(value)) {
882
+ value.forEach((item) => collectKeyTokens(item, out));
883
+ return;
884
+ }
885
+ if (!value || typeof value !== "object") {
886
+ return;
887
+ }
888
+ Object.entries(value).forEach(([key, child]) => {
889
+ keyTokensFromName(key).forEach((token) => out.add(token));
890
+ collectKeyTokens(child, out);
891
+ });
892
+ }
893
+ function pageHasComponentToken(pageValue, token) {
894
+ const tokens = /* @__PURE__ */ new Set();
895
+ collectKeyTokens(pageValue, tokens);
896
+ return tokens.has(token.toLowerCase());
897
+ }
898
+ function mentionsFormAsExistingComponent(value) {
899
+ const normalized = value.toLowerCase();
900
+ if (!/\bform\b/.test(normalized)) {
901
+ return false;
902
+ }
903
+ if (/\b(no|not|without|don't|doesn't|cannot|can't)\b[^.!?\n]{0,28}\bform\b/.test(normalized)) {
904
+ return false;
905
+ }
906
+ return /\b(the|your|our)\s+contact\s+form\b/.test(normalized) || /\b(above|below|beside|next to|around|under|over|before|after)\b[^.!?\n]{0,40}\bform\b/.test(
907
+ normalized
908
+ ) || /\bform\b[^.!?\n]{0,40}\b(above|below|beside|next to|right|left|column|block|section)\b/.test(
909
+ normalized
910
+ );
911
+ }
912
+ function formatPageLabel(pageId) {
913
+ if (!pageId) {
914
+ return "current";
915
+ }
916
+ return pageId.replace(/([A-Z])/g, " $1").toLowerCase();
917
+ }
918
+ function normalizeAssistantReply(rawText, context) {
919
+ if (context.unsupportedStyleControls.length > 0 && !context.mutationsApplied) {
920
+ const normalized2 = rawText.trim();
921
+ const mentionsSuperadmin = /superadmin/i.test(normalized2);
922
+ if (!normalized2 || isStyleWorkaroundReply(normalized2) || !mentionsSuperadmin) {
923
+ return buildUnsupportedStyleBoundaryMessage(context.unsupportedStyleControls);
924
+ }
925
+ return normalized2;
926
+ }
927
+ if (context.blockedThemeTokenCount > 0 && !context.mutationsApplied) {
928
+ return [
929
+ "That theme token is not available in this draft.",
930
+ "Please ask Superadmin to add that token capability."
931
+ ].join("\n");
932
+ }
933
+ const normalized = rawText.trim();
934
+ const looksLikeFallbackDone = /^done\.?$/i.test(normalized);
935
+ if (normalized && !context.currentPageHasForm && mentionsFormAsExistingComponent(normalized)) {
936
+ const pageLabel = formatPageLabel(context.currentPageId);
937
+ return [
938
+ `I don't see a form on the ${pageLabel} page in the current CMS structure.`,
939
+ "Please place this relative to sections that already exist."
940
+ ].join("\n");
941
+ }
942
+ if (!normalized || looksLikeFallbackDone && !context.mutationsApplied) {
943
+ if (context.mutationsApplied) {
944
+ return "Changes were applied.";
945
+ }
946
+ if (context.blockedContentPathCount > 0) {
947
+ return "No changes were applied because one or more target fields do not exist.";
948
+ }
949
+ return "No changes were applied.";
950
+ }
951
+ return normalized;
952
+ }
953
+ async function runAgentTurn(service, input) {
954
+ const draft = await service.getContent("draft");
955
+ const currentPageId = inferPageIdFromPath(input.currentPath, draft);
956
+ const currentPage = currentPageId ? draft.pages[currentPageId] : null;
957
+ const modelConfig = service.getModelConfig();
958
+ const model = resolveModel(input.modelId ?? modelConfig.defaultModelId, modelConfig);
959
+ const mutationPolicy = await resolveMutationPolicy(model, input.prompt, input.history);
960
+ const turnWriteMode = mutationPolicy.allowWrites ? "write-enabled" : "read-only";
961
+ const thinking = [];
962
+ const toolEvents = [];
963
+ const stagedContentOperations = [];
964
+ const stagedThemeTokens = {};
965
+ const stagedCheckpointReasonHints = [];
966
+ const themeTokenKeys = Object.keys(draft.themeTokens);
967
+ const allowedThemeTokenKeys = new Set(themeTokenKeys);
968
+ const blockedThemeTokenKeys = /* @__PURE__ */ new Set();
969
+ const blockedContentPaths = /* @__PURE__ */ new Set();
970
+ const requestedStyleControls = inferRequestedStyleControls(input.prompt, input.history);
971
+ const unsupportedStyleControls = requestedStyleControls.filter(
972
+ (control) => !isStyleControlSupported(control, themeTokenKeys)
973
+ );
974
+ const currentPageHasForm = currentPage ? pageHasComponentToken(currentPage, "form") : false;
975
+ const publicBaseUrl = normalizePublicBaseUrl(service.getPublicAssetBaseUrl());
976
+ const pushThinking = (note) => {
977
+ if (!input.includeThinking) {
978
+ return;
979
+ }
980
+ thinking.push(note);
981
+ input.onThinkingEvent?.(note);
982
+ };
983
+ const pushToolEvent = (event) => {
984
+ toolEvents.push(event);
985
+ input.onToolEvent?.(event);
986
+ };
987
+ const visionInputImages = collectVisionInputImages({
988
+ draft,
989
+ prompt: input.prompt,
990
+ selectedElement: input.selectedElement,
991
+ publicBaseUrl,
992
+ currentPageId
993
+ });
994
+ const hasVisionInputs = visionInputImages.length > 0;
995
+ if (hasVisionInputs) {
996
+ pushThinking(
997
+ `Attached ${visionInputImages.length} image input(s) for this turn (${visionInputImages.map((item) => item.source).join(", ")}).`
998
+ );
999
+ pushToolEvent({
1000
+ tool: "vision_input",
1001
+ summary: `Attached ${visionInputImages.length} image input(s) for visual analysis.`
1002
+ });
1003
+ }
1004
+ const promptSections = [
1005
+ "User request:",
1006
+ input.prompt,
1007
+ "Turn write mode:",
1008
+ turnWriteMode,
1009
+ `Write gate reason: ${mutationPolicy.reason}`,
1010
+ "Admin current page context:",
1011
+ `path: ${input.currentPath ?? "unknown"}`,
1012
+ `pageId: ${currentPageId ?? "unknown"}`,
1013
+ "Selected element context:",
1014
+ formatSelectedElementContext(input.selectedElement),
1015
+ "Current draft overview:",
1016
+ previewDocument(draft),
1017
+ "Theme token capability snapshot:",
1018
+ `availableThemeTokenKeys: ${themeTokenKeys.join(", ") || "none"}`,
1019
+ unsupportedStyleControls.length > 0 ? [
1020
+ `Unsupported style capability for this turn: ${unsupportedStyleControls.join(", ")}.`,
1021
+ "Do not ask follow-up questions or offer workaround options for this style request.",
1022
+ "State the limitation directly and route user to Superadmin."
1023
+ ].join(" ") : "",
1024
+ "Use tools for all concrete edits.",
1025
+ "Use read tools get_page, get_section, and search_content for precise context fetches.",
1026
+ "Use layout paths for header/footer/shared UI copy and media.",
1027
+ "Use generate_image for image creation and image modifications.",
1028
+ "For fully new images, call generate_image with mode='new'. For edits to an existing image, call generate_image with mode='edit'.",
1029
+ "For mutating tool calls, include a short reason that will be used as the checkpoint message.",
1030
+ mutationPolicy.allowWrites ? "This turn allows edits when needed." : "This turn is read-only. Do not call patch_content, patch_theme_tokens, or generate_image. Answer only. Ask whether we should apply changes only if user is discussing potential edits; for pure greetings or small talk, reply naturally and briefly."
1031
+ ];
1032
+ if (hasVisionInputs) {
1033
+ promptSections.push(
1034
+ [
1035
+ "Vision inputs attached below as image parts.",
1036
+ "You can inspect image pixels directly in this turn.",
1037
+ "Do not claim you are unable to view image contents.",
1038
+ "Use these images only when the user asks for visual interpretation or image-grounded edits.",
1039
+ "If the user request is not visual, ignore image inputs."
1040
+ ].join(" ")
1041
+ );
1042
+ promptSections.push(
1043
+ `Attached image URLs: ${visionInputImages.map((item) => item.url).join(", ")}`
1044
+ );
1045
+ }
1046
+ const promptText = promptSections.join("\n\n");
1047
+ const historyMessages = toHistoryModelMessages(input.history);
1048
+ const buildTurnMessages = (includeVisionInputs) => {
1049
+ const userContent = [
1050
+ {
1051
+ type: "text",
1052
+ text: promptText
1053
+ }
1054
+ ];
1055
+ if (includeVisionInputs) {
1056
+ for (const item of visionInputImages) {
1057
+ userContent.push({
1058
+ type: "image",
1059
+ image: new URL(item.url)
1060
+ });
1061
+ }
1062
+ }
1063
+ return [
1064
+ ...historyMessages,
1065
+ {
1066
+ role: "user",
1067
+ content: userContent
1068
+ }
1069
+ ];
1070
+ };
1071
+ const runModelTurn = (includeVisionInputs) => generateText({
1072
+ model,
1073
+ system: buildSystemPrompt(),
1074
+ messages: buildTurnMessages(includeVisionInputs),
1075
+ stopWhen: stepCountIs(5),
1076
+ tools: {
1077
+ get_page: tool({
1078
+ description: "Read-only. Returns a single page payload and matching SEO entry by pageId.",
1079
+ inputSchema: z.object({
1080
+ pageId: z.string().min(1).max(120)
1081
+ }),
1082
+ execute: async ({ pageId }) => {
1083
+ const current = await service.getContent("draft");
1084
+ const typedPageId = pageId.trim();
1085
+ const hasPage = Object.prototype.hasOwnProperty.call(
1086
+ current.pages,
1087
+ typedPageId
1088
+ );
1089
+ if (!hasPage) {
1090
+ return {
1091
+ pageId: typedPageId,
1092
+ found: false,
1093
+ page: null,
1094
+ seo: null,
1095
+ availablePageIds: Object.keys(current.pages)
1096
+ };
1097
+ }
1098
+ pushToolEvent({
1099
+ tool: "get_page",
1100
+ summary: `Read page '${typedPageId}'.`
1101
+ });
1102
+ pushThinking(`Fetched page content for ${typedPageId}.`);
1103
+ return {
1104
+ pageId: typedPageId,
1105
+ found: true,
1106
+ page: current.pages[typedPageId],
1107
+ seo: current.seo[typedPageId] ?? null,
1108
+ availablePageIds: Object.keys(current.pages)
1109
+ };
1110
+ }
1111
+ }),
1112
+ get_section: tool({
1113
+ description: "Read-only. Returns value at a JSON path (dot notation with optional [index]), e.g. pages.about.sections[0].title.",
1114
+ inputSchema: z.object({
1115
+ path: z.string().min(1).max(300)
1116
+ }),
1117
+ execute: async ({ path }) => {
1118
+ const current = await service.getContent("draft");
1119
+ const value = getByPath(current, path);
1120
+ pushToolEvent({
1121
+ tool: "get_section",
1122
+ summary: `Read section '${path}'.`
1123
+ });
1124
+ pushThinking(`Fetched section at path ${path}.`);
1125
+ return {
1126
+ path,
1127
+ found: value !== void 0,
1128
+ value: value ?? null
1129
+ };
1130
+ }
1131
+ }),
1132
+ search_content: tool({
1133
+ description: "Read-only. Searches text and returns matching paths with snippets. Snippets can be truncated; call get_section(path) before editing.",
1134
+ inputSchema: z.object({
1135
+ query: z.string().min(2).max(120)
1136
+ }),
1137
+ execute: async ({ query }) => {
1138
+ const current = await service.getContent("draft");
1139
+ const results = searchDocument(current, query);
1140
+ pushToolEvent({
1141
+ tool: "search_content",
1142
+ summary: `Searched '${query}' and found ${results.length} match(es).`
1143
+ });
1144
+ pushThinking(`Searched content for '${query}' and found ${results.length} match(es).`);
1145
+ return {
1146
+ query,
1147
+ totalMatches: results.length,
1148
+ results
1149
+ };
1150
+ }
1151
+ }),
1152
+ generate_image: tool({
1153
+ description: "Generates an image with Gemini Image Preview, uploads it to S3, and stages a CMS image URL update. Edit-mode references must be JPEG or PNG.",
1154
+ inputSchema: z.object({
1155
+ targetPath: z.string().min(3).max(320),
1156
+ prompt: z.string().min(3).max(2500),
1157
+ mode: z.enum(["new", "edit"]),
1158
+ quality: z.enum(["1K", "2K", "4K"]).optional(),
1159
+ reason: z.string().min(3).max(300).optional()
1160
+ }),
1161
+ execute: async ({ targetPath, prompt, mode, quality, reason }) => {
1162
+ if (!mutationPolicy.allowWrites) {
1163
+ pushThinking(`Blocked image generation attempt: ${mutationPolicy.reason}`);
1164
+ return {
1165
+ blocked: true,
1166
+ reason: mutationPolicy.reason,
1167
+ stagedOperations: 0,
1168
+ totalStagedOperations: stagedContentOperations.length
1169
+ };
1170
+ }
1171
+ if (!modelConfig.geminiEnabled) {
1172
+ pushToolEvent({
1173
+ tool: "generate_image",
1174
+ summary: "Blocked image generation: Gemini provider is disabled."
1175
+ });
1176
+ pushThinking("Blocked image generation attempt: Gemini provider is disabled.");
1177
+ return {
1178
+ blocked: true,
1179
+ reason: "Gemini provider is disabled.",
1180
+ stagedOperations: 0,
1181
+ totalStagedOperations: stagedContentOperations.length
1182
+ };
1183
+ }
1184
+ const current = await service.getContent("draft");
1185
+ const currentValue = getByPath(current, targetPath);
1186
+ if (currentValue === void 0) {
1187
+ blockedContentPaths.add(targetPath);
1188
+ pushToolEvent({
1189
+ tool: "generate_image",
1190
+ summary: `Blocked image generation: target path not found (${targetPath}).`
1191
+ });
1192
+ pushThinking(`Blocked image generation attempt: missing target path (${targetPath}).`);
1193
+ return {
1194
+ blocked: true,
1195
+ reason: "Target path does not exist in current schema.",
1196
+ stagedOperations: 0,
1197
+ totalStagedOperations: stagedContentOperations.length
1198
+ };
1199
+ }
1200
+ if (!requiresStrictImageValidation(targetPath)) {
1201
+ pushToolEvent({
1202
+ tool: "generate_image",
1203
+ summary: `Blocked image generation: target path is not an image field (${targetPath}).`
1204
+ });
1205
+ pushThinking(
1206
+ `Blocked image generation attempt: non-image target path (${targetPath}).`
1207
+ );
1208
+ return {
1209
+ blocked: true,
1210
+ reason: "Target path is not an image field.",
1211
+ stagedOperations: 0,
1212
+ totalStagedOperations: stagedContentOperations.length
1213
+ };
1214
+ }
1215
+ let referenceImage;
1216
+ if (mode === "edit") {
1217
+ if (typeof currentValue !== "string" || !currentValue.trim()) {
1218
+ pushToolEvent({
1219
+ tool: "generate_image",
1220
+ summary: "Blocked image generation: current image value is missing; cannot use edit mode."
1221
+ });
1222
+ pushThinking("Blocked image edit attempt: missing current image URL.");
1223
+ return {
1224
+ blocked: true,
1225
+ reason: "Current image value is missing for edit mode.",
1226
+ stagedOperations: 0,
1227
+ totalStagedOperations: stagedContentOperations.length
1228
+ };
1229
+ }
1230
+ const referenceUrl = resolveReferenceImageUrl(currentValue, publicBaseUrl);
1231
+ if (!referenceUrl) {
1232
+ pushToolEvent({
1233
+ tool: "generate_image",
1234
+ summary: "Blocked image generation: existing image URL is not a supported reference format."
1235
+ });
1236
+ pushThinking(
1237
+ "Blocked image edit attempt: existing image URL is not a supported reference."
1238
+ );
1239
+ return {
1240
+ blocked: true,
1241
+ reason: "Existing image URL is not a supported reference.",
1242
+ stagedOperations: 0,
1243
+ totalStagedOperations: stagedContentOperations.length
1244
+ };
1245
+ }
1246
+ try {
1247
+ referenceImage = await fetchReferenceImageAsInlineData(referenceUrl);
1248
+ } catch (error) {
1249
+ const detail = error instanceof Error ? error.message : "Unknown reference fetch error.";
1250
+ pushToolEvent({
1251
+ tool: "generate_image",
1252
+ summary: `Image generation failed: ${detail}`
1253
+ });
1254
+ pushThinking(`Image edit reference fetch failed: ${detail}`);
1255
+ return {
1256
+ blocked: true,
1257
+ reason: detail,
1258
+ stagedOperations: 0,
1259
+ totalStagedOperations: stagedContentOperations.length
1260
+ };
1261
+ }
1262
+ }
1263
+ try {
1264
+ const generated = await generateGeminiImage({
1265
+ prompt,
1266
+ mode,
1267
+ quality: quality ?? "1K",
1268
+ referenceImage
1269
+ });
1270
+ const saved = await service.saveGeneratedImage({
1271
+ targetPath,
1272
+ data: generated.bytes,
1273
+ contentType: generated.mimeType,
1274
+ cacheControl: DEFAULT_GENERATED_IMAGE_CACHE_CONTROL
1275
+ });
1276
+ const patch = createPatchFromAgentOperations([
1277
+ {
1278
+ path: targetPath,
1279
+ value: saved.url
1280
+ }
1281
+ ]);
1282
+ stagedContentOperations.push(...patch.operations);
1283
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1284
+ if (reasonHint) {
1285
+ stagedCheckpointReasonHints.push(reasonHint);
1286
+ }
1287
+ pushToolEvent({
1288
+ tool: "generate_image",
1289
+ summary: `Generated image for ${targetPath} and staged URL update (${saved.key}).`
1290
+ });
1291
+ pushThinking(
1292
+ `Generated image and prepared one content operation for ${targetPath} (${saved.key}).`
1293
+ );
1294
+ return {
1295
+ stagedOperations: patch.operations.length,
1296
+ totalStagedOperations: stagedContentOperations.length,
1297
+ targetPath,
1298
+ generatedUrl: saved.url,
1299
+ generatedKey: saved.key,
1300
+ reason: reason ?? "agent-image-generate"
1301
+ };
1302
+ } catch (error) {
1303
+ const detail = error instanceof Error ? error.message : "Unknown image generation error.";
1304
+ pushToolEvent({
1305
+ tool: "generate_image",
1306
+ summary: `Image generation failed: ${detail}`
1307
+ });
1308
+ pushThinking(`Image generation failed: ${detail}`);
1309
+ return {
1310
+ blocked: true,
1311
+ reason: detail,
1312
+ stagedOperations: 0,
1313
+ totalStagedOperations: stagedContentOperations.length
1314
+ };
1315
+ }
1316
+ }
1317
+ }),
1318
+ patch_content: tool({
1319
+ description: "Stage content edits to editable paths. Backend applies staged edits once at end of this user request.",
1320
+ inputSchema: z.object({
1321
+ reason: z.string().min(3).max(300).optional(),
1322
+ operations: z.array(
1323
+ z.object({
1324
+ path: z.string().min(3),
1325
+ value: z.unknown()
1326
+ })
1327
+ ).min(1).max(20)
1328
+ }),
1329
+ execute: async ({ operations, reason }) => {
1330
+ if (!mutationPolicy.allowWrites) {
1331
+ pushThinking(`Blocked write attempt: ${mutationPolicy.reason}`);
1332
+ return {
1333
+ blocked: true,
1334
+ reason: mutationPolicy.reason,
1335
+ stagedOperations: 0,
1336
+ totalStagedOperations: stagedContentOperations.length
1337
+ };
1338
+ }
1339
+ const current = await service.getContent("draft");
1340
+ const missingPaths = operations.map((operation) => operation.path).filter((path) => getByPath(current, path) === void 0);
1341
+ if (missingPaths.length > 0) {
1342
+ for (const path of missingPaths) {
1343
+ blockedContentPaths.add(path);
1344
+ }
1345
+ pushToolEvent({
1346
+ tool: "patch_content",
1347
+ summary: `Blocked ${missingPaths.length} content operation(s): target path not found.`
1348
+ });
1349
+ pushThinking(
1350
+ `Blocked write attempt: target path not found (${missingPaths.join(", ")}).`
1351
+ );
1352
+ return {
1353
+ blocked: true,
1354
+ reason: "One or more target paths do not exist in current schema.",
1355
+ missingPaths,
1356
+ stagedOperations: 0,
1357
+ totalStagedOperations: stagedContentOperations.length
1358
+ };
1359
+ }
1360
+ const patch = createPatchFromAgentOperations(
1361
+ operations
1362
+ );
1363
+ stagedContentOperations.push(...patch.operations);
1364
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1365
+ if (reasonHint) {
1366
+ stagedCheckpointReasonHints.push(reasonHint);
1367
+ }
1368
+ pushToolEvent({
1369
+ tool: "patch_content",
1370
+ summary: `Prepared ${operations.length} content operation(s).`
1371
+ });
1372
+ pushThinking(`Prepared ${operations.length} content operation(s) for end-of-turn apply.`);
1373
+ return {
1374
+ stagedOperations: operations.length,
1375
+ totalStagedOperations: stagedContentOperations.length,
1376
+ reason: reason ?? "agent-content-edit"
1377
+ };
1378
+ }
1379
+ }),
1380
+ patch_theme_tokens: tool({
1381
+ description: "Stage small theme token updates. Backend applies staged token edits once at end of this user request.",
1382
+ inputSchema: z.object({
1383
+ reason: z.string().min(3).max(300).optional(),
1384
+ tokens: z.record(z.string(), z.string().min(1))
1385
+ }),
1386
+ execute: async ({ tokens, reason }) => {
1387
+ if (!mutationPolicy.allowWrites) {
1388
+ pushThinking(`Blocked write attempt: ${mutationPolicy.reason}`);
1389
+ return {
1390
+ blocked: true,
1391
+ reason: mutationPolicy.reason,
1392
+ stagedThemeTokenCount: 0,
1393
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length
1394
+ };
1395
+ }
1396
+ const unknownTokenKeys = Object.keys(tokens).filter(
1397
+ (tokenKey) => !allowedThemeTokenKeys.has(tokenKey)
1398
+ );
1399
+ if (unknownTokenKeys.length > 0) {
1400
+ for (const tokenKey of unknownTokenKeys) {
1401
+ blockedThemeTokenKeys.add(tokenKey);
1402
+ }
1403
+ pushToolEvent({
1404
+ tool: "patch_theme_tokens",
1405
+ summary: `Blocked ${unknownTokenKeys.length} theme token change(s): token not found; route to Superadmin.`
1406
+ });
1407
+ pushThinking(
1408
+ `Blocked write attempt: unknown theme token(s): ${unknownTokenKeys.join(", ")}.`
1409
+ );
1410
+ return {
1411
+ blocked: true,
1412
+ reason: "One or more theme token keys do not exist in current schema. Route to Superadmin.",
1413
+ unknownTokenKeys,
1414
+ stagedThemeTokenCount: 0,
1415
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length
1416
+ };
1417
+ }
1418
+ Object.assign(stagedThemeTokens, tokens);
1419
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1420
+ if (reasonHint) {
1421
+ stagedCheckpointReasonHints.push(reasonHint);
1422
+ }
1423
+ pushToolEvent({
1424
+ tool: "patch_theme_tokens",
1425
+ summary: `Prepared ${Object.keys(tokens).length} theme token change(s).`
1426
+ });
1427
+ pushThinking(
1428
+ `Prepared ${Object.keys(tokens).length} theme token change(s) for end-of-turn apply.`
1429
+ );
1430
+ return {
1431
+ stagedThemeTokenCount: Object.keys(tokens).length,
1432
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length,
1433
+ reason: reason ?? "agent-theme-edit"
1434
+ };
1435
+ }
1436
+ })
1437
+ }
1438
+ });
1439
+ let response;
1440
+ try {
1441
+ response = await runModelTurn(hasVisionInputs);
1442
+ } catch (error) {
1443
+ if (!hasVisionInputs) {
1444
+ throw error;
1445
+ }
1446
+ const detail = error instanceof Error ? error.message : "Unknown model error.";
1447
+ pushThinking(`Vision input processing failed (${detail}). Retrying without vision inputs.`);
1448
+ response = await runModelTurn(false);
1449
+ }
1450
+ const hasContentChanges = stagedContentOperations.length > 0;
1451
+ const hasThemeChanges = Object.keys(stagedThemeTokens).length > 0;
1452
+ const mutationsApplied = hasContentChanges || hasThemeChanges;
1453
+ let updatedDraft;
1454
+ if (mutationsApplied) {
1455
+ const checkpointReason = resolveCheckpointReason({
1456
+ reasonHints: stagedCheckpointReasonHints,
1457
+ contentOperations: stagedContentOperations,
1458
+ themeTokens: stagedThemeTokens
1459
+ });
1460
+ const mutationResult = await service.mutateDraftBatch({
1461
+ patch: hasContentChanges ? { operations: stagedContentOperations } : void 0,
1462
+ themePatch: hasThemeChanges ? stagedThemeTokens : void 0,
1463
+ actor: input.actor,
1464
+ reason: checkpointReason
1465
+ });
1466
+ updatedDraft = mutationResult.document;
1467
+ pushThinking(
1468
+ `Committed ${stagedContentOperations.length} content operation(s) and ${Object.keys(stagedThemeTokens).length} theme token change(s) in one checkpoint for this request.`
1469
+ );
1470
+ pushToolEvent({
1471
+ tool: "commit_draft",
1472
+ summary: `Committed ${stagedContentOperations.length} content op(s) and ${Object.keys(stagedThemeTokens).length} theme token change(s) in one checkpoint.`
1473
+ });
1474
+ } else {
1475
+ updatedDraft = await service.getContent("draft");
1476
+ }
1477
+ return {
1478
+ text: normalizeAssistantReply(response.text, {
1479
+ mutationsApplied,
1480
+ blockedThemeTokenCount: blockedThemeTokenKeys.size,
1481
+ blockedContentPathCount: blockedContentPaths.size,
1482
+ unsupportedStyleControls,
1483
+ currentPageId,
1484
+ currentPageHasForm
1485
+ }),
1486
+ thinking,
1487
+ toolEvents,
1488
+ updatedDraft,
1489
+ mutationsApplied
1490
+ };
1491
+ }
1492
+
1493
+ export {
1494
+ listStaticToolNames,
1495
+ runAgentTurn
1496
+ };