@avocadostudio-ai/orchestrator-core 0.5.1 → 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.
@@ -45,13 +45,18 @@ export function buildBlockCatalog() {
45
45
  const guide = BLOCK_VISUAL_GUIDE[type];
46
46
  // Compact: Type — description | props | list fields
47
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
+ *
48
53
  * `reference` is dropped rather than annotated. It is a pointer the CMS
49
54
  * owns — a story link, an entry link — and a model handed the prop name
50
55
  * will eventually write a string into it, which replaces the pointer with a
51
56
  * hard-coded URL that stops following renames. A prop that must never be
52
57
  * written is not a prop worth naming.
53
58
  */
54
- const writable = ([, fm]) => fm.kind !== "headingLevel" && fm.kind !== "reference";
59
+ const writable = ([, fm]) => fm.kind !== "headingLevel" && fm.kind !== "reference" && !fm.internal;
55
60
  const fieldParts = Object.entries(meta.fields)
56
61
  .filter(writable) // headingLevel is always optional noise
57
62
  .map(([key, fm]) => {
@@ -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);
@@ -46,6 +46,25 @@ function referencePropKeysForBlockType(blockType) {
46
46
  }
47
47
  return keys;
48
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
+ }
49
68
  /**
50
69
  * Look up the current block by id across all pages in the draft. Preferring
51
70
  * the targeted page when supplied avoids cross-page collisions on id reuse.
@@ -99,6 +118,7 @@ export function validateAndStripHallucinatedProps(args) {
99
118
  const { plan, draft } = args;
100
119
  const hallucinatedProps = [];
101
120
  const referenceProps = [];
121
+ const internalProps = [];
102
122
  for (const op of plan.ops) {
103
123
  if (op.op !== "update_props")
104
124
  continue;
@@ -115,12 +135,18 @@ export function validateAndStripHallucinatedProps(args) {
115
135
  ? rawPatch.props
116
136
  : rawPatch;
117
137
  const referenceKeys = referencePropKeysForBlockType(blockType);
138
+ const internalKeys = internalPropKeysForBlockType(blockType);
118
139
  for (const key of Object.keys(patchCandidate)) {
119
140
  if (referenceKeys.has(key)) {
120
141
  delete patchCandidate[key];
121
142
  referenceProps.push({ blockType, propName: key });
122
143
  continue;
123
144
  }
145
+ if (internalKeys.has(key)) {
146
+ delete patchCandidate[key];
147
+ internalProps.push({ blockType, propName: key });
148
+ continue;
149
+ }
124
150
  if (allowedKeys.has(key))
125
151
  continue;
126
152
  delete patchCandidate[key];
@@ -153,7 +179,28 @@ export function validateAndStripHallucinatedProps(args) {
153
179
  }
154
180
  }
155
181
  }
156
- if (hallucinatedProps.length > 0 || referenceNoteParts.length > 0) {
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) {
157
204
  /*
158
205
  * Two different events used to share one sentence, and the wrong one was
159
206
  * the default.
@@ -178,7 +225,7 @@ export function validateAndStripHallucinatedProps(args) {
178
225
  bucket.add(entry.propName);
179
226
  byBlockType.set(entry.blockType, bucket);
180
227
  }
181
- const noteParts = [...referenceNoteParts];
228
+ const noteParts = [...referenceNoteParts, ...internalNoteParts];
182
229
  for (const [blockType, props] of byBlockType) {
183
230
  const name = humanBlockName(blockType);
184
231
  const visual = [...props].filter(isVisualPropName);
@@ -72,16 +72,19 @@ export interface CmsPublishContext {
72
72
  /**
73
73
  * Result of an `onPublish` call. `void` means "success, nothing to report";
74
74
  * an explicit `{ ok: false, error }` lets the adapter surface a per-publish
75
- * 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.
76
77
  */
77
78
  export type CmsPublishResult = void | {
78
79
  ok: true;
79
80
  written?: boolean;
80
81
  unsupported?: string[];
82
+ notes?: string[];
81
83
  } | {
82
84
  ok: false;
83
85
  error?: string;
84
86
  unsupported?: string[];
87
+ notes?: string[];
85
88
  };
86
89
  /**
87
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,8 +948,15 @@ 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;
909
962
  /*
@@ -920,6 +973,7 @@ export function createOrchestrator(config = {}) {
920
973
  }
921
974
  : undefined;
922
975
  let unsupported = [];
976
+ let notes = [];
923
977
  // Default true: an adapter that does not mention `written` means what
924
978
  // every adapter written before the field existed meant.
925
979
  let written = true;
@@ -928,6 +982,9 @@ export function createOrchestrator(config = {}) {
928
982
  if (result && typeof result === "object" && Array.isArray(result.unsupported)) {
929
983
  unsupported = result.unsupported;
930
984
  }
985
+ if (result && typeof result === "object" && Array.isArray(result.notes)) {
986
+ notes = sanitizePublishNotes(result.notes);
987
+ }
931
988
  if (result && typeof result === "object" && result.ok === true && result.written === false) {
932
989
  written = false;
933
990
  }
@@ -936,11 +993,13 @@ export function createOrchestrator(config = {}) {
936
993
  runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
937
994
  record(false, message, message);
938
995
  return jsonResponse({
996
+ status: "failed",
939
997
  ok: false,
940
998
  written: false,
941
999
  count: pages.length,
942
1000
  error: message,
943
- ...(unsupported.length > 0 ? { unsupported } : {})
1001
+ ...(unsupported.length > 0 ? { unsupported } : {}),
1002
+ ...(notes.length > 0 ? { notes } : {})
944
1003
  }, { status: 502, cors });
945
1004
  }
946
1005
  }
@@ -959,14 +1018,29 @@ export function createOrchestrator(config = {}) {
959
1018
  const summary = written
960
1019
  ? buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false })
961
1020
  : `Computed ${pages.length} ${pages.length === 1 ? "page" : "pages"} — nothing written`;
962
- record(true, unsupported.length > 0
963
- ? `${summary} ${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
964
- : 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);
965
1037
  return jsonResponse({
1038
+ ...publishEnvelope(body.session, slugs, logged),
966
1039
  ok: true,
967
1040
  written,
968
1041
  count: pages.length,
969
- ...(unsupported.length > 0 ? { unsupported } : {})
1042
+ ...(unsupported.length > 0 ? { unsupported } : {}),
1043
+ ...(notes.length > 0 ? { notes } : {})
970
1044
  }, { status: 200, cors });
971
1045
  }
972
1046
  // The editor polls this on boot to populate its model selector and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.5.1",
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.1",
27
- "@avocadostudio-ai/shared": "^0.5.1"
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",