@avocadostudio-ai/orchestrator-core 0.11.4 → 0.11.6

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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { keyFileHint } from "../env-file-location.js";
2
3
  import { blockManifestSchema } from "@avocadostudio-ai/shared";
3
4
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
4
5
  import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
@@ -2997,7 +2998,7 @@ export async function runChatPipeline(ctx, body, options) {
2997
2998
  markPlanningStart();
2998
2999
  try {
2999
3000
  emitStatusTone("planning");
3000
- const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
3001
+ const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType), keyFileHint());
3001
3002
  markPlanningFinish();
3002
3003
  const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
3003
3004
  if (outcome.done)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve the absolute path of the file to add the key to.
3
+ *
4
+ * Walks up from `cwd` for a directory that already holds a `package.json`,
5
+ * taking an existing env file there if there is one and naming the file that
6
+ * *should* exist if there is not. Returns the caller's best guess rather than
7
+ * nothing: a path that does not exist yet is still the right answer to "where
8
+ * do I put this", and creating it is the instruction.
9
+ */
10
+ export declare function resolveEnvFilePath(cwd?: string): string;
11
+ /**
12
+ * The path to show a user, or `undefined` when showing one would be wrong.
13
+ *
14
+ * Withheld in production for two reasons, and the second is the one that
15
+ * matters. It would put a server filesystem path into a browser — mildly
16
+ * careless on its own. But the advice attached to it ("add it to this file and
17
+ * restart the dev server") is also simply **false** in production: there is no
18
+ * dev server, the file is not deployed, and a key added to it changes nothing.
19
+ * A precise path attached to instructions that cannot work is worse than the
20
+ * vague version, because it reads as authoritative.
21
+ */
22
+ export declare function keyFileHint(env?: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Where the user should actually put their API key.
3
+ *
4
+ * Every piece of copy in this product tells someone to "add a key to
5
+ * `.env.local` in your project root — the same folder as `package.json`", and
6
+ * none of it says *which* folder that is. The editor runs on its own origin,
7
+ * usually started by a script the user did not write, from a directory they may
8
+ * not have chosen; the terminal they are reading is running two processes; and
9
+ * the instruction arrives inside a browser tab that has no filesystem at all.
10
+ * "Your project root" is a description, not an address, and the person reading
11
+ * it is precisely the person who does not yet know the layout.
12
+ *
13
+ * The server does know. It is running in that directory. So it says so.
14
+ *
15
+ * Only in development, deliberately — see `keyFileHint`.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ /**
20
+ * The env files a key could live in, in the order the reader should prefer.
21
+ *
22
+ * `.env.local` first because that is what every doc and the scaffolder name,
23
+ * and because it is the one git ignores. `.env` second so an existing project
24
+ * that already keeps its secrets there is told to use the file it has rather
25
+ * than a second one that will silently take precedence.
26
+ */
27
+ const CANDIDATES = [".env.local", ".env"];
28
+ /** How far up to look before giving up. A monorepo puts the app a few levels down. */
29
+ const MAX_DEPTH = 5;
30
+ /**
31
+ * Resolve the absolute path of the file to add the key to.
32
+ *
33
+ * Walks up from `cwd` for a directory that already holds a `package.json`,
34
+ * taking an existing env file there if there is one and naming the file that
35
+ * *should* exist if there is not. Returns the caller's best guess rather than
36
+ * nothing: a path that does not exist yet is still the right answer to "where
37
+ * do I put this", and creating it is the instruction.
38
+ */
39
+ export function resolveEnvFilePath(cwd = process.cwd()) {
40
+ let dir = resolve(cwd);
41
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
42
+ for (const candidate of CANDIDATES) {
43
+ if (existsSync(join(dir, candidate)))
44
+ return join(dir, candidate);
45
+ }
46
+ // No env file here, but if this is the project root then this is where one goes.
47
+ if (existsSync(join(dir, "package.json")))
48
+ return join(dir, CANDIDATES[0]);
49
+ const parent = dirname(dir);
50
+ if (parent === dir)
51
+ break;
52
+ dir = parent;
53
+ }
54
+ return join(resolve(cwd), CANDIDATES[0]);
55
+ }
56
+ /**
57
+ * The path to show a user, or `undefined` when showing one would be wrong.
58
+ *
59
+ * Withheld in production for two reasons, and the second is the one that
60
+ * matters. It would put a server filesystem path into a browser — mildly
61
+ * careless on its own. But the advice attached to it ("add it to this file and
62
+ * restart the dev server") is also simply **false** in production: there is no
63
+ * dev server, the file is not deployed, and a key added to it changes nothing.
64
+ * A precise path attached to instructions that cannot work is worse than the
65
+ * vague version, because it reads as authoritative.
66
+ */
67
+ export function keyFileHint(env = process.env) {
68
+ if ((env.NODE_ENV ?? "").trim() === "production")
69
+ return undefined;
70
+ try {
71
+ return resolveEnvFilePath();
72
+ }
73
+ catch {
74
+ // A sandboxed or read-only filesystem is not a reason to fail a chat turn.
75
+ return undefined;
76
+ }
77
+ }
@@ -21,7 +21,8 @@ import { mkdir, writeFile, readFile } from "node:fs/promises";
21
21
  import { resolve, basename } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
