@webmaster-droid/server 0.1.0-alpha.0 → 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.
@@ -1,147 +1,14 @@
1
1
  import {
2
2
  createPatchFromAgentOperations
3
- } from "./chunk-2LAI3MY2.js";
3
+ } from "./chunk-EYY23AAK.js";
4
4
 
5
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.
6
+ import { generateText, stepCountIs } from "ai";
7
+ import "@webmaster-droid/contracts";
98
8
 
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
- }
9
+ // src/agent/intent.ts
10
+ import { generateObject } from "ai";
11
+ import { z } from "zod";
145
12
  function normalizeIntentText(value) {
146
13
  return value.toLowerCase().replace(/\s+/g, " ").trim();
147
14
  }
@@ -193,6 +60,37 @@ async function resolveMutationPolicy(model, prompt, history) {
193
60
  };
194
61
  }
195
62
  }
63
+
64
+ // src/agent/model.ts
65
+ import { google } from "@ai-sdk/google";
66
+ import { openai } from "@ai-sdk/openai";
67
+ import "@webmaster-droid/contracts";
68
+ function normalizeModelId(modelId) {
69
+ if (modelId.includes(":")) {
70
+ return modelId;
71
+ }
72
+ return modelId.startsWith("gemini") ? `gemini:${modelId}` : `openai:${modelId}`;
73
+ }
74
+ function resolveModel(modelId, config) {
75
+ const normalized = normalizeModelId(modelId || config.defaultModelId);
76
+ if (normalized.startsWith("openai:")) {
77
+ if (!config.openaiEnabled) {
78
+ throw new Error("OpenAI provider is disabled.");
79
+ }
80
+ return openai(normalized.replace("openai:", ""));
81
+ }
82
+ if (normalized.startsWith("gemini:")) {
83
+ if (!config.geminiEnabled) {
84
+ throw new Error("Gemini provider is disabled.");
85
+ }
86
+ return google(normalized.replace("gemini:", ""));
87
+ }
88
+ throw new Error(`Unsupported model identifier: ${normalized}`);
89
+ }
90
+
91
+ // src/agent/document.ts
92
+ import "ai";
93
+ import "@webmaster-droid/contracts";
196
94
  function normalizeRoutePath(path) {
197
95
  const trimmed = path.trim();
198
96
  if (!trimmed.startsWith("/")) {
@@ -265,110 +163,6 @@ function formatSelectedElementContext(selectedElement) {
265
163
  }
266
164
  return lines.join("\n");
267
165
  }
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
166
  var MAX_STRUCTURE_DEPTH = 6;
373
167
  var MAX_STRUCTURE_KEYS_PER_OBJECT = 18;
374
168
  function describeStructure(value, depth = 0) {
@@ -443,34 +237,132 @@ function getByPath(root, path) {
443
237
  }
444
238
  return current;
445
239
  }
446
- function toRecord(value) {
447
- if (!value || typeof value !== "object" || Array.isArray(value)) {
448
- return null;
240
+ function makeSnippet(text, queryLower) {
241
+ const index = text.toLowerCase().indexOf(queryLower);
242
+ if (index < 0) {
243
+ const snippet = text.slice(0, 160);
244
+ return {
245
+ snippet,
246
+ truncated: snippet.length < text.length
247
+ };
449
248
  }
450
- return value;
249
+ const start = Math.max(0, index - 60);
250
+ const end = Math.min(text.length, index + queryLower.length + 60);
251
+ return {
252
+ snippet: text.slice(start, end),
253
+ truncated: start > 0 || end < text.length
254
+ };
451
255
  }
452
- function normalizePublicBaseUrl(value) {
453
- const raw = value?.trim();
454
- if (!raw) {
455
- return null;
256
+ function searchDocument(document, query) {
257
+ const hits = [];
258
+ const queryLower = query.trim().toLowerCase();
259
+ if (!queryLower) {
260
+ return hits;
456
261
  }
457
- try {
458
- const parsed = new URL(raw);
459
- if (parsed.protocol !== "https:") {
460
- return null;
262
+ const seen = /* @__PURE__ */ new Set();
263
+ let pathHitCount = 0;
264
+ const MAX_PATH_HITS = 8;
265
+ const pushHit = (path, snippet, snippetTruncated) => {
266
+ if (!path || hits.length >= 20) {
267
+ return;
461
268
  }
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/")) {
269
+ const key = `${path}::${snippet}`;
270
+ if (seen.has(key)) {
271
+ return;
272
+ }
273
+ seen.add(key);
274
+ hits.push({
275
+ path,
276
+ snippet,
277
+ snippetTruncated
278
+ });
279
+ };
280
+ const maybePushPathHit = (path) => {
281
+ if (!path || pathHitCount >= MAX_PATH_HITS) {
282
+ return;
283
+ }
284
+ const pathLower = path.toLowerCase();
285
+ if (!pathLower.includes(queryLower)) {
286
+ return;
287
+ }
288
+ const isDescendantOfExactQuery = pathLower !== queryLower && (pathLower.startsWith(`${queryLower}.`) || pathLower.startsWith(`${queryLower}[`));
289
+ if (isDescendantOfExactQuery) {
290
+ return;
291
+ }
292
+ pathHitCount += 1;
293
+ pushHit(path, `Path match: ${path}`, false);
294
+ };
295
+ const visit = (value, basePath) => {
296
+ maybePushPathHit(basePath);
297
+ if (typeof value === "string") {
298
+ if (value.toLowerCase().includes(queryLower)) {
299
+ const snippet = makeSnippet(value, queryLower);
300
+ pushHit(basePath, snippet.snippet, snippet.truncated);
301
+ }
302
+ return;
303
+ }
304
+ if (Array.isArray(value)) {
305
+ value.forEach((item, index) => {
306
+ visit(item, `${basePath}[${index}]`);
307
+ });
308
+ return;
309
+ }
310
+ if (value && typeof value === "object") {
311
+ Object.entries(value).forEach(([key, child]) => {
312
+ const nextPath = basePath ? `${basePath}.${key}` : key;
313
+ visit(child, nextPath);
314
+ });
315
+ }
316
+ };
317
+ visit(document, "");
318
+ return hits;
319
+ }
320
+
321
+ // src/agent/gemini-image.ts
322
+ var DEFAULT_GEMINI_IMAGE_MODEL_ID = "gemini-3-pro-image-preview";
323
+ var DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS = 285e3;
324
+ var GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
325
+ var GEMINI_REFERENCE_MIME_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png"]);
326
+ var DEFAULT_GENERATED_IMAGE_CACHE_CONTROL = "public,max-age=31536000,immutable";
327
+ function geminiImageRequestTimeoutMs() {
328
+ const raw = process.env.GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
329
+ if (!raw) {
330
+ return DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
331
+ }
332
+ const parsed = Number.parseInt(raw.trim(), 10);
333
+ if (!Number.isFinite(parsed) || parsed < 1e3) {
334
+ return DEFAULT_GEMINI_IMAGE_REQUEST_TIMEOUT_MS;
335
+ }
336
+ return parsed;
337
+ }
338
+ function toRecord(value) {
339
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
340
+ return null;
341
+ }
342
+ return value;
343
+ }
344
+ function normalizePublicBaseUrl(value) {
345
+ const raw = value?.trim();
346
+ if (!raw) {
347
+ return null;
348
+ }
349
+ try {
350
+ const parsed = new URL(raw);
351
+ if (parsed.protocol !== "https:") {
352
+ return null;
353
+ }
354
+ return parsed.toString().replace(/\/+$/, "");
355
+ } catch {
356
+ return null;
357
+ }
358
+ }
359
+ function parseImageMimeType(value) {
360
+ if (!value) {
361
+ return null;
362
+ }
363
+ const normalized = value.trim().toLowerCase().split(";", 1)[0];
364
+ const canonical = normalized === "image/jpg" || normalized === "image/pjpeg" ? "image/jpeg" : normalized;
365
+ if (!canonical.startsWith("image/")) {
474
366
  return null;
475
367
  }
476
368
  return canonical;
@@ -640,187 +532,189 @@ async function generateGeminiImage(input) {
640
532
  mimeType: inlineImage.mimeType
641
533
  };
642
534
  }
643
- function clampCheckpointReason(value) {
644
- const compact = value.replace(/\s+/g, " ").trim();
645
- if (compact.length <= CHECKPOINT_REASON_MAX_LENGTH) {
646
- return compact;
535
+
536
+ // src/agent/prompt.ts
537
+ import { readFileSync } from "fs";
538
+ import { dirname, join } from "path";
539
+ import { fileURLToPath } from "url";
540
+ var SOUL_FILE_NAME = "SOUL.md";
541
+ var SOUL_FALLBACK = `# Soul
542
+
543
+ ## Essence
544
+ A timeless caretaker-engine devoted to keeping the website true, intact, and correct.
545
+
546
+ ## Relationship to the User
547
+ - The user's intent outranks preferences.
548
+ - Ask precise questions when scope is ambiguous.
549
+ - Keep language simple and direct.`;
550
+ function loadSoulMarkdown() {
551
+ try {
552
+ const currentDir = dirname(fileURLToPath(import.meta.url));
553
+ return readFileSync(join(currentDir, SOUL_FILE_NAME), "utf8").trim();
554
+ } catch {
555
+ return SOUL_FALLBACK;
647
556
  }
648
- return `${compact.slice(0, CHECKPOINT_REASON_MAX_LENGTH - 3).trimEnd()}...`;
649
557
  }
650
- function normalizeCheckpointReasonHint(value) {
651
- if (typeof value !== "string") {
558
+ var SOUL_MARKDOWN = loadSoulMarkdown();
559
+ function buildSystemPrompt() {
560
+ return [
561
+ "You are Webmaster, the CMS editing agent for this site.",
562
+ "",
563
+ "Mission:",
564
+ "- Keep site integrity and recoverability safe.",
565
+ "- Apply only explicit user-requested edits.",
566
+ "- Communicate clearly with minimal drama.",
567
+ "",
568
+ "Instruction priority (highest first):",
569
+ "1) Safety, schema, and tool constraints.",
570
+ "2) Explicit user intent in the latest turn.",
571
+ "3) Correctness and grounded output.",
572
+ "4) Minimal-change execution.",
573
+ "5) Tone and brevity.",
574
+ "",
575
+ "Operating rules:",
576
+ "- If the request is clear and executable, perform it with tools.",
577
+ "- If target/path/scope is missing, ask one concise clarifying question.",
578
+ "- Never infer missing intent or invent components, paths, or schema keys.",
579
+ "- Never mutate from search snippets alone. Fetch full context first via get_page or get_section.",
580
+ "- For edits, use selectedElement context and relatedPaths when provided and relevant.",
581
+ "- For destructive or high-risk changes, briefly state impact and require explicit confirmation before mutating.",
582
+ "- Only use existing schema and existing theme token keys.",
583
+ "- If fields/components/tokens are missing or unsupported, state that directly and route user to Superadmin.",
584
+ "- Never initiate or propose publish/checkpoint management actions.",
585
+ "- Use generate_image for image creation or edits; never invent image URLs.",
586
+ "- In generate_image edit mode, reference images must be JPEG or PNG.",
587
+ "- For every mutating tool call, include a short reason describing edit intent.",
588
+ "",
589
+ "Tool and data constraints:",
590
+ "- Do not reveal internal technical IDs or JSON paths unless the user asks for technical detail.",
591
+ "",
592
+ "PERSONA",
593
+ SOUL_MARKDOWN,
594
+ "",
595
+ "Conflict resolution:",
596
+ "- If autonomy conflicts with ambiguity, ask one clarifying question.",
597
+ "- If a request conflicts with schema/tool limits, refuse that part and explain the limit briefly.",
598
+ "",
599
+ "Behavior examples:",
600
+ "1) Clear edit request: fetch exact path, patch only requested fields, then confirm briefly.",
601
+ "2) Ambiguous request: ask one direct question for target element/page and intended change.",
602
+ "3) Risky request: state likely impact and ask for explicit confirmation before any mutation."
603
+ ].join("\n");
604
+ }
605
+
606
+ // src/agent/vision.ts
607
+ import "@webmaster-droid/contracts";
608
+ var IMAGE_URL_REGEX = /https:\/\/[^\s<>"'`]+/gi;
609
+ var IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|webp|gif|bmp|svg|avif)$/i;
610
+ var VISION_INPUT_LIMIT = 3;
611
+ function trimTrailingUrlPunctuation(value) {
612
+ return value.replace(/[),.;!?]+$/g, "");
613
+ }
614
+ function normalizeVisionImageUrl(value, publicBaseUrl) {
615
+ const cleaned = trimTrailingUrlPunctuation(value.trim());
616
+ if (!cleaned) {
652
617
  return null;
653
618
  }
654
- const compact = clampCheckpointReason(value);
655
- if (!compact) {
619
+ const resolved = resolveReferenceImageUrl(cleaned, publicBaseUrl);
620
+ if (!resolved) {
656
621
  return null;
657
622
  }
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)) {
623
+ try {
624
+ const parsed = new URL(resolved);
625
+ if (parsed.protocol !== "https:") {
626
+ return null;
627
+ }
628
+ if (!IMAGE_EXTENSION_PATTERN.test(parsed.pathname.toLowerCase())) {
629
+ return null;
630
+ }
631
+ return parsed.toString();
632
+ } catch {
659
633
  return null;
660
634
  }
661
- return compact;
662
635
  }
663
- function pluralize(count, singular, plural) {
664
- if (count === 1) {
665
- return singular;
636
+ function collectVisionInputImages(input) {
637
+ const items = [];
638
+ const seen = /* @__PURE__ */ new Set();
639
+ const push = (url, source) => {
640
+ if (items.length >= VISION_INPUT_LIMIT || seen.has(url)) {
641
+ return;
642
+ }
643
+ seen.add(url);
644
+ items.push({ url, source });
645
+ };
646
+ if (input.selectedElement?.kind === "image") {
647
+ const candidatePaths2 = [
648
+ input.selectedElement.path,
649
+ ...input.selectedElement.relatedPaths ?? []
650
+ ];
651
+ for (const candidatePath of candidatePaths2) {
652
+ const value = getByPath(input.draft, candidatePath);
653
+ if (typeof value !== "string") {
654
+ continue;
655
+ }
656
+ const normalized = normalizeVisionImageUrl(value, input.publicBaseUrl);
657
+ if (normalized) {
658
+ push(normalized, "selected-element");
659
+ break;
660
+ }
661
+ }
662
+ if (items.length === 0 && input.selectedElement.preview) {
663
+ const previewUrl = normalizeVisionImageUrl(
664
+ input.selectedElement.preview,
665
+ input.publicBaseUrl
666
+ );
667
+ if (previewUrl) {
668
+ push(previewUrl, "selected-element");
669
+ }
670
+ }
666
671
  }
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];
672
+ for (const match of input.prompt.matchAll(IMAGE_URL_REGEX)) {
673
+ if (items.length >= VISION_INPUT_LIMIT) {
674
+ break;
675
+ }
676
+ const normalized = normalizeVisionImageUrl(match[0], input.publicBaseUrl);
677
+ if (!normalized) {
678
+ continue;
679
+ }
680
+ push(normalized, "prompt-url");
673
681
  }
674
- if (segments[0] === "layout") {
675
- return "layout";
682
+ if (items.length > 0) {
683
+ return items;
676
684
  }
677
- if (segments[0] === "seo" && segments[1]) {
678
- return `seo ${segments[1]}`;
685
+ const wantsVisualInspection = /\b(image|photo|picture|visual|looks?\s+like)\b/i.test(
686
+ input.prompt
687
+ );
688
+ if (!wantsVisualInspection) {
689
+ return items;
679
690
  }
680
- if (segments[0] === "seo") {
681
- return "seo";
691
+ const mentionsHero = /\bhero\b/i.test(input.prompt);
692
+ const candidatePaths = [];
693
+ if (mentionsHero) {
694
+ if (input.currentPageId) {
695
+ candidatePaths.push(`pages.${input.currentPageId}.hero.image`);
696
+ }
697
+ candidatePaths.push("pages.home.hero.image");
698
+ } else if (input.currentPageId) {
699
+ candidatePaths.push(`pages.${input.currentPageId}.hero.image`);
682
700
  }
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)) {
701
+ candidatePaths.push("layout.shared.pageIntro.image");
702
+ for (const path of candidatePaths) {
703
+ const value = getByPath(input.draft, path);
704
+ if (typeof value !== "string") {
691
705
  continue;
692
706
  }
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]}`;
707
+ const normalized = normalizeVisionImageUrl(value, input.publicBaseUrl);
708
+ if (!normalized) {
709
+ continue;
710
+ }
711
+ push(normalized, "inferred-context");
704
712
  }
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;
713
+ return items;
823
714
  }
715
+
716
+ // src/agent/assistant-reply.ts
717
+ import "@webmaster-droid/contracts";
824
718
  var STYLE_CONTROL_PATTERNS = [
825
719
  { control: "line-height", pattern: /\b(line[\s-]?height|leading)\b/i },
826
720
  { control: "font-size", pattern: /\b(font[\s-]?size|text[\s-]?size)\b/i },
@@ -950,6 +844,508 @@ function normalizeAssistantReply(rawText, context) {
950
844
  }
951
845
  return normalized;
952
846
  }
847
+
848
+ // src/agent/checkpoint.ts
849
+ import {
850
+ requiresStrictImageValidation
851
+ } from "@webmaster-droid/contracts";
852
+ var CHECKPOINT_REASON_MAX_LENGTH = 96;
853
+ function clampCheckpointReason(value) {
854
+ const compact = value.replace(/\s+/g, " ").trim();
855
+ if (compact.length <= CHECKPOINT_REASON_MAX_LENGTH) {
856
+ return compact;
857
+ }
858
+ return `${compact.slice(0, CHECKPOINT_REASON_MAX_LENGTH - 3).trimEnd()}...`;
859
+ }
860
+ function normalizeCheckpointReasonHint(value) {
861
+ if (typeof value !== "string") {
862
+ return null;
863
+ }
864
+ const compact = clampCheckpointReason(value);
865
+ if (!compact) {
866
+ return null;
867
+ }
868
+ 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)) {
869
+ return null;
870
+ }
871
+ return compact;
872
+ }
873
+ function pluralize(count, singular, plural) {
874
+ if (count === 1) {
875
+ return singular;
876
+ }
877
+ return plural ?? `${singular}s`;
878
+ }
879
+ function scopeFromContentPath(path) {
880
+ const segments = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
881
+ if (segments[0] === "pages" && segments[1]) {
882
+ return segments[1];
883
+ }
884
+ if (segments[0] === "layout") {
885
+ return "layout";
886
+ }
887
+ if (segments[0] === "seo" && segments[1]) {
888
+ return `seo ${segments[1]}`;
889
+ }
890
+ if (segments[0] === "seo") {
891
+ return "seo";
892
+ }
893
+ return "site";
894
+ }
895
+ function summarizeContentScopes(paths) {
896
+ const orderedScopes = [];
897
+ const seen = /* @__PURE__ */ new Set();
898
+ for (const path of paths) {
899
+ const scope = scopeFromContentPath(path);
900
+ if (seen.has(scope)) {
901
+ continue;
902
+ }
903
+ seen.add(scope);
904
+ orderedScopes.push(scope);
905
+ }
906
+ if (orderedScopes.length === 0) {
907
+ return "site";
908
+ }
909
+ if (orderedScopes.length === 1) {
910
+ return orderedScopes[0];
911
+ }
912
+ if (orderedScopes.length === 2) {
913
+ return `${orderedScopes[0]} and ${orderedScopes[1]}`;
914
+ }
915
+ return "multiple sections";
916
+ }
917
+ function resolveCheckpointReason(input) {
918
+ const hinted = input.reasonHints.at(-1);
919
+ if (hinted) {
920
+ return hinted;
921
+ }
922
+ const contentPaths = input.contentOperations.map((operation) => operation.path);
923
+ const contentCount = contentPaths.length;
924
+ const themeCount = Object.keys(input.themeTokens).length;
925
+ const hasContent = contentCount > 0;
926
+ const hasTheme = themeCount > 0;
927
+ if (!hasContent && !hasTheme) {
928
+ return "Apply CMS updates";
929
+ }
930
+ if (hasContent && hasTheme) {
931
+ const scope = summarizeContentScopes(contentPaths);
932
+ 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")}`;
933
+ return clampCheckpointReason(base);
934
+ }
935
+ if (hasContent) {
936
+ const scope = summarizeContentScopes(contentPaths);
937
+ const hasAnyImageChange = contentPaths.some((path) => requiresStrictImageValidation(path));
938
+ const imageOnlyChanges = contentPaths.every((path) => requiresStrictImageValidation(path));
939
+ if (imageOnlyChanges) {
940
+ const base2 = scope === "multiple sections" ? `Update ${contentCount} ${pluralize(contentCount, "image")} across multiple sections` : `Update ${scope} ${pluralize(contentCount, "image")}`;
941
+ return clampCheckpointReason(base2);
942
+ }
943
+ if (hasAnyImageChange) {
944
+ const base2 = scope === "multiple sections" ? "Update content and images across multiple sections" : `Update ${scope} content and images`;
945
+ return clampCheckpointReason(base2);
946
+ }
947
+ const base = scope === "multiple sections" ? "Update content across multiple sections" : `Update ${scope} content`;
948
+ return clampCheckpointReason(base);
949
+ }
950
+ return clampCheckpointReason(
951
+ `Update ${themeCount} theme ${pluralize(themeCount, "token")}`
952
+ );
953
+ }
954
+
955
+ // src/agent/tools.ts
956
+ import { tool } from "ai";
957
+ import { z as z2 } from "zod";
958
+ import {
959
+ requiresStrictImageValidation as requiresStrictImageValidation2
960
+ } from "@webmaster-droid/contracts";
961
+ function buildAgentTools(input) {
962
+ const {
963
+ service,
964
+ mutationPolicy,
965
+ modelConfig,
966
+ stagedContentOperations,
967
+ stagedThemeTokens,
968
+ stagedCheckpointReasonHints,
969
+ allowedThemeTokenKeys,
970
+ blockedThemeTokenKeys,
971
+ blockedContentPaths,
972
+ publicBaseUrl,
973
+ pushThinking,
974
+ pushToolEvent
975
+ } = input;
976
+ return {
977
+ get_page: tool({
978
+ description: "Read-only. Returns a single page payload and matching SEO entry by pageId.",
979
+ inputSchema: z2.object({
980
+ pageId: z2.string().min(1).max(120)
981
+ }),
982
+ execute: async ({ pageId }) => {
983
+ const current = await service.getContent("draft");
984
+ const typedPageId = pageId.trim();
985
+ const hasPage = Object.prototype.hasOwnProperty.call(current.pages, typedPageId);
986
+ if (!hasPage) {
987
+ return {
988
+ pageId: typedPageId,
989
+ found: false,
990
+ page: null,
991
+ seo: null,
992
+ availablePageIds: Object.keys(current.pages)
993
+ };
994
+ }
995
+ pushToolEvent({
996
+ tool: "get_page",
997
+ summary: `Read page '${typedPageId}'.`
998
+ });
999
+ pushThinking(`Fetched page content for ${typedPageId}.`);
1000
+ return {
1001
+ pageId: typedPageId,
1002
+ found: true,
1003
+ page: current.pages[typedPageId],
1004
+ seo: current.seo[typedPageId] ?? null,
1005
+ availablePageIds: Object.keys(current.pages)
1006
+ };
1007
+ }
1008
+ }),
1009
+ get_section: tool({
1010
+ description: "Read-only. Returns value at a JSON path (dot notation with optional [index]), e.g. pages.about.sections[0].title.",
1011
+ inputSchema: z2.object({
1012
+ path: z2.string().min(1).max(300)
1013
+ }),
1014
+ execute: async ({ path }) => {
1015
+ const current = await service.getContent("draft");
1016
+ const value = getByPath(current, path);
1017
+ pushToolEvent({
1018
+ tool: "get_section",
1019
+ summary: `Read section '${path}'.`
1020
+ });
1021
+ pushThinking(`Fetched section at path ${path}.`);
1022
+ return {
1023
+ path,
1024
+ found: value !== void 0,
1025
+ value: value ?? null
1026
+ };
1027
+ }
1028
+ }),
1029
+ search_content: tool({
1030
+ description: "Read-only. Searches text and returns matching paths with snippets. Snippets can be truncated; call get_section(path) before editing.",
1031
+ inputSchema: z2.object({
1032
+ query: z2.string().min(2).max(120)
1033
+ }),
1034
+ execute: async ({ query }) => {
1035
+ const current = await service.getContent("draft");
1036
+ const results = searchDocument(current, query);
1037
+ pushToolEvent({
1038
+ tool: "search_content",
1039
+ summary: `Searched '${query}' and found ${results.length} match(es).`
1040
+ });
1041
+ pushThinking(`Searched content for '${query}' and found ${results.length} match(es).`);
1042
+ return {
1043
+ query,
1044
+ totalMatches: results.length,
1045
+ results
1046
+ };
1047
+ }
1048
+ }),
1049
+ generate_image: tool({
1050
+ 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.",
1051
+ inputSchema: z2.object({
1052
+ targetPath: z2.string().min(3).max(320),
1053
+ prompt: z2.string().min(3).max(2500),
1054
+ mode: z2.enum(["new", "edit"]),
1055
+ quality: z2.enum(["1K", "2K", "4K"]).optional(),
1056
+ reason: z2.string().min(3).max(300).optional()
1057
+ }),
1058
+ execute: async ({ targetPath, prompt, mode, quality, reason }) => {
1059
+ if (!mutationPolicy.allowWrites) {
1060
+ pushThinking(`Blocked image generation attempt: ${mutationPolicy.reason}`);
1061
+ return {
1062
+ blocked: true,
1063
+ reason: mutationPolicy.reason,
1064
+ stagedOperations: 0,
1065
+ totalStagedOperations: stagedContentOperations.length
1066
+ };
1067
+ }
1068
+ if (!modelConfig.geminiEnabled) {
1069
+ pushToolEvent({
1070
+ tool: "generate_image",
1071
+ summary: "Blocked image generation: Gemini provider is disabled."
1072
+ });
1073
+ pushThinking("Blocked image generation attempt: Gemini provider is disabled.");
1074
+ return {
1075
+ blocked: true,
1076
+ reason: "Gemini provider is disabled.",
1077
+ stagedOperations: 0,
1078
+ totalStagedOperations: stagedContentOperations.length
1079
+ };
1080
+ }
1081
+ const current = await service.getContent("draft");
1082
+ const currentValue = getByPath(current, targetPath);
1083
+ if (currentValue === void 0) {
1084
+ blockedContentPaths.add(targetPath);
1085
+ pushToolEvent({
1086
+ tool: "generate_image",
1087
+ summary: `Blocked image generation: target path not found (${targetPath}).`
1088
+ });
1089
+ pushThinking(`Blocked image generation attempt: missing target path (${targetPath}).`);
1090
+ return {
1091
+ blocked: true,
1092
+ reason: "Target path does not exist in current schema.",
1093
+ stagedOperations: 0,
1094
+ totalStagedOperations: stagedContentOperations.length
1095
+ };
1096
+ }
1097
+ if (!requiresStrictImageValidation2(targetPath)) {
1098
+ pushToolEvent({
1099
+ tool: "generate_image",
1100
+ summary: `Blocked image generation: target path is not an image field (${targetPath}).`
1101
+ });
1102
+ pushThinking(
1103
+ `Blocked image generation attempt: non-image target path (${targetPath}).`
1104
+ );
1105
+ return {
1106
+ blocked: true,
1107
+ reason: "Target path is not an image field.",
1108
+ stagedOperations: 0,
1109
+ totalStagedOperations: stagedContentOperations.length
1110
+ };
1111
+ }
1112
+ let referenceImage;
1113
+ if (mode === "edit") {
1114
+ if (typeof currentValue !== "string" || !currentValue.trim()) {
1115
+ pushToolEvent({
1116
+ tool: "generate_image",
1117
+ summary: "Blocked image generation: current image value is missing; cannot use edit mode."
1118
+ });
1119
+ pushThinking("Blocked image edit attempt: missing current image URL.");
1120
+ return {
1121
+ blocked: true,
1122
+ reason: "Current image value is missing for edit mode.",
1123
+ stagedOperations: 0,
1124
+ totalStagedOperations: stagedContentOperations.length
1125
+ };
1126
+ }
1127
+ const referenceUrl = resolveReferenceImageUrl(currentValue, publicBaseUrl);
1128
+ if (!referenceUrl) {
1129
+ pushToolEvent({
1130
+ tool: "generate_image",
1131
+ summary: "Blocked image generation: existing image URL is not a supported reference format."
1132
+ });
1133
+ pushThinking(
1134
+ "Blocked image edit attempt: existing image URL is not a supported reference."
1135
+ );
1136
+ return {
1137
+ blocked: true,
1138
+ reason: "Existing image URL is not a supported reference.",
1139
+ stagedOperations: 0,
1140
+ totalStagedOperations: stagedContentOperations.length
1141
+ };
1142
+ }
1143
+ try {
1144
+ referenceImage = await fetchReferenceImageAsInlineData(referenceUrl);
1145
+ } catch (error) {
1146
+ const detail = error instanceof Error ? error.message : "Unknown reference fetch error.";
1147
+ pushToolEvent({
1148
+ tool: "generate_image",
1149
+ summary: `Image generation failed: ${detail}`
1150
+ });
1151
+ pushThinking(`Image edit reference fetch failed: ${detail}`);
1152
+ return {
1153
+ blocked: true,
1154
+ reason: detail,
1155
+ stagedOperations: 0,
1156
+ totalStagedOperations: stagedContentOperations.length
1157
+ };
1158
+ }
1159
+ }
1160
+ try {
1161
+ const generated = await generateGeminiImage({
1162
+ prompt,
1163
+ mode,
1164
+ quality: quality ?? "1K",
1165
+ referenceImage
1166
+ });
1167
+ const saved = await service.saveGeneratedImage({
1168
+ targetPath,
1169
+ data: generated.bytes,
1170
+ contentType: generated.mimeType,
1171
+ cacheControl: DEFAULT_GENERATED_IMAGE_CACHE_CONTROL
1172
+ });
1173
+ const patch = createPatchFromAgentOperations([
1174
+ {
1175
+ path: targetPath,
1176
+ value: saved.url
1177
+ }
1178
+ ]);
1179
+ stagedContentOperations.push(...patch.operations);
1180
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1181
+ if (reasonHint) {
1182
+ stagedCheckpointReasonHints.push(reasonHint);
1183
+ }
1184
+ pushToolEvent({
1185
+ tool: "generate_image",
1186
+ summary: `Generated image for ${targetPath} and staged URL update (${saved.key}).`
1187
+ });
1188
+ pushThinking(
1189
+ `Generated image and prepared one content operation for ${targetPath} (${saved.key}).`
1190
+ );
1191
+ return {
1192
+ stagedOperations: patch.operations.length,
1193
+ totalStagedOperations: stagedContentOperations.length,
1194
+ targetPath,
1195
+ generatedUrl: saved.url,
1196
+ generatedKey: saved.key,
1197
+ reason: reason ?? "agent-image-generate"
1198
+ };
1199
+ } catch (error) {
1200
+ const detail = error instanceof Error ? error.message : "Unknown image generation error.";
1201
+ pushToolEvent({
1202
+ tool: "generate_image",
1203
+ summary: `Image generation failed: ${detail}`
1204
+ });
1205
+ pushThinking(`Image generation failed: ${detail}`);
1206
+ return {
1207
+ blocked: true,
1208
+ reason: detail,
1209
+ stagedOperations: 0,
1210
+ totalStagedOperations: stagedContentOperations.length
1211
+ };
1212
+ }
1213
+ }
1214
+ }),
1215
+ patch_content: tool({
1216
+ description: "Stage content edits to editable paths. Backend applies staged edits once at end of this user request.",
1217
+ inputSchema: z2.object({
1218
+ reason: z2.string().min(3).max(300).optional(),
1219
+ operations: z2.array(
1220
+ z2.object({
1221
+ path: z2.string().min(3),
1222
+ value: z2.unknown()
1223
+ })
1224
+ ).min(1).max(20)
1225
+ }),
1226
+ execute: async ({ operations, reason }) => {
1227
+ if (!mutationPolicy.allowWrites) {
1228
+ pushThinking(`Blocked write attempt: ${mutationPolicy.reason}`);
1229
+ return {
1230
+ blocked: true,
1231
+ reason: mutationPolicy.reason,
1232
+ stagedOperations: 0,
1233
+ totalStagedOperations: stagedContentOperations.length
1234
+ };
1235
+ }
1236
+ const current = await service.getContent("draft");
1237
+ const missingPaths = operations.map((operation) => operation.path).filter((path) => getByPath(current, path) === void 0);
1238
+ if (missingPaths.length > 0) {
1239
+ for (const path of missingPaths) {
1240
+ blockedContentPaths.add(path);
1241
+ }
1242
+ pushToolEvent({
1243
+ tool: "patch_content",
1244
+ summary: `Blocked ${missingPaths.length} content operation(s): target path not found.`
1245
+ });
1246
+ pushThinking(
1247
+ `Blocked write attempt: target path not found (${missingPaths.join(", ")}).`
1248
+ );
1249
+ return {
1250
+ blocked: true,
1251
+ reason: "One or more target paths do not exist in current schema.",
1252
+ missingPaths,
1253
+ stagedOperations: 0,
1254
+ totalStagedOperations: stagedContentOperations.length
1255
+ };
1256
+ }
1257
+ const patch = createPatchFromAgentOperations(
1258
+ operations
1259
+ );
1260
+ stagedContentOperations.push(...patch.operations);
1261
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1262
+ if (reasonHint) {
1263
+ stagedCheckpointReasonHints.push(reasonHint);
1264
+ }
1265
+ pushToolEvent({
1266
+ tool: "patch_content",
1267
+ summary: `Prepared ${operations.length} content operation(s).`
1268
+ });
1269
+ pushThinking(`Prepared ${operations.length} content operation(s) for end-of-turn apply.`);
1270
+ return {
1271
+ stagedOperations: operations.length,
1272
+ totalStagedOperations: stagedContentOperations.length,
1273
+ reason: reason ?? "agent-content-edit"
1274
+ };
1275
+ }
1276
+ }),
1277
+ patch_theme_tokens: tool({
1278
+ description: "Stage small theme token updates. Backend applies staged token edits once at end of this user request.",
1279
+ inputSchema: z2.object({
1280
+ reason: z2.string().min(3).max(300).optional(),
1281
+ tokens: z2.record(z2.string(), z2.string().min(1))
1282
+ }),
1283
+ execute: async ({ tokens, reason }) => {
1284
+ if (!mutationPolicy.allowWrites) {
1285
+ pushThinking(`Blocked write attempt: ${mutationPolicy.reason}`);
1286
+ return {
1287
+ blocked: true,
1288
+ reason: mutationPolicy.reason,
1289
+ stagedThemeTokenCount: 0,
1290
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length
1291
+ };
1292
+ }
1293
+ const unknownTokenKeys = Object.keys(tokens).filter(
1294
+ (tokenKey) => !allowedThemeTokenKeys.has(tokenKey)
1295
+ );
1296
+ if (unknownTokenKeys.length > 0) {
1297
+ for (const tokenKey of unknownTokenKeys) {
1298
+ blockedThemeTokenKeys.add(tokenKey);
1299
+ }
1300
+ pushToolEvent({
1301
+ tool: "patch_theme_tokens",
1302
+ summary: `Blocked ${unknownTokenKeys.length} theme token change(s): token not found; route to Superadmin.`
1303
+ });
1304
+ pushThinking(
1305
+ `Blocked write attempt: unknown theme token(s): ${unknownTokenKeys.join(", ")}.`
1306
+ );
1307
+ return {
1308
+ blocked: true,
1309
+ reason: "One or more theme token keys do not exist in current schema. Route to Superadmin.",
1310
+ unknownTokenKeys,
1311
+ stagedThemeTokenCount: 0,
1312
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length
1313
+ };
1314
+ }
1315
+ Object.assign(stagedThemeTokens, tokens);
1316
+ const reasonHint = normalizeCheckpointReasonHint(reason);
1317
+ if (reasonHint) {
1318
+ stagedCheckpointReasonHints.push(reasonHint);
1319
+ }
1320
+ pushToolEvent({
1321
+ tool: "patch_theme_tokens",
1322
+ summary: `Prepared ${Object.keys(tokens).length} theme token change(s).`
1323
+ });
1324
+ pushThinking(
1325
+ `Prepared ${Object.keys(tokens).length} theme token change(s) for end-of-turn apply.`
1326
+ );
1327
+ return {
1328
+ stagedThemeTokenCount: Object.keys(tokens).length,
1329
+ totalStagedThemeTokenCount: Object.keys(stagedThemeTokens).length,
1330
+ reason: reason ?? "agent-theme-edit"
1331
+ };
1332
+ }
1333
+ })
1334
+ };
1335
+ }
1336
+
1337
+ // src/agent/index.ts
1338
+ var STATIC_TOOL_NAMES = [
1339
+ "patch_content",
1340
+ "patch_theme_tokens",
1341
+ "get_page",
1342
+ "get_section",
1343
+ "search_content",
1344
+ "generate_image"
1345
+ ];
1346
+ function listStaticToolNames() {
1347
+ return [...STATIC_TOOL_NAMES];
1348
+ }
953
1349
  async function runAgentTurn(service, input) {
954
1350
  const draft = await service.getContent("draft");
955
1351
  const currentPageId = inferPageIdFromPath(input.currentPath, draft);
@@ -1068,373 +1464,26 @@ async function runAgentTurn(service, input) {
1068
1464
  }
1069
1465
  ];
1070
1466
  };
1467
+ const tools = buildAgentTools({
1468
+ service,
1469
+ mutationPolicy,
1470
+ modelConfig,
1471
+ stagedContentOperations,
1472
+ stagedThemeTokens,
1473
+ stagedCheckpointReasonHints,
1474
+ allowedThemeTokenKeys,
1475
+ blockedThemeTokenKeys,
1476
+ blockedContentPaths,
1477
+ publicBaseUrl,
1478
+ pushThinking,
1479
+ pushToolEvent
1480
+ });
1071
1481
  const runModelTurn = (includeVisionInputs) => generateText({
1072
1482
  model,
1073
1483
  system: buildSystemPrompt(),
1074
1484
  messages: buildTurnMessages(includeVisionInputs),
1075
1485
  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
- }
1486
+ tools
1438
1487
  });
1439
1488
  let response;
1440
1489
  try {