@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.
@@ -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(([, fm]) => fm.kind !== "headingLevel") // skip headingLevel always optional noise
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(([, fm]) => fm.kind !== "headingLevel")
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
- const visionBlocks = imageUrlForVision
340
- ? [
341
- imageBase64
342
- ? { type: "image", source: { type: "base64", media_type: imageBase64.mediaType, data: imageBase64.base64 } }
343
- : { type: "image", source: { type: "url", url: imageUrlForVision } }
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]) => isTranslatableKind(fm.kind))
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]) => isTranslatableKind(fm.kind))
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
- if (hallucinatedProps.length > 0) {
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);
@@ -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
@@ -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({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
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
- ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
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
- record(true, unsupported.length > 0
953
- ? `${summary} ${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
954
- : summary);
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: activeBlockId && activeEditablePath
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.5.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.5.0",
27
- "@avocadostudio-ai/shared": "^0.5.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",