23
23
  import { z } from "zod";
24
- import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings } from "@avocadostudio-ai/shared";
24
+ import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings, editorAllowHeaders } from "@avocadostudio-ai/shared";
25
+ import { keyFileHint } from "../env-file-location.js";
25
26
  import { chatRequestBodySchema } from "../nlp/intent-detection.js";
26
27
  import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
27
28
  import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
@@ -316,6 +317,7 @@ const SUPPORTED_ROUTES = [
316
317
  "POST /image/generate",
317
318
  "POST /image/generate/chat",
318
319
  "POST /image/interpret",
320
+ "POST /attachment/upload",
319
321
  "POST /image/upload",
320
322
  "GET /generated-images/:fileName"
321
323
  ];
@@ -347,7 +349,14 @@ const EXT_TO_MIME = {
347
349
  webp: "image/webp",
348
350
  gif: "image/gif",
349
351
  avif: "image/avif",
350
- svg: "image/svg+xml"
352
+ svg: "image/svg+xml",
353
+ /*
354
+ * Not an image, and it has to be here anyway: `/attachment/upload` accepts
355
+ * PDFs, and this map is what `/generated-images/` serves them back through.
356
+ * Without it a PDF uploads successfully and then 415s on the GET — stored,
357
+ * referenced in the chat, and unreadable.
358
+ */
359
+ pdf: "application/pdf"
351
360
  };
352
361
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "../cms/index.js";
353
362
  function stripBasePath(pathname, basePath) {
@@ -527,7 +536,7 @@ export function createOrchestrator(config = {}) {
527
536
  * would have been unreachable from the very client it is for.
528
537
  */
529
538
  "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
530
- "access-control-allow-headers": "content-type, x-access-token, authorization",
539
+ "access-control-allow-headers": editorAllowHeaders(),
531
540
  "access-control-max-age": "600"
532
541
  }
533
542
  });
@@ -1129,6 +1138,17 @@ export function createOrchestrator(config = {}) {
1129
1138
  ...(config.siteName ? { name: config.siteName } : {}),
1130
1139
  demoContent: config.demoContent === true
1131
1140
  },
1141
+ /*
1142
+ * Where the user should put their key, as an absolute path.
1143
+ *
1144
+ * Every piece of copy naming `.env.local` describes a file without
1145
+ * addressing it, and the reader is in a browser tab with no
1146
+ * filesystem, on an origin that is not the site's. The server is
1147
+ * running in that directory, so it is the only party that can answer.
1148
+ * Absent in production, where the advice attached to it would be
1149
+ * false — see `env-file-location.ts`.
1150
+ */
1151
+ ...(keyFileHint() ? { keyFilePath: keyFileHint() } : {}),
1132
1152
  features: {
1133
1153
  googleDrive: false,
1134
1154
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
@@ -1138,6 +1158,23 @@ export function createOrchestrator(config = {}) {
1138
1158
  * editor offers the picker's CMS tab on it, so silence hides the
1139
1159
  * tab rather than opening one onto a method nobody implemented. */
1140
1160
  cmsMedia: runtime.capabilities.readsMedia,
1161
+ /*
1162
+ * Whether `/audio/transcribe` can actually answer.
1163
+ *
1164
+ * This key was simply absent from library mode's feature list while
1165
+ * the standalone server has always reported it, and the editor's
1166
+ * check is `audioTranscription !== false` — so `undefined` read as
1167
+ * *available* and the microphone shipped visible in every
1168
+ * scaffolded project, including with no key configured at all.
1169
+ * Pressing it records audio and posts it to a route that can only
1170
+ * answer 503.
1171
+ *
1172
+ * The same providers as the standalone server, for the same reason:
1173
+ * transcription tries OpenAI first and falls back to Gemini, so
1174
+ * either key enables it and neither means the control must not be
1175
+ * offered.
1176
+ */
1177
+ audioTranscription: Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.GOOGLE_GENAI_API_KEY?.trim()),
1141
1178
  agentMode: false
1142
1179
  },
