@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.
- package/dist/agent/SOUL.md +36 -0
- package/dist/agent/index.js +2 -2
- package/dist/api-aws/index.js +3 -3
- package/dist/{chunk-5CVLHGGO.js → chunk-6RB7H6PA.js} +27 -33
- package/dist/{chunk-2LAI3MY2.js → chunk-EYY23AAK.js} +1 -0
- package/dist/{chunk-X6TU47KZ.js → chunk-IK36OUJQ.js} +841 -792
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -3
- package/package.json +4 -3
|
@@ -1,147 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createPatchFromAgentOperations
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-EYY23AAK.js";
|
|
4
4
|
|
|
5
5
|
// src/agent/index.ts
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
256
|
+
function searchDocument(document, query) {
|
|
257
|
+
const hits = [];
|
|
258
|
+
const queryLower = query.trim().toLowerCase();
|
|
259
|
+
if (!queryLower) {
|
|
260
|
+
return hits;
|
|
456
261
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
|
655
|
-
if (!
|
|
619
|
+
const resolved = resolveReferenceImageUrl(cleaned, publicBaseUrl);
|
|
620
|
+
if (!resolved) {
|
|
656
621
|
return null;
|
|
657
622
|
}
|
|
658
|
-
|
|
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
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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 (
|
|
675
|
-
return
|
|
682
|
+
if (items.length > 0) {
|
|
683
|
+
return items;
|
|
676
684
|
}
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
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 {
|