@avocadostudio-ai/orchestrator-core 0.5.0 → 0.5.1

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,16 @@ 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
+ * `reference` is dropped rather than annotated. It is a pointer the CMS
49
+ * owns — a story link, an entry link — and a model handed the prop name
50
+ * will eventually write a string into it, which replaces the pointer with a
51
+ * hard-coded URL that stops following renames. A prop that must never be
52
+ * written is not a prop worth naming.
53
+ */
54
+ const writable = ([, fm]) => fm.kind !== "headingLevel" && fm.kind !== "reference";
47
55
  const fieldParts = Object.entries(meta.fields)
48
- .filter(([, fm]) => fm.kind !== "headingLevel") // skip headingLevel always optional noise
56
+ .filter(writable) // headingLevel is always optional noise
49
57
  .map(([key, fm]) => {
50
58
  const req = fm.required ? "" : "?";
51
59
  const opts = fm.kind === "enum" && fm.options ? `(${fm.options.join("|")})` : "";
@@ -54,7 +62,7 @@ export function buildBlockCatalog() {
54
62
  const listParts = meta.listFields
55
63
  ? Object.entries(meta.listFields).map(([listKey, listMeta]) => {
56
64
  const items = Object.entries(listMeta.itemFields)
57
- .filter(([, fm]) => fm.kind !== "headingLevel")
65
+ .filter(writable)
58
66
  .map(([k, fm]) => `${k}${fm.required ? "" : "?"}`);
59
67
  return `${listKey}[]{${items.join(",")}}`;
60
68
  })
@@ -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);
@@ -26,6 +26,26 @@ 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
+ }
29
49
  /**
30
50
  * Look up the current block by id across all pages in the draft. Preferring
31
51
  * the targeted page when supplied avoids cross-page collisions on id reuse.
@@ -78,6 +98,7 @@ function humanBlockName(blockType) {
78
98
  export function validateAndStripHallucinatedProps(args) {
79
99
  const { plan, draft } = args;
80
100
  const hallucinatedProps = [];
101
+ const referenceProps = [];
81
102
  for (const op of plan.ops) {
82
103
  if (op.op !== "update_props")
83
104
  continue;
@@ -93,7 +114,13 @@ export function validateAndStripHallucinatedProps(args) {
93
114
  const patchCandidate = rawPatch.props && typeof rawPatch.props === "object" && !Array.isArray(rawPatch.props)
94
115
  ? rawPatch.props
95
116
  : rawPatch;
117
+ const referenceKeys = referencePropKeysForBlockType(blockType);
96
118
  for (const key of Object.keys(patchCandidate)) {
119
+ if (referenceKeys.has(key)) {
120
+ delete patchCandidate[key];
121
+ referenceProps.push({ blockType, propName: key });
122
+ continue;
123
+ }
97
124
  if (allowedKeys.has(key))
98
125
  continue;
99
126
  delete patchCandidate[key];
@@ -105,7 +132,28 @@ export function validateAndStripHallucinatedProps(args) {
105
132
  });
106
133
  }
107
134
  }
108
- if (hallucinatedProps.length > 0) {
135
+ /*
136
+ * Said separately from the hallucinated-prop note below, because it is a
137
+ * different fact about a different kind of field. "The block has no such
138
+ * field" is false here — the field exists, it is on the page, and the person
139
+ * can see what it points at. What they cannot do is change it from here.
140
+ */
141
+ const referenceNoteParts = [];
142
+ if (referenceProps.length > 0) {
143
+ const byBlock = new Map();
144
+ for (const entry of referenceProps) {
145
+ const bucket = byBlock.get(entry.blockType) ?? new Set();
146
+ bucket.add(entry.propName);
147
+ byBlock.set(entry.blockType, bucket);
148
+ }
149
+ for (const [blockType, props] of byBlock) {
150
+ const name = humanBlockName(blockType);
151
+ for (const prop of props) {
152
+ referenceNoteParts.push(`“${prop}” on the ${name} block points at a document in your CMS, so it is changed there rather than here.`);
153
+ }
154
+ }
155
+ }
156
+ if (hallucinatedProps.length > 0 || referenceNoteParts.length > 0) {
109
157
  /*
110
158
  * Two different events used to share one sentence, and the wrong one was
111
159
  * the default.
@@ -130,7 +178,7 @@ export function validateAndStripHallucinatedProps(args) {
130
178
  bucket.add(entry.propName);
131
179
  byBlockType.set(entry.blockType, bucket);
132
180
  }
133
- const noteParts = [];
181
+ const noteParts = [...referenceNoteParts];
134
182
  for (const [blockType, props] of byBlockType) {
135
183
  const name = humanBlockName(blockType);
136
184
  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,14 +35,39 @@ 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";
@@ -906,8 +906,18 @@ export function createOrchestrator(config = {}) {
906
906
  return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
907
907
  }
908
908
  const config = selection.siteConfig;
909
+ /*
910
+ * `baseline` and `published` are the same array. The second name is what
911
+ * adapters were written against and the first is what it actually holds
912
+ * — the CMS's *draft*, read with `perspective: "draft"` when the adapter
913
+ * declares one, which is the only baseline that makes a publish mean
914
+ * "what this session changed".
915
+ */
909
916
  const context = body.assets || published
910
- ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
917
+ ? {
918
+ ...(body.assets ? { assets: body.assets } : {}),
919
+ ...(published ? { published, baseline: published } : {})
920
+ }
911
921
  : undefined;
912
922
  let unsupported = [];
913
923
  // Default true: an adapter that does not mention `written` means what
@@ -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.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -23,8 +23,8 @@
23
23
  "openai": "^4.87.1",
24
24
  "sharp": "^0.34.5",
25
25
  "zod": "^4.3.6",
26
- "@avocadostudio-ai/migration-sdk": "^0.5.0",
27
- "@avocadostudio-ai/shared": "^0.5.0"
26
+ "@avocadostudio-ai/migration-sdk": "^0.5.1",
27
+ "@avocadostudio-ai/shared": "^0.5.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",