1143
1180
  /*
@@ -1334,8 +1371,29 @@ export function createOrchestrator(config = {}) {
1334
1371
  const scopedSession = scope(body.session, body.siteId);
1335
1372
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
1336
1373
  const parsedOps = z.array(operationSchema).safeParse(body.ops);
1337
- if (!parsedOps.success)
1338
- return jsonResponse({ error: "invalid ops payload", details: parsedOps.error.issues }, { status: 400, cors });
1374
+ if (!parsedOps.success) {
1375
+ /*
1376
+ * Name the key, and say what an operation looks like.
1377
+ *
1378
+ * Zod reports the *failure*, not the *expectation*: a body with no
1379
+ * `ops` array produced `{"expected":"array","path":[],"message":
1380
+ * "expected array, received undefined"}` — a root-level path pointing
1381
+ * at nothing, never naming `ops`. Someone wiring this by hand from the
1382
+ * documented op vocabulary needed three consecutive 400s to discover
1383
+ * the envelope key, then that the discriminator is `op` and not `type`,
1384
+ * and only then saw the real shape. Each error revealed exactly one
1385
+ * layer, which is the most expensive way to publish a schema.
1386
+ *
1387
+ * `example` is a valid minimal operation on purpose: it answers all
1388
+ * three of those questions at once, in the first response.
1389
+ */
1390
+ return jsonResponse({
1391
+ error: "invalid ops payload",
1392
+ expected: "a JSON body of { session, siteId, ops: Operation[] }",
1393
+ example: { session: "dev", siteId: "my-site", ops: [{ op: "update_props", pageSlug: "/", blockId: "hero-1", patch: { heading: "New heading" } }] },
1394
+ details: parsedOps.error.issues
1395
+ }, { status: 400, cors });
1396
+ }
1339
1397
  if (parsedOps.data.length === 0)
1340
1398
  return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
1341
1399
  let manifest;
@@ -2122,6 +2180,72 @@ export function createOrchestrator(config = {}) {
2122
2180
  const bytes = Buffer.from(await file.arrayBuffer());
2123
2181
  return actionResponse(await interpretImageAction({ bytes, byteLength: bytes.byteLength, mimeType: file.type }, { log: runtime.log }), cors);
2124
2182
  }
