@avocadostudio-ai/orchestrator-core 0.5.0 → 0.6.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/sites-agent-context.js +15 -2
- package/dist/chat/anthropic-planner.js +13 -7
- package/dist/chat/chat-pipeline-translation.js +13 -2
- package/dist/chat/hallucination-validator.js +97 -2
- package/dist/chat/planner.js +10 -1
- package/dist/cms/adapter.d.ts +29 -1
- package/dist/handler/create-orchestrator.js +91 -7
- package/dist/nlp/deterministic-planner-context.d.ts +19 -0
- package/dist/nlp/deterministic-planner-context.js +73 -3
- package/dist/state/session-state.d.ts +21 -0
- package/dist/state/session-state.js +24 -1
- package/package.json +16 -16
|
@@ -44,8 +44,21 @@ export function buildBlockCatalog() {
|
|
|
44
44
|
continue; // skip chrome blocks
|
|
45
45
|
const guide = BLOCK_VISUAL_GUIDE[type];
|
|
46
46
|
// Compact: Type — description | props | list fields
|
|
47
|
+
/*
|
|
48
|
+
* An `internal` field is dropped for a stronger reason than either: it is
|
|
49
|
+
* the CMS's own bookkeeping — a `_uid`, a revision stamp — and the site
|
|
50
|
+
* has said outright that it is not content. Naming it invites an edit to
|
|
51
|
+
* it.
|
|
52
|
+
*
|
|
53
|
+
* `reference` is dropped rather than annotated. It is a pointer the CMS
|
|
54
|
+
* owns — a story link, an entry link — and a model handed the prop name
|
|
55
|
+
* will eventually write a string into it, which replaces the pointer with a
|
|
56
|
+
* hard-coded URL that stops following renames. A prop that must never be
|
|
57
|
+
* written is not a prop worth naming.
|
|
58
|
+
*/
|
|
59
|
+
const writable = ([, fm]) => fm.kind !== "headingLevel" && fm.kind !== "reference" && !fm.internal;
|
|
47
60
|
const fieldParts = Object.entries(meta.fields)
|
|
48
|
-
.filter(
|
|
61
|
+
.filter(writable) // headingLevel is always optional noise
|
|
49
62
|
.map(([key, fm]) => {
|
|
50
63
|
const req = fm.required ? "" : "?";
|
|
51
64
|
const opts = fm.kind === "enum" && fm.options ? `(${fm.options.join("|")})` : "";
|
|
@@ -54,7 +67,7 @@ export function buildBlockCatalog() {
|
|
|
54
67
|
const listParts = meta.listFields
|
|
55
68
|
? Object.entries(meta.listFields).map(([listKey, listMeta]) => {
|
|
56
69
|
const items = Object.entries(listMeta.itemFields)
|
|
57
|
-
.filter(
|
|
70
|
+
.filter(writable)
|
|
58
71
|
.map(([k, fm]) => `${k}${fm.required ? "" : "?"}`);
|
|
59
72
|
return `${listKey}[]{${items.join(",")}}`;
|
|
60
73
|
})
|
|
@@ -336,13 +336,19 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
336
336
|
const attachmentBlocks = resolvedAttachments.map((attachment) => attachment.kind === "pdf"
|
|
337
337
|
? { type: "document", source: { type: "base64", media_type: "application/pdf", data: attachment.base64 } }
|
|
338
338
|
: { type: "image", source: { type: "base64", media_type: attachment.mediaType, data: attachment.base64 } });
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
/*
|
|
340
|
+
* A URL source is only ever https. Anthropic rejects anything else with
|
|
341
|
+
* `400 … Only HTTPS URLs are supported`, and it rejects the *request*, not the
|
|
342
|
+
* block — so one http image on a selected block failed the whole turn, and the
|
|
343
|
+
* user saw a raw provider error in place of their answer. `fetchImageAsBase64`
|
|
344
|
+
* inlines the loopback case; an image we can neither inline nor link is simply
|
|
345
|
+
* not shown to the model, which costs this turn its vision and nothing else.
|
|
346
|
+
*/
|
|
347
|
+
const visionBlocks = imageBase64
|
|
348
|
+
? [{ type: "image", source: { type: "base64", media_type: imageBase64.mediaType, data: imageBase64.base64 } }]
|
|
349
|
+
: imageUrlForVision && /^https:\/\//i.test(imageUrlForVision)
|
|
350
|
+
? [{ type: "image", source: { type: "url", url: imageUrlForVision } }]
|
|
351
|
+
: [];
|
|
346
352
|
const userContent = visionBlocks.length > 0 || attachmentBlocks.length > 0
|
|
347
353
|
? [...visionBlocks, ...attachmentBlocks, { type: "text", text: JSON.stringify(user) }]
|
|
348
354
|
: JSON.stringify(user);
|
|
@@ -232,6 +232,17 @@ export function findExplicitCtaTargetCoverageGap(args) {
|
|
|
232
232
|
function isTranslatableKind(kind) {
|
|
233
233
|
return kind === "text" || kind === "richtext" || kind === "imageAlt";
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Prose, and meant for a reader.
|
|
237
|
+
*
|
|
238
|
+
* A CMS's `_uid` is typed `text` and holds a string, so a translation
|
|
239
|
+
* checklist built on kind alone demands a French version of a UUID — and then
|
|
240
|
+
* reports the page as incompletely translated forever, because the planner
|
|
241
|
+
* quite rightly never produces one.
|
|
242
|
+
*/
|
|
243
|
+
function isTranslatableField(fm) {
|
|
244
|
+
return !fm.internal && isTranslatableKind(fm.kind);
|
|
245
|
+
}
|
|
235
246
|
/**
|
|
236
247
|
* The translatable surface of one block: which top-level props and which
|
|
237
248
|
* list-child fields carry prose. One definition, used both to tell the planner
|
|
@@ -239,13 +250,13 @@ function isTranslatableKind(kind) {
|
|
|
239
250
|
*/
|
|
240
251
|
function translatableFieldKeys(meta) {
|
|
241
252
|
const topLevel = Object.entries(meta?.fields ?? {})
|
|
242
|
-
.filter(([, fm]) =>
|
|
253
|
+
.filter(([, fm]) => isTranslatableField(fm))
|
|
243
254
|
.map(([key]) => key);
|
|
244
255
|
const lists = Object.entries(meta?.listFields ?? {})
|
|
245
256
|
.map(([listKey, listMeta]) => [
|
|
246
257
|
listKey,
|
|
247
258
|
Object.entries(listMeta.itemFields ?? {})
|
|
248
|
-
.filter(([, fm]) =>
|
|
259
|
+
.filter(([, fm]) => isTranslatableField(fm))
|
|
249
260
|
.map(([key]) => key)
|
|
250
261
|
])
|
|
251
262
|
.filter(([, itemFields]) => itemFields.length > 0);
|
|
@@ -26,6 +26,45 @@ function allowedPropKeysForBlockType(blockType) {
|
|
|
26
26
|
return null;
|
|
27
27
|
return new Set(Object.keys(shape));
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Props that hold a pointer the CMS owns.
|
|
31
|
+
*
|
|
32
|
+
* These are in the schema and therefore survive every check above, which is the
|
|
33
|
+
* problem: a Storyblok story link is `{ linktype, id, cached_url }`, a model
|
|
34
|
+
* writes the rendered href it saw, and the CMS ends up holding a hard-coded URL
|
|
35
|
+
* where a reference was. It renders identically and silently stops following
|
|
36
|
+
* renames — the one thing the reference was for. There is no valid value a
|
|
37
|
+
* planner can produce here, because constructing one needs the CMS's own
|
|
38
|
+
* document ids, so the op is dropped rather than repaired.
|
|
39
|
+
*/
|
|
40
|
+
function referencePropKeysForBlockType(blockType) {
|
|
41
|
+
const meta = getBlockMeta(blockType);
|
|
42
|
+
const keys = new Set();
|
|
43
|
+
for (const [key, field] of Object.entries(meta?.fields ?? {})) {
|
|
44
|
+
if (field.kind === "reference")
|
|
45
|
+
keys.add(key);
|
|
46
|
+
}
|
|
47
|
+
return keys;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Props the site has declared as its own bookkeeping.
|
|
51
|
+
*
|
|
52
|
+
* A `_uid`, a revision stamp, a `__source` snapshot. They are in the schema —
|
|
53
|
+
* the publisher needs them — so every check above passes them, and the planner
|
|
54
|
+
* never sees them in the block summary. A plan that names one therefore did
|
|
55
|
+
* not read it anywhere; it invented a plausible key, which for identity fields
|
|
56
|
+
* is exactly the kind of key that is easy to invent. Writing it would corrupt
|
|
57
|
+
* the CMS's own link between the draft and the document it came from.
|
|
58
|
+
*/
|
|
59
|
+
function internalPropKeysForBlockType(blockType) {
|
|
60
|
+
const meta = getBlockMeta(blockType);
|
|
61
|
+
const keys = new Set();
|
|
62
|
+
for (const [key, field] of Object.entries(meta?.fields ?? {})) {
|
|
63
|
+
if (field.internal)
|
|
64
|
+
keys.add(key);
|
|
65
|
+
}
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
29
68
|
/**
|
|
30
69
|
* Look up the current block by id across all pages in the draft. Preferring
|
|
31
70
|
* the targeted page when supplied avoids cross-page collisions on id reuse.
|
|
@@ -78,6 +117,8 @@ function humanBlockName(blockType) {
|
|
|
78
117
|
export function validateAndStripHallucinatedProps(args) {
|
|
79
118
|
const { plan, draft } = args;
|
|
80
119
|
const hallucinatedProps = [];
|
|
120
|
+
const referenceProps = [];
|
|
121
|
+
const internalProps = [];
|
|
81
122
|
for (const op of plan.ops) {
|
|
82
123
|
if (op.op !== "update_props")
|
|
83
124
|
continue;
|
|
@@ -93,7 +134,19 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
93
134
|
const patchCandidate = rawPatch.props && typeof rawPatch.props === "object" && !Array.isArray(rawPatch.props)
|
|
94
135
|
? rawPatch.props
|
|
95
136
|
: rawPatch;
|
|
137
|
+
const referenceKeys = referencePropKeysForBlockType(blockType);
|
|
138
|
+
const internalKeys = internalPropKeysForBlockType(blockType);
|
|
96
139
|
for (const key of Object.keys(patchCandidate)) {
|
|
140
|
+
if (referenceKeys.has(key)) {
|
|
141
|
+
delete patchCandidate[key];
|
|
142
|
+
referenceProps.push({ blockType, propName: key });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (internalKeys.has(key)) {
|
|
146
|
+
delete patchCandidate[key];
|
|
147
|
+
internalProps.push({ blockType, propName: key });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
97
150
|
if (allowedKeys.has(key))
|
|
98
151
|
continue;
|
|
99
152
|
delete patchCandidate[key];
|
|
@@ -105,7 +158,49 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
105
158
|
});
|
|
106
159
|
}
|
|
107
160
|
}
|
|
108
|
-
|
|
161
|
+
/*
|
|
162
|
+
* Said separately from the hallucinated-prop note below, because it is a
|
|
163
|
+
* different fact about a different kind of field. "The block has no such
|
|
164
|
+
* field" is false here — the field exists, it is on the page, and the person
|
|
165
|
+
* can see what it points at. What they cannot do is change it from here.
|
|
166
|
+
*/
|
|
167
|
+
const referenceNoteParts = [];
|
|
168
|
+
if (referenceProps.length > 0) {
|
|
169
|
+
const byBlock = new Map();
|
|
170
|
+
for (const entry of referenceProps) {
|
|
171
|
+
const bucket = byBlock.get(entry.blockType) ?? new Set();
|
|
172
|
+
bucket.add(entry.propName);
|
|
173
|
+
byBlock.set(entry.blockType, bucket);
|
|
174
|
+
}
|
|
175
|
+
for (const [blockType, props] of byBlock) {
|
|
176
|
+
const name = humanBlockName(blockType);
|
|
177
|
+
for (const prop of props) {
|
|
178
|
+
referenceNoteParts.push(`“${prop}” on the ${name} block points at a document in your CMS, so it is changed there rather than here.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/*
|
|
183
|
+
* An internal prop is the quietest of the three and gets the shortest
|
|
184
|
+
* sentence. The person did not ask for it — no request mentions a `_uid` —
|
|
185
|
+
* so the note exists to explain why one op did less than the plan said, not
|
|
186
|
+
* to teach them anything about the field.
|
|
187
|
+
*/
|
|
188
|
+
const internalNoteParts = [];
|
|
189
|
+
if (internalProps.length > 0) {
|
|
190
|
+
const byBlock = new Map();
|
|
191
|
+
for (const entry of internalProps) {
|
|
192
|
+
const bucket = byBlock.get(entry.blockType) ?? new Set();
|
|
193
|
+
bucket.add(entry.propName);
|
|
194
|
+
byBlock.set(entry.blockType, bucket);
|
|
195
|
+
}
|
|
196
|
+
for (const [blockType, props] of byBlock) {
|
|
197
|
+
const name = humanBlockName(blockType);
|
|
198
|
+
for (const prop of props) {
|
|
199
|
+
internalNoteParts.push(`“${prop}” on the ${name} block is managed by your site, not edited here, so it was left alone.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (hallucinatedProps.length > 0 || referenceNoteParts.length > 0 || internalNoteParts.length > 0) {
|
|
109
204
|
/*
|
|
110
205
|
* Two different events used to share one sentence, and the wrong one was
|
|
111
206
|
* the default.
|
|
@@ -130,7 +225,7 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
130
225
|
bucket.add(entry.propName);
|
|
131
226
|
byBlockType.set(entry.blockType, bucket);
|
|
132
227
|
}
|
|
133
|
-
const noteParts = [];
|
|
228
|
+
const noteParts = [...referenceNoteParts, ...internalNoteParts];
|
|
134
229
|
for (const [blockType, props] of byBlockType) {
|
|
135
230
|
const name = humanBlockName(blockType);
|
|
136
231
|
const visual = [...props].filter(isVisualPropName);
|
package/dist/chat/planner.js
CHANGED
|
@@ -1190,9 +1190,18 @@ export async function generatePlanWithOpenAI(args) {
|
|
|
1190
1190
|
? args.contextPack.selected.imageUrlForVision
|
|
1191
1191
|
: null;
|
|
1192
1192
|
const imageBase64 = imageUrlForVision ? await fetchImageAsBase64(imageUrlForVision) : null;
|
|
1193
|
+
/*
|
|
1194
|
+
* Same rule as the Anthropic path: a bare URL goes to the provider only when
|
|
1195
|
+
* it is https. A root-relative `/media/...` — which is what a site most often
|
|
1196
|
+
* writes in an image prop — is not a URL any provider can fetch, and sending
|
|
1197
|
+
* it fails the whole call rather than the image. Inline it when we can reach
|
|
1198
|
+
* it, drop it when we cannot.
|
|
1199
|
+
*/
|
|
1193
1200
|
const visionImageUrl = imageBase64
|
|
1194
1201
|
? `data:${imageBase64.mediaType};base64,${imageBase64.base64}`
|
|
1195
|
-
: imageUrlForVision
|
|
1202
|
+
: imageUrlForVision && /^https:\/\//i.test(imageUrlForVision)
|
|
1203
|
+
? imageUrlForVision
|
|
1204
|
+
: null;
|
|
1196
1205
|
// User-attached files: images as image_url parts, PDFs as `file` parts with
|
|
1197
1206
|
// inline base64 data. The `file` content part is only accepted by the gpt-4o /
|
|
1198
1207
|
// gpt-4.1 / gpt-5 families on chat.completions; sending it to other models
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -35,28 +35,56 @@ export interface CmsPublishContext {
|
|
|
35
35
|
* destroys what it was projected from. A publisher has to diff, and it cannot
|
|
36
36
|
* diff against nothing.
|
|
37
37
|
*
|
|
38
|
+
* **This is the CMS's draft, not the live site**, and the distinction decides
|
|
39
|
+
* whether a publish is correct. `getPages({ perspective: "draft" })` is what
|
|
40
|
+
* seeded the session, and the baseline is that same list, so the diff
|
|
41
|
+
* compares like with like and reports what *this session* changed. Diff
|
|
42
|
+
* against the live site instead and the difference includes every
|
|
43
|
+
* unpublished edit anybody made in the CMS — an Avocado publish then ships
|
|
44
|
+
* all of it, from a button whose label says nothing about that. An adapter
|
|
45
|
+
* that does not declare `perspectives` ignores the argument and answers with
|
|
46
|
+
* whatever it has, in which case the two are the same thing anyway.
|
|
47
|
+
*
|
|
38
48
|
* Absent when the session was never bootstrapped from the adapter, so treat
|
|
39
49
|
* `undefined` as "no baseline available" and not as "the site was empty" —
|
|
40
50
|
* publishing every field on that assumption is the overwrite this exists to
|
|
41
51
|
* prevent. An integration that embeds its own source snapshot in block props
|
|
42
52
|
* (the more precise approach, since only it knows what it projected) can
|
|
43
53
|
* ignore this.
|
|
54
|
+
*
|
|
55
|
+
* Prefer `baseline`. This field holds the same array and is kept because
|
|
56
|
+
* adapters are written against it.
|
|
57
|
+
*
|
|
58
|
+
* @deprecated Misnamed — read `baseline`.
|
|
44
59
|
*/
|
|
45
60
|
published?: PageDoc[];
|
|
61
|
+
/**
|
|
62
|
+
* The same array as `published`, under the name that says what it holds.
|
|
63
|
+
*
|
|
64
|
+
* The old name taught the opposite of the truth: an integrator reading
|
|
65
|
+
* "published" reasons that the baseline is the live site, concludes the diff
|
|
66
|
+
* would carry colleagues' unpublished work, and designs around a problem the
|
|
67
|
+
* orchestrator does not have — or, worse, does a second read of the published
|
|
68
|
+
* perspective and creates it.
|
|
69
|
+
*/
|
|
70
|
+
baseline?: PageDoc[];
|
|
46
71
|
}
|
|
47
72
|
/**
|
|
48
73
|
* Result of an `onPublish` call. `void` means "success, nothing to report";
|
|
49
74
|
* an explicit `{ ok: false, error }` lets the adapter surface a per-publish
|
|
50
|
-
* error message that the orchestrator returns to the client
|
|
75
|
+
* error message that the orchestrator returns to the client, and `notes` lets
|
|
76
|
+
* a *successful* one say what it did.
|
|
51
77
|
*/
|
|
52
78
|
export type CmsPublishResult = void | {
|
|
53
79
|
ok: true;
|
|
54
80
|
written?: boolean;
|
|
55
81
|
unsupported?: string[];
|
|
82
|
+
notes?: string[];
|
|
56
83
|
} | {
|
|
57
84
|
ok: false;
|
|
58
85
|
error?: string;
|
|
59
86
|
unsupported?: string[];
|
|
87
|
+
notes?: string[];
|
|
60
88
|
};
|
|
61
89
|
/**
|
|
62
90
|
* One asset from a CMS's media library, in the shape the editor's image picker
|
|
@@ -344,6 +344,52 @@ function stripBasePath(pathname, basePath) {
|
|
|
344
344
|
return pathname.slice(basePath.length) || "/";
|
|
345
345
|
return pathname || "/";
|
|
346
346
|
}
|
|
347
|
+
/** One note may not be longer than this, and a publish may not carry more. */
|
|
348
|
+
const PUBLISH_NOTE_MAX_LENGTH = 500;
|
|
349
|
+
const PUBLISH_NOTE_MAX_COUNT = 20;
|
|
350
|
+
/**
|
|
351
|
+
* An adapter's notes are prose from another codebase, and they end up in a
|
|
352
|
+
* SQLite row and in the editor's chat transcript. Keep what is readable and
|
|
353
|
+
* drop the rest rather than trusting the shape.
|
|
354
|
+
*/
|
|
355
|
+
function sanitizePublishNotes(notes) {
|
|
356
|
+
const kept = [];
|
|
357
|
+
for (const note of notes) {
|
|
358
|
+
if (typeof note !== "string")
|
|
359
|
+
continue;
|
|
360
|
+
const trimmed = note.trim();
|
|
361
|
+
if (trimmed === "")
|
|
362
|
+
continue;
|
|
363
|
+
kept.push(trimmed.length > PUBLISH_NOTE_MAX_LENGTH ? `${trimmed.slice(0, PUBLISH_NOTE_MAX_LENGTH - 1)}…` : trimmed);
|
|
364
|
+
if (kept.length === PUBLISH_NOTE_MAX_COUNT)
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
return kept;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The fields the editor reads off a publish response.
|
|
371
|
+
*
|
|
372
|
+
* Library mode answered a successful publish with `{ ok: true, written, count }`
|
|
373
|
+
* and no `status`, and the editor's one check is
|
|
374
|
+
* `data.status !== "triggered" && data.status !== "ready"` — so every
|
|
375
|
+
* successful CMS publish was announced to the person who pressed the button as
|
|
376
|
+
* "Failed to trigger publish." The HTTP status was 200 and the CMS had the
|
|
377
|
+
* writes; only the sentence was wrong, which is the worst way for it to be
|
|
378
|
+
* wrong: it invites a second publish, and then a third.
|
|
379
|
+
*
|
|
380
|
+
* `ok`/`written`/`count` stay exactly as they were — an integration reading
|
|
381
|
+
* them is not disturbed by the additions. This mirrors the shape the
|
|
382
|
+
* site-contract target already returns, which is why the editor can read it.
|
|
383
|
+
*/
|
|
384
|
+
function publishEnvelope(session, slugs, message) {
|
|
385
|
+
return {
|
|
386
|
+
status: "ready",
|
|
387
|
+
...(session ? { session } : {}),
|
|
388
|
+
slugs,
|
|
389
|
+
vercelState: "READY",
|
|
390
|
+
message
|
|
391
|
+
};
|
|
392
|
+
}
|
|
347
393
|
/**
|
|
348
394
|
* Build a Web-standard request handler that wraps the orchestrator brain.
|
|
349
395
|
*
|
|
@@ -902,14 +948,32 @@ export function createOrchestrator(config = {}) {
|
|
|
902
948
|
}
|
|
903
949
|
};
|
|
904
950
|
if (!runtime.adapter?.onPublish) {
|
|
951
|
+
const reason = "adapter has no onPublish; publish is a no-op";
|
|
905
952
|
record(true, "Nothing written — the adapter has no onPublish");
|
|
906
|
-
return jsonResponse({
|
|
953
|
+
return jsonResponse({
|
|
954
|
+
...publishEnvelope(body.session, slugs, reason),
|
|
955
|
+
ok: true,
|
|
956
|
+
written: false,
|
|
957
|
+
count: pages.length,
|
|
958
|
+
reason
|
|
959
|
+
}, { status: 200, cors });
|
|
907
960
|
}
|
|
908
961
|
const config = selection.siteConfig;
|
|
962
|
+
/*
|
|
963
|
+
* `baseline` and `published` are the same array. The second name is what
|
|
964
|
+
* adapters were written against and the first is what it actually holds
|
|
965
|
+
* — the CMS's *draft*, read with `perspective: "draft"` when the adapter
|
|
966
|
+
* declares one, which is the only baseline that makes a publish mean
|
|
967
|
+
* "what this session changed".
|
|
968
|
+
*/
|
|
909
969
|
const context = body.assets || published
|
|
910
|
-
? {
|
|
970
|
+
? {
|
|
971
|
+
...(body.assets ? { assets: body.assets } : {}),
|
|
972
|
+
...(published ? { published, baseline: published } : {})
|
|
973
|
+
}
|
|
911
974
|
: undefined;
|
|
912
975
|
let unsupported = [];
|
|
976
|
+
let notes = [];
|
|
913
977
|
// Default true: an adapter that does not mention `written` means what
|
|
914
978
|
// every adapter written before the field existed meant.
|
|
915
979
|
let written = true;
|
|
@@ -918,6 +982,9 @@ export function createOrchestrator(config = {}) {
|
|
|
918
982
|
if (result && typeof result === "object" && Array.isArray(result.unsupported)) {
|
|
919
983
|
unsupported = result.unsupported;
|
|
920
984
|
}
|
|
985
|
+
if (result && typeof result === "object" && Array.isArray(result.notes)) {
|
|
986
|
+
notes = sanitizePublishNotes(result.notes);
|
|
987
|
+
}
|
|
921
988
|
if (result && typeof result === "object" && result.ok === true && result.written === false) {
|
|
922
989
|
written = false;
|
|
923
990
|
}
|
|
@@ -926,11 +993,13 @@ export function createOrchestrator(config = {}) {
|
|
|
926
993
|
runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
|
|
927
994
|
record(false, message, message);
|
|
928
995
|
return jsonResponse({
|
|
996
|
+
status: "failed",
|
|
929
997
|
ok: false,
|
|
930
998
|
written: false,
|
|
931
999
|
count: pages.length,
|
|
932
1000
|
error: message,
|
|
933
|
-
...(unsupported.length > 0 ? { unsupported } : {})
|
|
1001
|
+
...(unsupported.length > 0 ? { unsupported } : {}),
|
|
1002
|
+
...(notes.length > 0 ? { notes } : {})
|
|
934
1003
|
}, { status: 502, cors });
|
|
935
1004
|
}
|
|
936
1005
|
}
|
|
@@ -949,14 +1018,29 @@ export function createOrchestrator(config = {}) {
|
|
|
949
1018
|
const summary = written
|
|
950
1019
|
? buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false })
|
|
951
1020
|
: `Computed ${pages.length} ${pages.length === 1 ? "page" : "pages"} — nothing written`;
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1021
|
+
/*
|
|
1022
|
+
* The adapter's own sentences go in the log row too. A publish log that
|
|
1023
|
+
* records "Published 12 pages" for a run that wrote nothing because the
|
|
1024
|
+
* queue was paused is worse than no log: it is a record of something
|
|
1025
|
+
* that did not happen.
|
|
1026
|
+
*/
|
|
1027
|
+
const logged = [
|
|
1028
|
+
summary,
|
|
1029
|
+
unsupported.length > 0
|
|
1030
|
+
? `${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
|
|
1031
|
+
: "",
|
|
1032
|
+
...notes
|
|
1033
|
+
]
|
|
1034
|
+
.filter((part) => part !== "")
|
|
1035
|
+
.join(" ");
|
|
1036
|
+
record(true, logged);
|
|
955
1037
|
return jsonResponse({
|
|
1038
|
+
...publishEnvelope(body.session, slugs, logged),
|
|
956
1039
|
ok: true,
|
|
957
1040
|
written,
|
|
958
1041
|
count: pages.length,
|
|
959
|
-
...(unsupported.length > 0 ? { unsupported } : {})
|
|
1042
|
+
...(unsupported.length > 0 ? { unsupported } : {}),
|
|
1043
|
+
...(notes.length > 0 ? { notes } : {})
|
|
960
1044
|
}, { status: 200, cors });
|
|
961
1045
|
}
|
|
962
1046
|
// The editor polls this on boot to populate its model selector and the
|
|
@@ -11,6 +11,25 @@ export declare function readPathValue(root: unknown, path: string): unknown;
|
|
|
11
11
|
* - "items[0].image.alt" → props.items[0].image.src
|
|
12
12
|
*/
|
|
13
13
|
export declare function resolveImageUrlForAltField(blockProps: Record<string, unknown>, editablePath: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Turn whatever the page wrote in an image prop into a URL something can
|
|
16
|
+
* actually fetch, or nothing at all.
|
|
17
|
+
*
|
|
18
|
+
* `resolveImageUrlForAltField` reads its answer straight out of block props, so
|
|
19
|
+
* it is whatever the site put there — and a site most often puts a root-relative
|
|
20
|
+
* path (`/media/pool.webp`), which names a file on the *site* and is not a URL
|
|
21
|
+
* any model, or this process, can resolve on its own. It went to the provider
|
|
22
|
+
* verbatim, and Anthropic answered `400 … Only HTTPS URLs are supported` for the
|
|
23
|
+
* whole request: selecting a hero image and asking an unrelated question failed
|
|
24
|
+
* the turn outright, over the least important thing it was carrying.
|
|
25
|
+
*
|
|
26
|
+
* `previewUrl` is the site's own origin — the same value `/preview/screenshot`
|
|
27
|
+
* resolves a page path against — so a relative path becomes an absolute one the
|
|
28
|
+
* loopback fetch below can turn into base64. A site that never declared one
|
|
29
|
+
* gets `undefined` and loses vision for that turn, which is the correct trade:
|
|
30
|
+
* the alt text is a nicety, the answer is the request.
|
|
31
|
+
*/
|
|
32
|
+
export declare function absolutizeVisionImageUrl(raw: string, previewUrl?: unknown): string | undefined;
|
|
14
33
|
/**
|
|
15
34
|
* Fetch an image URL and return base64 + media type.
|
|
16
35
|
* For localhost URLs the AI APIs can't reach, we fetch locally.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSessionDraft, getRecentEdits, getSiteConfig, orderSlugsHomeFirst } from "../state/session-state.js";
|
|
2
|
+
import { getLibraryMount } from "../handler/library-mount.js";
|
|
2
3
|
import { resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Path traversal
|
|
@@ -58,6 +59,71 @@ export function resolveImageUrlForAltField(blockProps, editablePath) {
|
|
|
58
59
|
const value = readPathValue(blockProps, companionPath);
|
|
59
60
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Turn whatever the page wrote in an image prop into a URL something can
|
|
64
|
+
* actually fetch, or nothing at all.
|
|
65
|
+
*
|
|
66
|
+
* `resolveImageUrlForAltField` reads its answer straight out of block props, so
|
|
67
|
+
* it is whatever the site put there — and a site most often puts a root-relative
|
|
68
|
+
* path (`/media/pool.webp`), which names a file on the *site* and is not a URL
|
|
69
|
+
* any model, or this process, can resolve on its own. It went to the provider
|
|
70
|
+
* verbatim, and Anthropic answered `400 … Only HTTPS URLs are supported` for the
|
|
71
|
+
* whole request: selecting a hero image and asking an unrelated question failed
|
|
72
|
+
* the turn outright, over the least important thing it was carrying.
|
|
73
|
+
*
|
|
74
|
+
* `previewUrl` is the site's own origin — the same value `/preview/screenshot`
|
|
75
|
+
* resolves a page path against — so a relative path becomes an absolute one the
|
|
76
|
+
* loopback fetch below can turn into base64. A site that never declared one
|
|
77
|
+
* gets `undefined` and loses vision for that turn, which is the correct trade:
|
|
78
|
+
* the alt text is a nicety, the answer is the request.
|
|
79
|
+
*/
|
|
80
|
+
export function absolutizeVisionImageUrl(raw, previewUrl) {
|
|
81
|
+
if (!raw)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (/^data:/i.test(raw))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw))
|
|
86
|
+
return raw;
|
|
87
|
+
// Protocol-relative (`//host/x.png`) has an origin already; anything else
|
|
88
|
+
// relative needs one, and only the site can supply it.
|
|
89
|
+
if (raw.startsWith("//"))
|
|
90
|
+
return `https:${raw}`;
|
|
91
|
+
if (typeof previewUrl !== "string" || previewUrl.trim() === "")
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
return new URL(raw, previewUrl.replace(/\/+$/, "") + "/").toString();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The origin a relative image path belongs to: this site's own.
|
|
102
|
+
*
|
|
103
|
+
* Two sources, because neither covers both modes. `previewUrl` is what
|
|
104
|
+
* `/sites/register` writes and what `/preview/screenshot` resolves page paths
|
|
105
|
+
* against — but library mode passes it to `createOrchestrator()` rather than
|
|
106
|
+
* registering it, so it is absent from the config of the very sites whose
|
|
107
|
+
* content is most likely to be relative. The library mount is that site's own
|
|
108
|
+
* address, observed from a request it actually served, so its origin is the
|
|
109
|
+
* site: exactly the base a `/media/...` path is written against.
|
|
110
|
+
*/
|
|
111
|
+
function siteOriginForVision(session) {
|
|
112
|
+
// `previewUrl` is not in `siteConfigSchema` — `/sites/register` merges it in
|
|
113
|
+
// as an unvalidated extra, so it can only be read off the config as unknown.
|
|
114
|
+
const registered = getSiteConfig(session).previewUrl;
|
|
115
|
+
if (typeof registered === "string" && registered.trim() !== "")
|
|
116
|
+
return registered;
|
|
117
|
+
const mount = getLibraryMount();
|
|
118
|
+
if (!mount)
|
|
119
|
+
return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return new URL(mount).origin;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
61
127
|
function isLocalUrl(url) {
|
|
62
128
|
try {
|
|
63
129
|
const parsed = new URL(url);
|
|
@@ -302,6 +368,12 @@ export function plannerContextPack(args) {
|
|
|
302
368
|
const { session, slug, message, currentPage, activeBlockId, activeBlockType, activeEditablePath } = args;
|
|
303
369
|
const pageRoutes = orderSlugsHomeFirst(Array.from(getSessionDraft(session).keys()));
|
|
304
370
|
const siteConfigSummary = summarizeSiteConfigForPlanner(getSiteConfig(session), pageRoutes);
|
|
371
|
+
const rawVisionImageUrl = activeBlockId && activeEditablePath
|
|
372
|
+
? resolveImageUrlForAltField((currentPage.blocks.find((b) => b.id === activeBlockId)?.props ?? {}), activeEditablePath)
|
|
373
|
+
: undefined;
|
|
374
|
+
const visionImageUrl = rawVisionImageUrl
|
|
375
|
+
? absolutizeVisionImageUrl(rawVisionImageUrl, siteOriginForVision(session))
|
|
376
|
+
: undefined;
|
|
305
377
|
const selectedIdx = activeBlockId ? currentPage.blocks.findIndex((b) => b.id === activeBlockId) : -1;
|
|
306
378
|
const neighbors = selectedIdx >= 0
|
|
307
379
|
? {
|
|
@@ -320,9 +392,7 @@ export function plannerContextPack(args) {
|
|
|
320
392
|
blockType: activeBlockType ?? null,
|
|
321
393
|
editablePath: activeEditablePath ?? null,
|
|
322
394
|
block: selectedBlockSnapshot({ currentPage, activeBlockId, activeEditablePath }),
|
|
323
|
-
imageUrlForVision:
|
|
324
|
-
? resolveImageUrlForAltField((currentPage.blocks.find((b) => b.id === activeBlockId)?.props ?? {}), activeEditablePath) ?? null
|
|
325
|
-
: null
|
|
395
|
+
imageUrlForVision: visionImageUrl ?? null
|
|
326
396
|
},
|
|
327
397
|
neighbors: {
|
|
328
398
|
previous: neighbors.previous ? { id: neighbors.previous.id, type: neighbors.previous.type } : null,
|
|
@@ -252,6 +252,27 @@ export declare function publishedPageCountGlobal(): number;
|
|
|
252
252
|
export declare function markRecentlyRestored(session: string): void;
|
|
253
253
|
export declare function consumeRecentlyRestored(session: string): boolean;
|
|
254
254
|
export declare const toErrorDetail: typeof _unifiedToErrorDetail;
|
|
255
|
+
/**
|
|
256
|
+
* Fill in the props Avocado's own Hero and TwoColumn need but a stored page may
|
|
257
|
+
* predate.
|
|
258
|
+
*
|
|
259
|
+
* **Only for blocks Avocado defines.** Both halves are keyed on a type *name*,
|
|
260
|
+
* and a name is all a site's own `Hero` shares with ours. Run them against one
|
|
261
|
+
* and they invent content: a `Hero` whose image lives in `backgroundAsset` is
|
|
262
|
+
* given an `imageUrl` pointing at our placeholder and an English alt string on
|
|
263
|
+
* a German page; a `TwoColumn` with its own `variant` has it rewritten to
|
|
264
|
+
* `"default"`, and its `heading`/`body` are copied into a `left`/`right` pair
|
|
265
|
+
* it does not render. Nothing is lost visibly, because the site renders its own
|
|
266
|
+
* props and ignores the additions — but they land in the draft, the publish
|
|
267
|
+
* baseline is the un-mutated page the adapter returned, so the diff attributes
|
|
268
|
+
* every one of them to the session, and a publish writes them into the CMS.
|
|
269
|
+
* This ran on the CMS seed path, which is where a page nobody edited picks them
|
|
270
|
+
* up.
|
|
271
|
+
*
|
|
272
|
+
* `isBuiltinBlock` is written at registration, so a site that registers over
|
|
273
|
+
* `Hero` turns both halves off for its own blocks and leaves them on for any
|
|
274
|
+
* built-in it still uses.
|
|
275
|
+
*/
|
|
255
276
|
export declare function ensureHeroImageProps(page: PageDoc): void;
|
|
256
277
|
export declare function orderSlugsHomeFirst(slugs: string[]): string[];
|
|
257
278
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER, isBuiltinBlock } from "@avocadostudio-ai/shared";
|
|
4
4
|
import { toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
|
|
5
5
|
import { archiveMigratedJson, getStore, notePersistenceFailure, notePersistenceSuccess, readLegacyJson, resolveDbFile, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
|
|
6
6
|
import { discardPendingProposalsForSession, sweepExpiredProposals } from "../durable/durable-store-singleton.js";
|
|
@@ -255,9 +255,32 @@ export const toErrorDetail = _unifiedToErrorDetail;
|
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
256
|
// Hero image prop guard (needed by getSessionDraft, setPage, applyPersistedState)
|
|
257
257
|
// ---------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Fill in the props Avocado's own Hero and TwoColumn need but a stored page may
|
|
260
|
+
* predate.
|
|
261
|
+
*
|
|
262
|
+
* **Only for blocks Avocado defines.** Both halves are keyed on a type *name*,
|
|
263
|
+
* and a name is all a site's own `Hero` shares with ours. Run them against one
|
|
264
|
+
* and they invent content: a `Hero` whose image lives in `backgroundAsset` is
|
|
265
|
+
* given an `imageUrl` pointing at our placeholder and an English alt string on
|
|
266
|
+
* a German page; a `TwoColumn` with its own `variant` has it rewritten to
|
|
267
|
+
* `"default"`, and its `heading`/`body` are copied into a `left`/`right` pair
|
|
268
|
+
* it does not render. Nothing is lost visibly, because the site renders its own
|
|
269
|
+
* props and ignores the additions — but they land in the draft, the publish
|
|
270
|
+
* baseline is the un-mutated page the adapter returned, so the diff attributes
|
|
271
|
+
* every one of them to the session, and a publish writes them into the CMS.
|
|
272
|
+
* This ran on the CMS seed path, which is where a page nobody edited picks them
|
|
273
|
+
* up.
|
|
274
|
+
*
|
|
275
|
+
* `isBuiltinBlock` is written at registration, so a site that registers over
|
|
276
|
+
* `Hero` turns both halves off for its own blocks and leaves them on for any
|
|
277
|
+
* built-in it still uses.
|
|
278
|
+
*/
|
|
258
279
|
export function ensureHeroImageProps(page) {
|
|
259
280
|
for (const block of page.blocks) {
|
|
260
281
|
const props = block.props;
|
|
282
|
+
if (!isBuiltinBlock(block.type))
|
|
283
|
+
continue;
|
|
261
284
|
if (block.type === "Hero") {
|
|
262
285
|
// Skip imageUrl fallback if the block uses carouselImages instead
|
|
263
286
|
if (typeof props.imageUrl !== "string" || props.imageUrl.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -16,17 +16,29 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
20
19
|
"@anthropic-ai/sdk": "^0.115.0",
|
|
21
20
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
22
21
|
"better-sqlite3": "^12.9.0",
|
|
23
22
|
"openai": "^4.87.1",
|
|
24
23
|
"sharp": "^0.34.5",
|
|
25
24
|
"zod": "^4.3.6",
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "^0.
|
|
27
|
-
"@avocadostudio-ai/shared": "^0.
|
|
25
|
+
"@avocadostudio-ai/migration-sdk": "^0.6.0",
|
|
26
|
+
"@avocadostudio-ai/shared": "^0.6.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"googleapis": "^171.4.0",
|
|
30
|
+
"@google/genai": "^1.46.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"googleapis": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"@google/genai": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
28
39
|
},
|
|
29
40
|
"devDependencies": {
|
|
41
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
30
42
|
"@google/genai": "^1.46.0",
|
|
31
43
|
"@types/better-sqlite3": "^7.6.13",
|
|
32
44
|
"@types/node": "^22.13.10",
|
|
@@ -65,18 +77,6 @@
|
|
|
65
77
|
"url": "https://github.com/avocadostudio-ai/avocado.git",
|
|
66
78
|
"directory": "packages/orchestrator-core"
|
|
67
79
|
},
|
|
68
|
-
"peerDependencies": {
|
|
69
|
-
"googleapis": "^171.4.0",
|
|
70
|
-
"@google/genai": "^1.46.0"
|
|
71
|
-
},
|
|
72
|
-
"peerDependenciesMeta": {
|
|
73
|
-
"googleapis": {
|
|
74
|
-
"optional": true
|
|
75
|
-
},
|
|
76
|
-
"@google/genai": {
|
|
77
|
-
"optional": true
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
80
|
"scripts": {
|
|
81
81
|
"typecheck": "tsc --noEmit",
|
|
82
82
|
"build": "tsc -p tsconfig.build.json && node ./scripts/copy-assets.mjs",
|