@avocadostudio-ai/orchestrator-core 0.3.1 → 0.3.2

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.
@@ -1171,6 +1171,7 @@ export async function runChatPipeline(ctx, body, options) {
1171
1171
  blockId: entry.blockId,
1172
1172
  blockType: entry.blockType,
1173
1173
  propName: entry.propName,
1174
+ allowedProps: entry.allowedProps,
1174
1175
  plannerSource: source,
1175
1176
  modelKey,
1176
1177
  modelUsed
@@ -15,6 +15,12 @@ export type HallucinatedProp = {
15
15
  blockId: string;
16
16
  blockType: string;
17
17
  propName: string;
18
+ /**
19
+ * What the block *does* accept. Present so the log line that records a strip
20
+ * carries the answer next to the question — the integrator who hit this spent
21
+ * nine minutes proving the block was fine, and this is the line they read.
22
+ */
23
+ allowedProps: string[];
18
24
  };
19
25
  export type HallucinationValidationResult = {
20
26
  plan: EditPlan;
@@ -45,6 +45,22 @@ function findBlockType(args) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
+ /*
49
+ * Does this key read as a visual/presentational one?
50
+ *
51
+ * Deliberately a small allow-list of stems rather than a clever rule: the
52
+ * default has to be "content", because the expensive mistake is telling
53
+ * somebody their content edit was a styling limitation, not the reverse.
54
+ */
55
+ const VISUAL_PROP_STEMS = [
56
+ "color", "colour", "background", "gradient", "animation", "animate", "shadow",
57
+ "font", "size", "spacing", "padding", "margin", "border", "radius", "opacity",
58
+ "align", "theme", "style", "variant", "width", "height", "position", "layout"
59
+ ];
60
+ function isVisualPropName(prop) {
61
+ const lower = prop.toLowerCase();
62
+ return VISUAL_PROP_STEMS.some((stem) => lower.includes(stem));
63
+ }
48
64
  function humanBlockName(blockType) {
49
65
  const meta = getBlockMeta(blockType);
50
66
  return meta?.displayName ?? blockType;
@@ -81,15 +97,33 @@ export function validateAndStripHallucinatedProps(args) {
81
97
  if (allowedKeys.has(key))
82
98
  continue;
83
99
  delete patchCandidate[key];
84
- hallucinatedProps.push({ blockId: op.blockId, blockType, propName: key });
100
+ hallucinatedProps.push({
101
+ blockId: op.blockId,
102
+ blockType,
103
+ propName: key,
104
+ allowedProps: [...allowedKeys].sort()
105
+ });
85
106
  }
86
107
  }
87
108
  if (hallucinatedProps.length > 0) {
88
- // Merge duplicates into a single readable note keyed by blockType. We
89
- // intentionally avoid echoing the raw prop name back to the user
90
- // doing so is (a) jargon-y (users don't think in prop keys), and (b)
91
- // makes the note trivially collide with eval banned-word checks that
92
- // try to prove the planner didn't promise the unsupported behavior.
109
+ /*
110
+ * Two different events used to share one sentence, and the wrong one was
111
+ * the default.
112
+ *
113
+ * "Some requested styling isn't available" is true when the planner
114
+ * promised a colour, a gradient or an animation the block has no field
115
+ * for. It is false — and actively misleading — when the planner simply
116
+ * used the wrong *name* for a field the block does have under another
117
+ * name: nothing about that is styling, and the sentence sends the reader
118
+ * to look at their design system. An integrator lost nine minutes to
119
+ * exactly that, on a block whose schema, registry and ops path were all
120
+ * correct.
121
+ *
122
+ * So classify the stripped key. A visual key keeps the original wording,
123
+ * because that is the case it was written for and the evals that check it
124
+ * are checking that case. A content key gets a sentence that names it,
125
+ * which is the one piece of information that ends the search.
126
+ */
93
127
  const byBlockType = new Map();
94
128
  for (const entry of hallucinatedProps) {
95
129
  const bucket = byBlockType.get(entry.blockType) ?? new Set();
@@ -97,9 +131,16 @@ export function validateAndStripHallucinatedProps(args) {
97
131
  byBlockType.set(entry.blockType, bucket);
98
132
  }
99
133
  const noteParts = [];
100
- for (const [blockType] of byBlockType) {
134
+ for (const [blockType, props] of byBlockType) {
101
135
  const name = humanBlockName(blockType);
102
- noteParts.push(`Some requested styling isn't available on the ${name} block — applied the supported parts.`);
136
+ const visual = [...props].filter(isVisualPropName);
137
+ const content = [...props].filter((prop) => !isVisualPropName(prop));
138
+ if (visual.length > 0) {
139
+ noteParts.push(`Some requested styling isn't available on the ${name} block — applied the supported parts.`);
140
+ }
141
+ for (const prop of content) {
142
+ noteParts.push(`The ${name} block has no “${prop}” field, so that part wasn't applied.`);
143
+ }
103
144
  }
104
145
  const note = noteParts.join(" ");
105
146
  const summary = plan.summary_for_user?.trimEnd() ?? "";
@@ -51,6 +51,7 @@ export interface CmsPublishContext {
51
51
  */
52
52
  export type CmsPublishResult = void | {
53
53
  ok: true;
54
+ written?: boolean;
54
55
  unsupported?: string[];
55
56
  } | {
56
57
  ok: false;
@@ -771,20 +771,31 @@ export function createOrchestrator(config = {}) {
771
771
  *
772
772
  * This is the copy the bootstrap already took, not a fresh read — a
773
773
  * second `getPages()` here is 45 sequential Sanity calls on the
774
- * integration that motivated it. It is therefore absent after a restart
775
- * that reloaded the draft from SQLite, which is why the contract says to
776
- * treat undefined as "no baseline" and never as "the site was empty".
774
+ * integration that motivated it. It used to be absent for the rest of a
775
+ * process's life after a restart that reloaded the draft from SQLite,
776
+ * which quietly disabled publishing for any adapter that refuses without
777
+ * a baseline; `ensure` now recovers it on the first request instead.
778
+ *
779
+ * It can still be undefined — the adapter read can fail, and baselines
780
+ * are evicted FIFO — so the contract is unchanged: treat undefined as
781
+ * "no baseline available" and never as "the site was empty".
777
782
  */
778
783
  const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
779
784
  const context = body.assets || published
780
785
  ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
781
786
  : undefined;
782
787
  let unsupported = [];
788
+ // Default true: an adapter that does not mention `written` means what
789
+ // every adapter written before the field existed meant.
790
+ let written = true;
783
791
  try {
784
792
  const result = await runtime.adapter.onPublish(pages, config, context);
785
793
  if (result && typeof result === "object" && Array.isArray(result.unsupported)) {
786
794
  unsupported = result.unsupported;
787
795
  }
796
+ if (result && typeof result === "object" && result.ok === true && result.written === false) {
797
+ written = false;
798
+ }
788
799
  if (result && typeof result === "object" && result.ok === false) {
789
800
  const message = result.error ?? "adapter.onPublish returned not-ok";
790
801
  runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
@@ -810,13 +821,15 @@ export function createOrchestrator(config = {}) {
810
821
  * image beside it — that is neither a plain success, which claims the
811
822
  * whole edit shipped, nor an error, which claims none of it did.
812
823
  */
813
- const summary = buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false });
824
+ const summary = written
825
+ ? buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false })
826
+ : `Computed ${pages.length} ${pages.length === 1 ? "page" : "pages"} — nothing written`;
814
827
  record(true, unsupported.length > 0
815
828
  ? `${summary} ${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
816
829
  : summary);
817
830
  return jsonResponse({
818
831
  ok: true,
819
- written: true,
832
+ written,
820
833
  count: pages.length,
821
834
  ...(unsupported.length > 0 ? { unsupported } : {})
822
835
  }, { status: 200, cors });
@@ -31,7 +31,7 @@ export function promptFromPropKey(propKey, blockType) {
31
31
  return CLARIFICATION_LABELS[propKey];
32
32
  const human = blockType ? getPropDisplayName(blockType, propKey) : propKey;
33
33
  // Fall back to the registry-declared label so host-app overrides (e.g.
34
- // villa's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
34
+ // a host site's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
35
35
  // than leaking the raw camelCase key into the UI.
36
36
  return human === propKey ? `Edit ${propKey}` : `Edit ${human.toLowerCase()}`;
37
37
  }
@@ -1,6 +1,135 @@
1
- import { allowedBlockTypes, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
1
+ import { allowedBlockTypes, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType, getBlockMeta } from "@avocadostudio-ai/shared";
2
2
  import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
3
  // ---------------------------------------------------------------------------
4
+ // Prop-name aliasing, asked of the registry rather than of a literal
5
+ // ---------------------------------------------------------------------------
6
+ /*
7
+ * Models confuse Avocado's own prop names — `heading` for a section that calls
8
+ * it `title`, `question`/`answer` for FAQ items that call them `q`/`a`. The
9
+ * aliases below exist to repair that, and they are worth keeping.
10
+ *
11
+ * They were applied by comparing the block type against the literal string
12
+ * "Hero", which is a name only Avocado's own catalogue has. Every other block
13
+ * in the world is `!== "Hero"`, so a site that brings its own blocks and names
14
+ * a text prop `heading` had that prop deleted here and replaced with `title` —
15
+ * a prop its schema does not have. The ops engine then stripped `title` as a
16
+ * hallucinated prop, the edit vanished, and the user was told that "some
17
+ * requested styling isn't available" on their block. Nothing in the chain was
18
+ * wrong about its own job; the first link was answering a question about a name
19
+ * instead of about a schema.
20
+ *
21
+ * So ask the registry. A rename now requires positive evidence in both
22
+ * directions: the block cannot take the key the planner used, and can take the
23
+ * one we would rewrite it to. Every other case — unknown block type, a block
24
+ * that accepts both, a block that accepts neither — leaves the value alone,
25
+ * which is the answer that loses no data.
26
+ */
27
+ function blockAcceptsProp(blockType, prop) {
28
+ if (!blockType)
29
+ return false;
30
+ const meta = getBlockMeta(blockType);
31
+ if (meta?.fields && prop in meta.fields)
32
+ return true;
33
+ // Manifest-registered blocks may carry a schema richer than their derived
34
+ // meta, so the schema gets the second look rather than the first refusal.
35
+ const shape = blockSchemas[blockType]?.shape;
36
+ return Boolean(shape && prop in shape);
37
+ }
38
+ /*
39
+ * Avocado's own `autoplay` / `loop` / `striped` are string enums ("true" /
40
+ * "false"), not booleans, so a model that emits a real boolean has to be
41
+ * coerced. That coercion ran on every block type, and a custom block whose
42
+ * `loop` is a genuine `z.boolean()` had the string `"true"` written into it —
43
+ * silently, and only on the chat path, so it looked like a CMS problem.
44
+ *
45
+ * Coerce only where the block actually declares the prop as a string enum.
46
+ */
47
+ const BOOLEAN_ENUM_PROPS = ["autoplay", "loop", "striped"];
48
+ /**
49
+ * Ask the block's own schema, which is the only thing that actually knows:
50
+ * rewrite a boolean to `"true"`/`"false"` exactly when the schema rejects the
51
+ * boolean and accepts the string. A block that takes a real boolean keeps it,
52
+ * a block that takes either keeps what the planner sent, and an unregistered
53
+ * type is left alone.
54
+ */
55
+ function coerceBooleanEnumProps(blockType, props) {
56
+ const shape = blockSchemas[blockType]?.shape;
57
+ if (!shape)
58
+ return;
59
+ for (const key of BOOLEAN_ENUM_PROPS) {
60
+ const value = props[key];
61
+ if (typeof value !== "boolean")
62
+ continue;
63
+ const field = shape[key];
64
+ if (typeof field?.safeParse !== "function")
65
+ continue;
66
+ const asString = value ? "true" : "false";
67
+ /*
68
+ * Round-trip, not merely "parses". Avocado's own `autoplay` is
69
+ * `z.enum(["true","false"]).default("false").catch("false")`, and `.catch`
70
+ * means a boolean parses *successfully* — into the wrong value. Accepting
71
+ * that as "the block wants a boolean" would leave the silent wrong answer
72
+ * in place. A schema genuinely wants a boolean only when it gives the
73
+ * boolean back unchanged.
74
+ */
75
+ const asBool = field.safeParse(value);
76
+ if (asBool.success && asBool.data === value)
77
+ continue;
78
+ const asStr = field.safeParse(asString);
79
+ if (!asStr.success || asStr.data !== asString)
80
+ continue;
81
+ props[key] = asString;
82
+ }
83
+ }
84
+ /** Rename `from`→`to` only when the block demonstrably wants `to` and not `from`. */
85
+ function shouldAliasProp(blockType, from, to) {
86
+ return !blockAcceptsProp(blockType, from) && blockAcceptsProp(blockType, to);
87
+ }
88
+ /*
89
+ * The same question for a key inside a list item. `listFields[key].itemFields`
90
+ * is the declared shape; a block with no declared list metadata answers "no"
91
+ * to both halves and is therefore left alone.
92
+ */
93
+ function listItemAcceptsKey(blockType, listKey, itemKey) {
94
+ if (!blockType)
95
+ return false;
96
+ const itemFields = getBlockMeta(blockType)?.listFields?.[listKey]?.itemFields;
97
+ return Boolean(itemFields && itemKey in itemFields);
98
+ }
99
+ function shouldAliasItemKey(blockType, listKey, from, to) {
100
+ return (!listItemAcceptsKey(blockType, listKey, from) && listItemAcceptsKey(blockType, listKey, to));
101
+ }
102
+ const ITEM_KEY_ALIASES = {
103
+ question: "q",
104
+ answer: "a",
105
+ testimonial: "quote",
106
+ review: "quote"
107
+ };
108
+ /**
109
+ * Apply the list-item aliases to one array prop, block-type aware.
110
+ * Shared by the `update_props` and `add_block` paths so they cannot drift.
111
+ */
112
+ function aliasListItems(blockType, listKey, value) {
113
+ return value.map((item) => {
114
+ if (!item || typeof item !== "object" || Array.isArray(item))
115
+ return item;
116
+ const entry = item;
117
+ let changed = false;
118
+ const mapped = {};
119
+ for (const [k, v] of Object.entries(entry)) {
120
+ const alias = ITEM_KEY_ALIASES[k.toLowerCase()];
121
+ if (alias && !(alias in entry) && shouldAliasItemKey(blockType, listKey, k.toLowerCase(), alias)) {
122
+ mapped[alias] = v;
123
+ changed = true;
124
+ }
125
+ else {
126
+ mapped[k] = v;
127
+ }
128
+ }
129
+ return changed ? mapped : entry;
130
+ });
131
+ }
132
+ // ---------------------------------------------------------------------------
4
133
  // Op reordering: create_page must precede ops targeting the same slug
5
134
  // ---------------------------------------------------------------------------
6
135
  function reorderCreatePageFirst(ops) {
@@ -886,38 +1015,17 @@ export function normalizePlanCandidate(input, args) {
886
1015
  const patch = raw.patch;
887
1016
  const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
888
1017
  const blockType = targetBlock?.type ?? "";
889
- if (blockType !== "Hero" && "heading" in patch && !("title" in patch)) {
1018
+ if ("heading" in patch && !("title" in patch) && shouldAliasProp(blockType, "heading", "title")) {
890
1019
  patch.title = patch.heading;
891
1020
  delete patch.heading;
892
1021
  }
893
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
894
1022
  for (const [propKey, propVal] of Object.entries(patch)) {
895
1023
  if (!Array.isArray(propVal))
896
1024
  continue;
897
- patch[propKey] = propVal.map((item) => {
898
- if (!item || typeof item !== "object" || Array.isArray(item))
899
- return item;
900
- const entry = item;
901
- let changed = false;
902
- const mapped = {};
903
- for (const [k, v] of Object.entries(entry)) {
904
- const alias = itemKeyAliases[k.toLowerCase()];
905
- if (alias && !(alias in entry)) {
906
- mapped[alias] = v;
907
- changed = true;
908
- }
909
- else {
910
- mapped[k] = v;
911
- }
912
- }
913
- return changed ? mapped : entry;
914
- });
1025
+ patch[propKey] = aliasListItems(blockType, propKey, propVal);
915
1026
  }
916
1027
  // Block-specific prop key remapping and type coercion (mirrors add_block path)
917
- for (const k of ["autoplay", "loop", "striped"]) {
918
- if (typeof patch[k] === "boolean")
919
- patch[k] = patch[k] ? "true" : "false";
920
- }
1028
+ coerceBooleanEnumProps(blockType, patch);
921
1029
  if (blockType === "Carousel" && Array.isArray(patch.slides) && !patch.items) {
922
1030
  patch.items = patch.slides;
923
1031
  delete patch.slides;
@@ -1153,36 +1261,20 @@ export function normalizePlanCandidate(input, args) {
1153
1261
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1154
1262
  const bProps = block.props;
1155
1263
  const blockType = typeof block.type === "string" ? block.type : "";
1156
- if (blockType !== "Hero" && "heading" in bProps && !("title" in bProps)) {
1264
+ if ("heading" in bProps && !("title" in bProps) && shouldAliasProp(blockType, "heading", "title")) {
1157
1265
  bProps.title = bProps.heading;
1158
1266
  delete bProps.heading;
1159
1267
  }
1160
1268
  }
1161
- // Remap list item keys inside add_block props (e.g., question→q, answer→a)
1269
+ // Remap list item keys inside add_block props (e.g., question→q, answer→a),
1270
+ // asking the block's own list metadata rather than rewriting every array.
1162
1271
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1163
1272
  const bProps = block.props;
1164
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
1273
+ const blockType = typeof block.type === "string" ? block.type : "";
1165
1274
  for (const [propKey, propVal] of Object.entries(bProps)) {
1166
1275
  if (!Array.isArray(propVal))
1167
1276
  continue;
1168
- bProps[propKey] = propVal.map((item) => {
1169
- if (!item || typeof item !== "object" || Array.isArray(item))
1170
- return item;
1171
- const entry = item;
1172
- let changed = false;
1173
- const mapped = {};
1174
- for (const [k, v] of Object.entries(entry)) {
1175
- const alias = itemKeyAliases[k.toLowerCase()];
1176
- if (alias && !(alias in entry)) {
1177
- mapped[alias] = v;
1178
- changed = true;
1179
- }
1180
- else {
1181
- mapped[k] = v;
1182
- }
1183
- }
1184
- return changed ? mapped : entry;
1185
- });
1277
+ bProps[propKey] = aliasListItems(blockType, propKey, propVal);
1186
1278
  }
1187
1279
  }
1188
1280
  // Block-specific prop key remapping and type coercion
@@ -1190,10 +1282,7 @@ export function normalizePlanCandidate(input, args) {
1190
1282
  const cProps = block.props;
1191
1283
  const bt = block.type;
1192
1284
  // Coerce boolean→string for "true"/"false" enum props (Carousel, Video, Table)
1193
- for (const k of ["autoplay", "loop", "striped"]) {
1194
- if (typeof cProps[k] === "boolean")
1195
- cProps[k] = cProps[k] ? "true" : "false";
1196
- }
1285
+ coerceBooleanEnumProps(typeof bt === "string" ? bt : "", cProps);
1197
1286
  // Carousel: slides→items
1198
1287
  if (bt === "Carousel" && Array.isArray(cProps.slides) && !cProps.items) {
1199
1288
  cProps.items = cProps.slides;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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.3.1",
27
- "@avocadostudio-ai/shared": "^0.3.1"
26
+ "@avocadostudio-ai/migration-sdk": "^0.3.2",
27
+ "@avocadostudio-ai/shared": "^0.3.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",