2183
+ /*
2184
+ * The composer's paperclip, which library mode never answered.
2185
+ *
2186
+ * The editor's `useMediaInput` posts every chat attachment here,
2187
+ * unconditionally and with no capability probe, and this handler had no
2188
+ * such route — so the control shipped enabled on the first keyless screen
2189
+ * of every scaffolded project and answered `405` with
2190
+ * "not handled by createOrchestrator()" rendered in red under the composer.
2191
+ * It existed only in the standalone Fastify orchestrator
2192
+ * (`apps/orchestrator/src/routes/media.ts`), while library mode is what the
2193
+ * scaffold ships and what the docs call the shape most integrations use.
2194
+ * `features/chat-attachments.md` documents the flow with no caveat.
2195
+ *
2196
+ * Implemented rather than hidden, because nothing was actually missing:
2197
+ * `/image/upload` below already writes to `imageDir` and
2198
+ * `/generated-images/` already serves it back. The route was the only
2199
+ * absent part, and a documented feature is better answered than suppressed.
2200
+ *
2201
+ * Accepts either field name. Fastify's multipart reader takes the first
2202
+ * file part whatever it is called, so the standalone route never had to
2203
+ * care; a Web `FormData` does. The composer sends `file`, the image
2204
+ * endpoints send `image`, and making the two disagree across modes is how
2205
+ * this class of defect started.
2206
+ */
2207
+ if (request.method === "POST" && path === "/attachment/upload") {
2208
+ let form;
2209
+ try {
2210
+ form = await request.formData();
2211
+ }
2212
+ catch {
2213
+ return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
2214
+ }
2215
+ const file = form.get("file") ?? form.get("image");
2216
+ if (!(file instanceof File)) {
2217
+ return jsonResponse({ error: "missing 'file' field" }, { status: 400, cors });
2218
+ }
2219
+ /*
2220
+ * PDFs are attachable and are not images, so the image MIME map is not
2221
+ * the right gate here — it is the gate for what a `<img>` can render.
2222
+ */
2223
+ const ext = file.type === "application/pdf" ? "pdf" : MIME_TO_EXT[file.type];
2224
+ if (!ext) {
2225
+ return jsonResponse({ error: `unsupported attachment type: ${file.type || "unknown"}` }, { status: 415, cors });
2226
+ }
2227
+ if (file.size > MAX_UPLOAD_BYTES) {
2228
+ return jsonResponse({ error: `attachment exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
2229
+ }
2230
+ const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
2231
+ try {
2232
+ await mkdir(imageDir, { recursive: true });
2233
+ await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
2234
+ }
2235
+ catch (err) {
2236
+ return jsonResponse({ error: "attachment upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
2237
+ }
2238
+ return jsonResponse({
2239
+ url: `${basePath}/generated-images/${fileName}`,
2240
+ bytes: file.size,
2241
+ mimeType: file.type,
2242
+ // `name` and `kind` are what the composer renders in the attachment
2243
+ // chip. Without them it falls back to the local File, which is right
2244
+ // but makes the two modes' responses differ for no reason.
2245
+ name: file.name,
2246
+ kind: file.type === "application/pdf" ? "pdf" : "image"
2247
+ }, { status: 200, cors });
2248
+ }
2125
2249
  if (request.method === "POST" && path === "/image/upload") {
2126
2250
  let form;
2127
2251
  try {
@@ -74,7 +74,31 @@ export declare function blockContractsSummary(manifest?: BlockManifest): Record<
74
74
  * they are the phrasings this planner *can* execute without a key.
75
75
  */
76
76
  export declare const KEYLESS_PLANNER_NOTICE: string;
77
- export declare function withKeylessNotice(plan: EditPlan): EditPlan;
77
+ /**
78
+ * The same notice, without the *path*.
79
+ *
80
+ * An earlier version interpolated the absolute path of `.env.local` into the
81
+ * sentence. Correct, and it read appallingly: an 80-character path mid-
82
+ * paragraph, wrapping across four lines of a narrow pane, so a two-line
83
+ * explanation became a wall in which the actual advice was the least visible
84
+ * part — and it duplicated, word for word, the notice sitting directly below
85
+ * the conversation.
86
+ *
87
+ * The path belongs in exactly one place: the notice, where it is a selectable
88
+ * code block rather than prose. The variable names stay here, though, and a
89
+ * first attempt at removing those was wrong — `library-demo-identity.test.ts`
90
+ * pins them, and it is right to. **The notice is dismissible.** A message that
91
+ * says "see the notice below" is a dangling reference the moment someone closes
92
+ * it, and chat messages are also read in transcripts and telemetry where no
93
+ * notice exists. So this sentence has to stand alone; it just does not have to
94
+ * carry a filesystem path to do it.
95
+ *
96
+ * `envFilePath` is still accepted so callers need not know any of this. It
97
+ * changes nothing today and keeps the seam if a surface without the notice ever
98
+ * needs the long form.
99
+ */
100
+ export declare function keylessPlannerNotice(_envFilePath?: string): string;
101
+ export declare function withKeylessNotice(plan: EditPlan, envFilePath?: string): EditPlan;
78
102
  /**
79
103
  * Drop the suggestions this planner could not act on.
80
104
  *
@@ -625,12 +625,38 @@ export function blockContractsSummary(manifest) {
625
625
  * So when the rules produce no operations, say why. The suggestions are kept:
626
626
  * they are the phrasings this planner *can* execute without a key.
627
627
  */
628
- export const KEYLESS_PLANNER_NOTICE = "There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
629
- "Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
630
- export function withKeylessNotice(plan) {
628
+ export const KEYLESS_PLANNER_NOTICE = "No API key is configured, so this is the built-in demo planner, which handles only simple, literal edits. " +
629
+ "Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your project's .env.local and restart the dev server for full chat.";
630
+ /**
631
+ * The same notice, without the *path*.
632
+ *
633
+ * An earlier version interpolated the absolute path of `.env.local` into the
634
+ * sentence. Correct, and it read appallingly: an 80-character path mid-
635
+ * paragraph, wrapping across four lines of a narrow pane, so a two-line
636
+ * explanation became a wall in which the actual advice was the least visible
637
+ * part — and it duplicated, word for word, the notice sitting directly below
638
+ * the conversation.
639
+ *
640
+ * The path belongs in exactly one place: the notice, where it is a selectable
641
+ * code block rather than prose. The variable names stay here, though, and a
642
+ * first attempt at removing those was wrong — `library-demo-identity.test.ts`
643
+ * pins them, and it is right to. **The notice is dismissible.** A message that
644
+ * says "see the notice below" is a dangling reference the moment someone closes
645
+ * it, and chat messages are also read in transcripts and telemetry where no
646
+ * notice exists. So this sentence has to stand alone; it just does not have to
647
+ * carry a filesystem path to do it.
648
+ *
649
+ * `envFilePath` is still accepted so callers need not know any of this. It
650
+ * changes nothing today and keeps the seam if a surface without the notice ever
651
+ * needs the long form.
652
+ */
653
+ export function keylessPlannerNotice(_envFilePath) {
654
+ return KEYLESS_PLANNER_NOTICE;
655
+ }
656
+ export function withKeylessNotice(plan, envFilePath) {
631
657
  if (plan.ops.length > 0)
632
658
  return plan;
633
- return { ...plan, summary_for_user: KEYLESS_PLANNER_NOTICE };
659
+ return { ...plan, summary_for_user: keylessPlannerNotice(envFilePath) };
634
660
  }
635
661
  /**
636
662
  * Drop the suggestions this planner could not act on.
@@ -159,15 +159,53 @@ function diffPage(before, after) {
159
159
  const a = after;
160
160
  const blockDiffs = diffBlocks(b.blocks, a.blocks);
161
161
  const titleChanged = b.title !== a.title;
162
+ const metaDiffs = diffPageMeta(b, a);
162
163
  const hasStructuralChange = blockDiffs.some((bd) => bd.status !== "unchanged");
163
164
  return {
164
165
  slug: a.slug,
165
- status: titleChanged || hasStructuralChange ? "modified" : "unchanged",
166
+ status: titleChanged || metaDiffs.length > 0 || hasStructuralChange ? "modified" : "unchanged",
166
167
  titleBefore: titleChanged ? b.title : undefined,
167
168
  titleAfter: titleChanged ? a.title : undefined,
169
+ ...(metaDiffs.length > 0 ? { metaDiffs } : {}),
168
170
  blockDiffs,
169
171
  };
170
172
  }
173
+ /**
174
+ * Page-level SEO fields, which the diff did not look at.
175
+ *
176
+ * A page's `meta` carries the title, description, Open Graph image and public
177
+ * path — everything a crawler and a social card read, and the whole content of
178
+ * the property panel's **SEO** section. None of it reached the publish review,
179
+ * so a user who edited their page title saw "1 page modified · 1 field changed"
180
+ * counting only the block they had also touched, and no mention of the title at
181
+ * all. The draft held the change; the screen that exists to show what will
182
+ * change did not show it.
183
+ *
184
+ * `ogImage` is typed as an image so the UI renders a thumbnail rather than a
185
+ * URL, matching how block image fields already behave.
186
+ */
187
+ const PAGE_META_FIELDS = [
188
+ ["title", "text"],
189
+ ["description", "text"],
190
+ ["ogImage", "image"],
191
+ ["path", "other"]
192
+ ];
193
+ function diffPageMeta(before, after) {
194
+ const b = (before.meta ?? {});
195
+ const a = (after.meta ?? {});
196
+ const out = [];
197
+ for (const [key, kind] of PAGE_META_FIELDS) {
198
+ const bv = b[key];
199
+ const av = a[key];
200
+ // Absent and empty are the same thing to a reader, and treating them as
201
+ // different produced phantom diffs on pages that had never had a value.
202
+ const normalise = (v) => (v === undefined || v === null || v === "" ? undefined : v);
203
+ if (normalise(bv) === normalise(av))
204
+ continue;
205
+ out.push({ path: `meta.${key}`, before: bv, after: av, kind });
206
+ }
207
+ return out;
208
+ }
171
209
  // Keys of SiteConfig that drive the rendered SiteHeader chrome. We diff
172
210
  // only these to keep the publish UI focused on user-visible header changes;
173
211
  // `purpose`, `tone`, `constraints`, `themeOverrides` are AI/style context
@@ -276,6 +314,15 @@ export function computePublishDiff(draft, published, options) {
276
314
  if (bd.fieldDiffs)
277
315
  totalChangedFields += bd.fieldDiffs.length;
278
316
  }
317
+ /*
318
+ * Page metadata counts as changed fields too.
319
+ *
320
+ * Three counters used to disagree on one screen — "1 field changed" here
321
+ * (blocks only), "2 changes" on the page row (which counted an added
322
+ * block), and "Publish 1 change" on the button — and a metadata edit moved
323
+ * none of them.
324
+ */
325
+ totalChangedFields += diff.metaDiffs?.length ?? 0;
279
326
  }
280
327
  const statusOrder = {
281
328
  added: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -22,8 +22,8 @@
22
22
  "openai": "^4.87.1",
23
23
  "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/shared": "^0.11.4",
26
- "@avocadostudio-ai/migration-sdk": "^0.11.4"
25
+ "@avocadostudio-ai/migration-sdk": "^0.11.6",
26
+ "@avocadostudio-ai/shared": "^0.11.6"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",