@avocadostudio-ai/orchestrator-core 0.2.3 → 0.3.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.
@@ -19,7 +19,7 @@ import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig }
19
19
  import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
20
20
  import sharp from "sharp";
21
21
  import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
22
- /** Convert a package name like "villa-puravida-web" → "Villa Puravida Web" */
22
+ /** Convert a package name like "coastal-villa-web" → "Coastal Villa Web" */
23
23
  function humanizePkgName(name) {
24
24
  return name
25
25
  .replace(/^@[^/]+\//, "") // strip scope
@@ -34,4 +34,16 @@ export type ChangelogCoverageResult = {
34
34
  export declare function validateChangelogCoverage(args: {
35
35
  plan: EditPlan;
36
36
  draft: Map<string, PageDoc>;
37
+ /**
38
+ * Trailing `change_log` entries that describe the turn rather than an op —
39
+ * currently just the note the hallucination validator appends when it strips
40
+ * a prop the block does not have.
41
+ *
42
+ * They are neither counted against `ops.length` nor trimmed. Without this the
43
+ * arithmetic below sees N ops and N+1 entries, calls the note the planner
44
+ * over-describing itself, and deletes it — and that note is the only place
45
+ * the user is told a field they asked for does not exist. Two validators
46
+ * twenty lines apart, one erasing the other's output.
47
+ */
48
+ notes?: string[];
37
49
  }): ChangelogCoverageResult;
@@ -156,6 +156,22 @@ function describeOp(op, draft) {
156
156
  */
157
157
  export function validateChangelogCoverage(args) {
158
158
  const { plan, draft } = args;
159
+ /*
160
+ * Split the notes off the tail, run the op-vs-narration arithmetic on what is
161
+ * left, and put them back. `notes` are appended last by construction, so this
162
+ * is a suffix check rather than a search.
163
+ */
164
+ const notes = [];
165
+ for (const note of [...(args.notes ?? [])].reverse()) {
166
+ if (plan.change_log[plan.change_log.length - 1] !== note)
167
+ continue;
168
+ plan.change_log = plan.change_log.slice(0, -1);
169
+ notes.unshift(note);
170
+ }
171
+ const restoreNotes = () => {
172
+ if (notes.length > 0)
173
+ plan.change_log = [...plan.change_log, ...notes];
174
+ };
159
175
  const empty = {
160
176
  plan,
161
177
  missingCount: 0,
@@ -165,8 +181,10 @@ export function validateChangelogCoverage(args) {
165
181
  fieldMislabelCount: 0,
166
182
  relabeledEntries: []
167
183
  };
168
- if (plan.intent !== "edit_plan")
184
+ if (plan.intent !== "edit_plan") {
185
+ restoreNotes();
169
186
  return empty;
187
+ }
170
188
  const opCount = plan.ops.length;
171
189
  // Field-level pass (#13): an update_props entry can be the right COUNT yet name
172
190
  // the wrong field. Relabel each mislabeled update_props entry from the op's
@@ -190,13 +208,16 @@ export function validateChangelogCoverage(args) {
190
208
  }
191
209
  const changeCount = plan.change_log.length;
192
210
  if (changeCount === opCount) {
211
+ restoreNotes();
193
212
  return { ...empty, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
194
213
  }
195
214
  // Empty ops list with non-empty change_log is almost always a planner
196
215
  // confusion (intent should have been content_answer). Leave it for
197
216
  // higher-level handling rather than wiping the user-facing copy here.
198
- if (opCount === 0)
217
+ if (opCount === 0) {
218
+ restoreNotes();
199
219
  return empty;
220
+ }
200
221
  if (changeCount < opCount) {
201
222
  const missingCount = opCount - changeCount;
202
223
  const synthesizedEntries = [];
@@ -204,6 +225,7 @@ export function validateChangelogCoverage(args) {
204
225
  synthesizedEntries.push(describeOp(plan.ops[i], draft));
205
226
  }
206
227
  plan.change_log = [...plan.change_log, ...synthesizedEntries];
228
+ restoreNotes();
207
229
  return { plan, missingCount, synthesizedEntries, extraCount: 0, droppedEntries: [], fieldMislabelCount: relabeledEntries.length, relabeledEntries };
208
230
  }
209
231
  // changeCount > opCount: planner described more changes than it emitted ops
@@ -211,5 +233,6 @@ export function validateChangelogCoverage(args) {
211
233
  const extraCount = changeCount - opCount;
212
234
  const droppedEntries = plan.change_log.slice(opCount);
213
235
  plan.change_log = plan.change_log.slice(0, opCount);
236
+ restoreNotes();
214
237
  return { plan, missingCount: 0, synthesizedEntries: [], extraCount, droppedEntries, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
215
238
  }
@@ -4,7 +4,7 @@ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
4
4
  import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
5
5
  import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
6
6
  import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
7
- import { toErrorDetail, isNoEffectiveChangeError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
7
+ import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
8
8
  import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
9
9
  import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
10
10
  import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
@@ -1101,6 +1101,14 @@ export async function runChatPipeline(ctx, body, options) {
1101
1101
  const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
1102
1102
  if (plannerTier)
1103
1103
  activePlannerTier = plannerTier;
1104
+ /*
1105
+ * The note the hallucination validator writes when it strips a prop the
1106
+ * block does not have, hoisted so the no-effective-change handler far below
1107
+ * can reach it. That handler replaces `summary_for_user` wholesale, and a
1108
+ * plan whose every prop was stripped lands there — so without this the one
1109
+ * true sentence written about the turn is the one the user never sees.
1110
+ */
1111
+ let strippedPropsNote;
1104
1112
  const usageFields = planUsage ? {
1105
1113
  inputTokens: planUsage.inputTokens,
1106
1114
  outputTokens: planUsage.outputTokens,
@@ -1152,6 +1160,7 @@ export async function runChatPipeline(ctx, body, options) {
1152
1160
  plan: resolvedPlan,
1153
1161
  draft: getSessionDraft(body.session)
1154
1162
  });
1163
+ strippedPropsNote = hallucinationResult.note;
1155
1164
  if (hallucinationResult.hallucinatedProps.length > 0) {
1156
1165
  for (const entry of hallucinationResult.hallucinatedProps) {
1157
1166
  ctx.log.warn({
@@ -1173,7 +1182,9 @@ export async function runChatPipeline(ctx, body, options) {
1173
1182
  // sees the correct count instead of silently approving uncovered ops.
1174
1183
  const changelogResult = validateChangelogCoverage({
1175
1184
  plan: resolvedPlan,
1176
- draft: getSessionDraft(body.session)
1185
+ draft: getSessionDraft(body.session),
1186
+ // Not an op narration, so not something to trim for over-describing.
1187
+ notes: hallucinationResult.note ? [hallucinationResult.note] : undefined
1177
1188
  });
1178
1189
  if (changelogResult.missingCount > 0) {
1179
1190
  ctx.log.warn({
@@ -2540,13 +2551,47 @@ export async function runChatPipeline(ctx, body, options) {
2540
2551
  ...plannerContextTelemetryFields,
2541
2552
  ...timingFields()
2542
2553
  });
2554
+ /*
2555
+ * Two facts arrive here and only one of them is "your content is
2556
+ * already correct".
2557
+ *
2558
+ * The engine throws "All update patches matched existing values" when
2559
+ * the values genuinely matched, and "Edit plan produced no changes"
2560
+ * when the plan had nothing left to apply — which is what a plan looks
2561
+ * like after every prop in it was stripped as unsupported by the block.
2562
+ * Answering both with `status: "applied"` and "already up to date"
2563
+ * tells an author their content is fine when their request was in fact
2564
+ * dropped, and it is the shape of failure nobody reports, because the
2565
+ * product said it worked.
2566
+ *
2567
+ * The two sibling branches in this file already draw this line — the
2568
+ * approval-gate no-op at `planPreview.appliedCount === 0` and the
2569
+ * empty-plan branch that refuses to claim up-to-date-ness for an
2570
+ * `edit_plan` intent. This was the third one, and it was the one an
2571
+ * adopter hit.
2572
+ */
2573
+ /*
2574
+ * A note from the hallucination validator outranks both engine
2575
+ * messages. It means this turn asked for a field the block does not
2576
+ * have, which is a better answer than either of them and the only one
2577
+ * that names the block — and it is true whichever message the engine
2578
+ * happened to throw, since an op stripped to an empty patch can land in
2579
+ * `skippedOps` and read as "matched existing values".
2580
+ */
2581
+ const noopSummary = strippedPropsNote ??
2582
+ (isAlreadyCurrentError(reason)
2583
+ ? "No changes needed — that content is already set."
2584
+ : "I couldn't make that change — it isn't supported on this block. Try a different field or wording.");
2543
2585
  return {
2544
2586
  done: true,
2545
2587
  response: {
2546
2588
  code: 200,
2547
2589
  payload: withDebugPayload({
2548
- status: "applied",
2549
- summary: "No changes needed. That content is already up to date.",
2590
+ // `info`, not `applied`: nothing was applied either way, and the
2591
+ // approval-gate branch above has answered `info` for the same
2592
+ // fact since it was written.
2593
+ status: "info",
2594
+ summary: noopSummary,
2550
2595
  changes: [],
2551
2596
  mentionedSlugs: [effectiveSlug],
2552
2597
  previewVersion: versions.get(body.session) ?? 0,
@@ -19,6 +19,11 @@ export type HallucinatedProp = {
19
19
  export type HallucinationValidationResult = {
20
20
  plan: EditPlan;
21
21
  hallucinatedProps: HallucinatedProp[];
22
+ /**
23
+ * The user-facing note appended to `summary_for_user` and `change_log`,
24
+ * when props were stripped. Absent when nothing was.
25
+ */
26
+ note?: string;
22
27
  };
23
28
  /**
24
29
  * Scan every `update_props` op in the plan, strip patch keys that are not
@@ -105,6 +105,19 @@ export function validateAndStripHallucinatedProps(args) {
105
105
  const summary = plan.summary_for_user?.trimEnd() ?? "";
106
106
  plan.summary_for_user = summary.length > 0 ? `${summary}\n\n${note}` : note;
107
107
  plan.change_log = [...plan.change_log, note];
108
+ /*
109
+ * Returned as well as appended, for two callers that both need it back.
110
+ *
111
+ * The changelog-coverage validator runs next and trims trailing entries
112
+ * when there are more of them than ops — and this note is always the last
113
+ * entry, so it was always the one trimmed. It has to be told which entry is
114
+ * a note rather than an op narration.
115
+ *
116
+ * And the no-effective-change handler replaces `summary_for_user` wholesale
117
+ * with a canned line, which is exactly the case where this note is the only
118
+ * true thing anyone wrote about the turn.
119
+ */
120
+ return { plan, hallucinatedProps, note };
108
121
  }
109
122
  return { plan, hallucinatedProps };
110
123
  }
@@ -57,6 +57,41 @@ export type CmsPublishResult = void | {
57
57
  error?: string;
58
58
  unsupported?: string[];
59
59
  };
60
+ /**
61
+ * One asset from a CMS's media library, in the shape the editor's image picker
62
+ * renders. Deliberately the smallest projection that a picker needs: an
63
+ * identity, something to show, something to insert, and something to put in an
64
+ * `alt`. Anything richer would be a per-CMS shape again.
65
+ */
66
+ export interface CmsMediaItem {
67
+ /** Stable id in the source store. Used as the picker's selection key. */
68
+ id: string;
69
+ /** Human-facing filename or title, if the store has one. */
70
+ name?: string;
71
+ /** Full-size URL to insert into the page. Omitted only if the store has none. */
72
+ imageUrl?: string;
73
+ /** Grid thumbnail. May be the same URL as `imageUrl` when no derivative exists. */
74
+ thumbUrl: string;
75
+ /** Alt text the store already holds, so the editor does not invent one. */
76
+ alt?: string;
77
+ }
78
+ /** One page of a media-library search. */
79
+ export interface CmsMediaQuery {
80
+ /** Free-text filter. Empty or absent means "everything, newest first". */
81
+ query?: string;
82
+ /** 1-based page number. */
83
+ page: number;
84
+ /** Items per page. */
85
+ limit: number;
86
+ }
87
+ /** What `getMedia` returns: the page, and how many there are in total. */
88
+ export interface CmsMediaPage {
89
+ items: CmsMediaItem[];
90
+ /** Total pages for this query, so the picker knows whether to offer "load more". */
91
+ totalPages: number;
92
+ /** Display name for the tab, e.g. "Sanity". Defaults to the adapter's `id`. */
93
+ label?: string;
94
+ }
60
95
  /**
61
96
  * Which side of a CMS's draft/published split a read wants.
62
97
  *
@@ -146,6 +181,24 @@ export interface CmsAdapter {
146
181
  * unreachable, so it cannot be a method that reads upstream.
147
182
  */
148
183
  readonly capabilities?: CmsCapabilities;
184
+ /**
185
+ * Optional media-library read, for the editor's image picker.
186
+ *
187
+ * The picker used to reach the CMS from the browser, over a closed union of
188
+ * three providers compiled into the editor app. That made the image picker
189
+ * the one part of an integration that could not be integrated: a site could
190
+ * bring its own adapter, its own blocks and its own publish handler, and
191
+ * still had no way to bring its own images short of a change to Avocado.
192
+ *
193
+ * Implement it and the tab appears. Leave it off and it does not — silence
194
+ * means no here, the same way it does for `perspectives`, because a picker
195
+ * that offers a tab it cannot fill is worse than no tab.
196
+ *
197
+ * `cmsMediaSource()` in `./media-sources.ts` implements this for Contentful,
198
+ * Sanity and Strapi if one of those is what you have; it is a helper, not a
199
+ * requirement, and nothing in the route path knows it exists.
200
+ */
201
+ getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
149
202
  }
150
203
  /**
151
204
  * Per-operation capability declaration. Every field is tri-state, and the
@@ -203,6 +256,15 @@ export interface ResolvedCapabilities {
203
256
  * knows not to conclude "nothing is pending" from an empty diff.
204
257
  */
205
258
  readsDraftPerspective: boolean;
259
+ /**
260
+ * True when the adapter can list the CMS's media library; derived from the
261
+ * presence of `getMedia`, never declared as a capability.
262
+ *
263
+ * The editor reads it to decide whether to offer a CMS tab in the image
264
+ * picker at all, which is why it is derived rather than declared: a tab that
265
+ * opens onto a method nobody implemented is a worse answer than no tab.
266
+ */
267
+ readsMedia: boolean;
206
268
  /**
207
269
  * The subset the adapter actually declared. A caller that needs to
208
270
  * distinguish "this site says no" from "nobody said" reads this; everything
@@ -14,6 +14,7 @@ export function resolveCapabilities(adapter, override) {
14
14
  publishesUpstream: typeof adapter?.onPublish === "function",
15
15
  // `=== true`, not truthiness: an adapter that says nothing has not said yes.
16
16
  readsDraftPerspective: adapter?.perspectives === true,
17
+ readsMedia: typeof adapter?.getMedia === "function",
17
18
  declared
18
19
  };
19
20
  }
@@ -43,12 +43,36 @@ export interface CmsBootstrapCache {
43
43
  * clone and no extra adapter read. Re-reading upstream at publish time would
44
44
  * cost 45 sequential CMS calls on the integration that motivated this.
45
45
  *
46
- * Bounded and best-effort by construction. It is null after a restart that
47
- * reloaded the draft from SQLite (the seed never ran), and it is evicted
48
- * FIFO past `maxBaselines`, so a caller must treat null as "no baseline
49
- * available" and never as "the site was empty".
46
+ * Bounded and best-effort by construction. A restart that reloads the draft
47
+ * from SQLite skips the seed but not the baseline `ensure` re-reads the
48
+ * adapter for it, which is sound because nothing was published in between —
49
+ * but it is still evicted FIFO past `maxBaselines`, and still null when the
50
+ * adapter read failed. A caller must treat null as "no baseline available"
51
+ * and never as "the site was empty".
50
52
  */
51
53
  baselineFor(session: string): PageDoc[] | null;
54
+ /**
55
+ * Discard this session's draft and seed it again from the adapter.
56
+ *
57
+ * `ensure()` is deliberately once-per-session: it exists to not clobber a
58
+ * draft that SQLite already holds. That is right for every ordinary request
59
+ * and wrong for the one case an integrator hits constantly while building an
60
+ * adapter — they change how their projection shapes pages, and the
61
+ * orchestrator keeps serving the shapes it read before the change. The only
62
+ * way out used to be deleting `.data/` and restarting the process, which is
63
+ * a much larger hammer than the problem.
64
+ *
65
+ * **This destroys unpublished edits in the session draft**, which is what
66
+ * makes it a separate, explicitly-requested call rather than something
67
+ * `ensure()` could decide to do on its own.
68
+ *
69
+ * It discards nothing until the adapter has answered: a failed read rejects
70
+ * and leaves the draft, the baseline and the attempt marker untouched, so a
71
+ * reseed against a CMS that happens to be down cannot cost anyone their
72
+ * work. A read that succeeds and returns no pages *is* an answer, and empties
73
+ * the draft accordingly.
74
+ */
75
+ reseed(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
52
76
  /** Test helper — drop all remembered state. */
53
77
  reset(): void;
54
78
  /** Inspection — number of remembered attempts (post-eviction). */
@@ -129,7 +129,31 @@ export function createCmsBootstrapCache(opts = {}) {
129
129
  return existing;
130
130
  const promise = (async () => {
131
131
  const draft = getSessionDraft(session);
132
- if (draft.size > 0)
132
+ /*
133
+ * A draft that is already populated came from SQLite, not from us, and
134
+ * for a long time that was the end of the matter: seeding was the only
135
+ * thing this function did, so a loaded draft meant there was nothing to
136
+ * do. But the seed also produces the publish baseline, and that made the
137
+ * early return silently disable publishing. The draft survives a
138
+ * restart, so `ensure` returned here, so `rememberBaseline` never ran, so
139
+ * `context.published` was `undefined` for the rest of the process's life
140
+ * — and a publisher written the documented way (diff against the
141
+ * baseline, refuse when there is none) can then never publish again.
142
+ * Restarting to fix it is the one thing guaranteed not to.
143
+ *
144
+ * So the two jobs are now separate. A loaded draft still skips the seed;
145
+ * it no longer skips the baseline. Recovering it costs one
146
+ * `getPages()` — the same call the seed would have made, usually served
147
+ * from the warm cache — and it is correct to read upstream for it,
148
+ * because nothing has been published: the CMS still holds exactly the
149
+ * pre-edit content the lost baseline was a copy of.
150
+ *
151
+ * This is the same shape as the `setSessionCapabilities` hoist above.
152
+ * Anything the seed produces besides the draft has to survive a restart
153
+ * that reloads the draft.
154
+ */
155
+ const seeded = draft.size > 0;
156
+ if (seeded && baselines.has(session))
133
157
  return; // finally-block records the attempt
134
158
  try {
135
159
  const warmed = warmedPages(adapter);
@@ -139,13 +163,17 @@ export function createCmsBootstrapCache(opts = {}) {
139
163
  return;
140
164
  }
141
165
  /*
142
- * Keep the pre-edit copy. The pages are already being cloned into the
143
- * draft, so a baseline for the eventual publish diff costs one more
166
+ * Keep the pre-edit copy. When we are also seeding, the pages are
167
+ * already being cloned into the draft, so the baseline costs one more
144
168
  * clone and — the point — no second adapter read. Re-reading upstream
145
169
  * at publish time is 45 sequential CMS calls on the integration that
146
170
  * motivated this.
147
171
  */
148
172
  rememberBaseline(session, pages.map((page) => structuredClone(page)));
173
+ if (seeded) {
174
+ log.info({ session, adapter: adapter.id, count: pages.length }, "cms-bootstrap: recovered publish baseline for a draft reloaded from storage");
175
+ return;
176
+ }
149
177
  for (const page of pages) {
150
178
  const copy = structuredClone(page);
151
179
  ensureHeroImageProps(copy);
@@ -174,9 +202,48 @@ export function createCmsBootstrapCache(opts = {}) {
174
202
  inFlight.delete(session);
175
203
  }
176
204
  }
205
+ async function reseed(session, adapter, log) {
206
+ if (!adapter)
207
+ return;
208
+ setSessionCapabilities(session, resolveCapabilities(adapter, capabilityOverride));
209
+ // Wait out a seed already in flight, so the discard below cannot land
210
+ // between that seed's adapter read and its writes and leave half a draft.
211
+ const existing = inFlight.get(session);
212
+ if (existing)
213
+ await existing.catch(() => { });
214
+ /*
215
+ * Read before discarding, not after. Clearing the draft and then calling
216
+ * `ensure` reads well and fails badly: `ensure` swallows an adapter error
217
+ * and marks the session attempted on the way out, so an adapter that was
218
+ * merely down turned a stale draft into an empty one that nothing would
219
+ * ever retry. The caller asked to replace the draft with what the adapter
220
+ * says; if the adapter says nothing, there is no replacement to make and
221
+ * the right answer is to leave the session exactly as we found it.
222
+ *
223
+ * This deliberately does not consult the warmed list. That list is a
224
+ * cold-start bridge and may predate whatever change prompted the reseed —
225
+ * serving it here would return the exact staleness we were asked to clear.
226
+ */
227
+ const pages = await adapter.getPages({ perspective: "draft" });
228
+ const draft = getSessionDraft(session);
229
+ const discarded = draft.size;
230
+ draft.clear();
231
+ for (const page of pages) {
232
+ const copy = structuredClone(page);
233
+ ensureHeroImageProps(copy);
234
+ draft.set(copy.slug, copy);
235
+ }
236
+ rememberBaseline(session, pages.map((page) => structuredClone(page)));
237
+ markAttempted(session);
238
+ warmEntry = null;
239
+ bumpVersion(session);
240
+ schedulePersistState(log);
241
+ log.info({ session, adapter: adapter.id, discarded, count: pages.length }, "cms-bootstrap: reseeded session draft from the adapter");
242
+ }
177
243
  return {
178
244
  ensure,
179
245
  warm,
246
+ reseed,
180
247
  baselineFor(session) {
181
248
  return baselines.get(session) ?? null;
182
249
  },
@@ -1,5 +1,6 @@
1
- export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
1
+ export type { CmsAdapter, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
2
2
  export { resolveCapabilities } from "./adapter.ts";
3
3
  export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
4
4
  export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
5
5
  export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
6
+ export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
package/dist/cms/index.js CHANGED
@@ -2,3 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
2
2
  export { jsonFileAdapter } from "./json-file-adapter.js";
3
3
  export { editorApiAdapter } from "./editor-api-adapter.js";
4
4
  export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
5
+ export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Smallest possible adapter. Suitable for sites that keep their content as
4
4
  // a checked-in JSON array (the shape the orchestrator + blocks already
5
- // understand). villa-puravida-web's lib/published-content.json is the
5
+ // understand). A consumer site's lib/published-content.json is the
6
6
  // canonical example.
7
7
  //
8
8
  // If `writeOnPublish` is true, onPublish() will overwrite the file with the
@@ -0,0 +1,49 @@
1
+ import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
2
+ /** Connection details for one of the built-in media readers. */
3
+ export type CmsMediaSourceConfig = {
4
+ provider: "contentful";
5
+ spaceId: string;
6
+ deliveryToken: string;
7
+ environment?: string;
8
+ } | {
9
+ provider: "sanity";
10
+ projectId: string;
11
+ dataset?: string;
12
+ token?: string;
13
+ } | {
14
+ provider: "strapi";
15
+ url: string;
16
+ token?: string;
17
+ };
18
+ /** A media reader: the exact shape of `CmsAdapter.getMedia`. */
19
+ export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
20
+ /** Display name for a provider id, for the picker's tab. */
21
+ export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
22
+ /**
23
+ * Build a media reader from connection details.
24
+ *
25
+ * ```ts
26
+ * export function sanityAdapter(): CmsAdapter {
27
+ * return {
28
+ * id: "sanity",
29
+ * getPages: …,
30
+ * getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
31
+ * }
32
+ * }
33
+ * ```
34
+ *
35
+ * Every reader answers an empty page rather than throwing when the upstream
36
+ * call fails: a picker tab that renders nothing is recoverable, and a rejected
37
+ * promise inside a modal is not.
38
+ */
39
+ export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
40
+ /**
41
+ * Build a reader from an untrusted object, or `null` if it is not one of the
42
+ * three shapes.
43
+ *
44
+ * The editor sends this over the wire, so every field is checked rather than
45
+ * asserted. A partially-filled form — the drawer creates `{ provider:
46
+ * "sanity", projectId: "" }` the moment the provider is picked — must read as
47
+ * "not configured" and not as a reader that will 404 on every request.
48
+ */
49
+ export declare function mediaSourceFromUnknown(raw: unknown): CmsMediaSource | null;
@@ -0,0 +1,182 @@
1
+ const LABELS = {
2
+ contentful: "Contentful",
3
+ sanity: "Sanity",
4
+ strapi: "Strapi"
5
+ };
6
+ /** Display name for a provider id, for the picker's tab. */
7
+ export function cmsMediaLabel(provider) {
8
+ return LABELS[provider];
9
+ }
10
+ const EMPTY = (label) => ({ items: [], totalPages: 0, label });
11
+ /**
12
+ * Build a media reader from connection details.
13
+ *
14
+ * ```ts
15
+ * export function sanityAdapter(): CmsAdapter {
16
+ * return {
17
+ * id: "sanity",
18
+ * getPages: …,
19
+ * getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Every reader answers an empty page rather than throwing when the upstream
25
+ * call fails: a picker tab that renders nothing is recoverable, and a rejected
26
+ * promise inside a modal is not.
27
+ */
28
+ export function cmsMediaSource(config) {
29
+ switch (config.provider) {
30
+ case "contentful": return (q) => contentfulMedia(config, q);
31
+ case "sanity": return (q) => sanityMedia(config, q);
32
+ case "strapi": return (q) => strapiMedia(config, q);
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Contentful — Delivery API, `skip`/`limit` paging, `total` in the envelope.
37
+ // ---------------------------------------------------------------------------
38
+ async function contentfulMedia(config, { query, page, limit }) {
39
+ const label = LABELS.contentful;
40
+ const env = config.environment ?? "master";
41
+ const params = new URLSearchParams({
42
+ access_token: config.deliveryToken,
43
+ limit: String(limit),
44
+ skip: String((page - 1) * limit),
45
+ mimetype_group: "image"
46
+ });
47
+ if (query)
48
+ params.set("query", query);
49
+ const res = await fetch(`https://cdn.contentful.com/spaces/${config.spaceId}/environments/${env}/assets?${params}`);
50
+ if (!res.ok)
51
+ return EMPTY(label);
52
+ const data = (await res.json());
53
+ const items = (data.items ?? [])
54
+ .filter((item) => item.sys?.id && item.fields?.file?.url)
55
+ .map((item) => {
56
+ const url = item.fields.file.url;
57
+ // Contentful returns protocol-relative asset URLs.
58
+ const fullUrl = url.startsWith("//") ? `https:${url}` : url;
59
+ return {
60
+ id: item.sys.id,
61
+ name: item.fields?.title,
62
+ alt: item.fields?.description ?? item.fields?.title,
63
+ imageUrl: fullUrl,
64
+ thumbUrl: `${fullUrl}?w=200&h=200&fit=thumb`
65
+ };
66
+ });
67
+ return { items, totalPages: Math.ceil((data.total ?? 0) / limit), label };
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Sanity — GROQ, slice paging, a second query for the count.
71
+ // ---------------------------------------------------------------------------
72
+ async function sanityMedia(config, { query, page, limit }) {
73
+ const label = LABELS.sanity;
74
+ const dataset = config.dataset ?? "production";
75
+ const offset = (page - 1) * limit;
76
+ const end = offset + limit - 1;
77
+ // GROQ is interpolated, so the filter term is stripped of the two characters
78
+ // that could close the string literal it lands inside.
79
+ const safeQuery = (query ?? "").replace(/["\\]/g, "");
80
+ const filter = safeQuery
81
+ ? `_type == "sanity.imageAsset" && originalFilename match "*${safeQuery}*"`
82
+ : `_type == "sanity.imageAsset"`;
83
+ const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] { _id, url, originalFilename, metadata { dimensions } }`;
84
+ const countGroq = `count(*[${filter}])`;
85
+ const base = `https://${config.projectId}.api.sanity.io/v2024-01-01/data/query/${dataset}`;
86
+ const headers = {};
87
+ if (config.token)
88
+ headers.authorization = `Bearer ${config.token}`;
89
+ const [assetsRes, countRes] = await Promise.all([
90
+ fetch(`${base}?query=${encodeURIComponent(groq)}`, { headers }),
91
+ fetch(`${base}?query=${encodeURIComponent(countGroq)}`, { headers })
92
+ ]);
93
+ if (!assetsRes.ok)
94
+ return EMPTY(label);
95
+ const assets = (await assetsRes.json());
96
+ const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
97
+ return {
98
+ items: (assets.result ?? []).map((asset) => ({
99
+ id: asset._id,
100
+ name: asset.originalFilename,
101
+ alt: asset.originalFilename,
102
+ imageUrl: asset.url,
103
+ thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
104
+ })),
105
+ totalPages: Math.ceil(count / limit),
106
+ label
107
+ };
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Strapi — upload plugin, page/pageSize paging, count in a response header.
111
+ // ---------------------------------------------------------------------------
112
+ async function strapiMedia(config, { query, page, limit }) {
113
+ const label = LABELS.strapi;
114
+ const baseUrl = config.url.replace(/\/+$/, "");
115
+ const params = new URLSearchParams({
116
+ "filters[mime][$startsWith]": "image",
117
+ "pagination[page]": String(page),
118
+ "pagination[pageSize]": String(limit),
119
+ sort: "createdAt:desc"
120
+ });
121
+ if (query)
122
+ params.set("filters[name][$containsi]", query);
123
+ const headers = {};
124
+ if (config.token)
125
+ headers.authorization = `Bearer ${config.token}`;
126
+ const res = await fetch(`${baseUrl}/api/upload/files?${params}`, { headers });
127
+ if (!res.ok)
128
+ return EMPTY(label);
129
+ const data = (await res.json());
130
+ const absolute = (u) => (u.startsWith("http") ? u : `${baseUrl}${u}`);
131
+ // The upload endpoint returns a bare array; the count is a header.
132
+ const totalCount = Number(res.headers.get("x-total-count") ?? data.length);
133
+ return {
134
+ items: data.map((file) => ({
135
+ id: file.documentId ?? String(file.id),
136
+ name: file.name,
137
+ alt: file.name,
138
+ imageUrl: absolute(file.url),
139
+ thumbUrl: file.formats?.thumbnail?.url ? absolute(file.formats.thumbnail.url) : absolute(file.url)
140
+ })),
141
+ totalPages: Math.ceil(totalCount / limit),
142
+ label
143
+ };
144
+ }
145
+ /**
146
+ * Build a reader from an untrusted object, or `null` if it is not one of the
147
+ * three shapes.
148
+ *
149
+ * The editor sends this over the wire, so every field is checked rather than
150
+ * asserted. A partially-filled form — the drawer creates `{ provider:
151
+ * "sanity", projectId: "" }` the moment the provider is picked — must read as
152
+ * "not configured" and not as a reader that will 404 on every request.
153
+ */
154
+ export function mediaSourceFromUnknown(raw) {
155
+ if (!raw || typeof raw !== "object")
156
+ return null;
157
+ const obj = raw;
158
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
159
+ switch (obj.provider) {
160
+ case "contentful": {
161
+ const spaceId = str(obj.spaceId);
162
+ const deliveryToken = str(obj.deliveryToken);
163
+ if (!spaceId || !deliveryToken)
164
+ return null;
165
+ return cmsMediaSource({ provider: "contentful", spaceId, deliveryToken, environment: str(obj.environment) });
166
+ }
167
+ case "sanity": {
168
+ const projectId = str(obj.projectId);
169
+ if (!projectId)
170
+ return null;
171
+ return cmsMediaSource({ provider: "sanity", projectId, dataset: str(obj.dataset), token: str(obj.token) });
172
+ }
173
+ case "strapi": {
174
+ const url = str(obj.url);
175
+ if (!url)
176
+ return null;
177
+ return cmsMediaSource({ provider: "strapi", url, token: str(obj.token) });
178
+ }
179
+ default:
180
+ return null;
181
+ }
182
+ }
@@ -47,6 +47,7 @@ import { createFeedbackStore } from "../telemetry/feedback-store.js";
47
47
  import { consoleLogger } from "../logger.js";
48
48
  import { createCmsBootstrapCache } from "../cms/bootstrap.js";
49
49
  import { resolveCapabilities } from "../cms/adapter.js";
50
+ import { mediaSourceFromUnknown } from "../cms/media-sources.js";
50
51
  import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
51
52
  import { checkAuth, resolveAuth } from "./auth.js";
52
53
  const defaultModelLookup = () => ({
@@ -122,7 +123,7 @@ async function buildRuntime(config) {
122
123
  * Start the adapter's page read now rather than on the first request.
123
124
  *
124
125
  * `ensure()` is lazy, so without this the first caller pays for the whole
125
- * CMS read: Paintball Arena Bern's adapter is 45 sequential Sanity reads
126
+ * CMS read: a tri-lingual site's adapter is 45 sequential Sanity reads
126
127
  * behind a dev-server compile, and the `whoami` an MCP agent opens with took
127
128
  * four minutes — long enough that the host times out and the agent reports
128
129
  * the site as down. Fire-and-forget by contract: a failed warm logs and is
@@ -229,6 +230,7 @@ const SUPPORTED_ROUTES = [
229
230
  "POST /restore/snapshot",
230
231
  "DELETE /restore/snapshot",
231
232
  "GET /unsplash/search",
233
+ "POST /media/cms",
232
234
  "GET+POST /telemetry/chat/feedback",
233
235
  "POST /preview/screenshot",
234
236
  "POST /audio/transcribe",
@@ -835,6 +837,10 @@ export function createOrchestrator(config = {}) {
835
837
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
836
838
  imageGenerate: hasImageBackend,
837
839
  imageGenerateChat: hasImageBackend,
840
+ /* Whether this mount's adapter can list a media library. The
841
+ * editor offers the picker's CMS tab on it, so silence hides the
842
+ * tab rather than opening one onto a method nobody implemented. */
843
+ cmsMedia: runtime.capabilities.readsMedia,
838
844
  agentMode: false
839
845
  },
840
846
  /*
@@ -866,10 +872,24 @@ export function createOrchestrator(config = {}) {
866
872
  await runtime.ready;
867
873
  const session = url.searchParams.get("session") ?? undefined;
868
874
  const siteId = url.searchParams.get("siteId") ?? undefined;
869
- const slug = url.searchParams.get("slug") ?? undefined;
870
- if (!session || !slug) {
875
+ const slug = url.searchParams.get("slug");
876
+ if (!session || slug === null) {
871
877
  return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
872
878
  }
879
+ /*
880
+ * `?slug=` is a parameter that is present and empty, and answering
881
+ * "required" sends the integrator to look for a bug in their query
882
+ * string. What it actually means is that their projection emitted bare
883
+ * paths: a slug is a leading-slash path and the home page's is `"/"`.
884
+ * Every other bare slug resolves by string equality, so the home page is
885
+ * the only one that fails, and it fails as the site's own 404 rendered
886
+ * inside the editor iframe — which reads as "my site is broken".
887
+ */
888
+ if (slug === "") {
889
+ return jsonResponse({
890
+ error: 'slug must not be empty: a page slug is a path beginning with "/", and the home page is "/" rather than ""'
891
+ }, { status: 400, cors });
892
+ }
873
893
  const scopedSession = scope(session, siteId);
874
894
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
875
895
  const page = getPage(scopedSession, slug);
@@ -909,9 +929,25 @@ export function createOrchestrator(config = {}) {
909
929
  })
910
930
  }, { status: 200, cors });
911
931
  }
912
- // Editor bootstrap probe. In library mode the adapter is the source of
913
- // truth (already seeded by ensure), so this is effectively a confirm — we
914
- // don't clobber the draft with the posted pages.
932
+ /*
933
+ * Editor bootstrap probe. In library mode the adapter is the source of
934
+ * truth (already seeded by ensure), so this is effectively a confirm — we
935
+ * don't clobber the draft with the posted pages.
936
+ *
937
+ * `force` was accepted by the standalone orchestrator and silently dropped
938
+ * here: the body was read as `{session, siteId}` and nothing else, so a
939
+ * caller asking to re-read the adapter got the cached page list back with
940
+ * a 200 and the word "bootstrapped" on it. That is worse than refusing,
941
+ * because the shape an integrator is in when they send it is "I changed my
942
+ * projection and the orchestrator is still serving the old shapes" — and
943
+ * the answer looks like confirmation that it re-read. The documented
944
+ * escape was deleting `.data/` and restarting.
945
+ *
946
+ * It now does what it says, including the destructive half: the session
947
+ * draft is discarded and rebuilt from the adapter, so unpublished edits in
948
+ * it are lost. `forced` comes back in the response so a caller can tell
949
+ * which of the two things happened.
950
+ */
915
951
  if (request.method === "POST" && path === "/draft/bootstrap") {
916
952
  const runtime = await getRuntime();
917
953
  await runtime.ready;
@@ -924,9 +960,31 @@ export function createOrchestrator(config = {}) {
924
960
  }
925
961
  const body = (raw ?? {});
926
962
  const scopedSession = scope(body.session, body.siteId);
927
- await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
963
+ const force = body.force === true;
964
+ if (force) {
965
+ try {
966
+ await runtime.bootstrapCache.reseed(scopedSession, runtime.adapter, runtime.log);
967
+ }
968
+ catch (err) {
969
+ /*
970
+ * A forced reseed reaches the adapter and can fail there, and the
971
+ * one thing this route must not do is answer "bootstrapped" when it
972
+ * did not bootstrap — that is the failure being fixed, in a new
973
+ * costume. The reseed discards nothing before the read succeeds, so
974
+ * the draft is intact and saying so is the useful half of the reply.
975
+ */
976
+ return jsonResponse({
977
+ error: "reseed failed: the adapter could not be read",
978
+ detail: err instanceof Error ? err.message : String(err),
979
+ draft: "unchanged"
980
+ }, { status: 502, cors });
981
+ }
982
+ }
983
+ else {
984
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
985
+ }
928
986
  const pages = getSessionPages(scopedSession);
929
- return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
987
+ return jsonResponse({ status: "bootstrapped", forced: force, count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
930
988
  }
931
989
  // Site config — drives the page-level nav-label + SEO fields shown in the
932
990
  // property panel when no block is selected.
@@ -1389,6 +1447,53 @@ export function createOrchestrator(config = {}) {
1389
1447
  }
1390
1448
  return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
1391
1449
  }
1450
+ /*
1451
+ * The CMS tab of the image picker.
1452
+ *
1453
+ * Two callers, one route. A library-mode site whose adapter implements
1454
+ * `getMedia` needs to send nothing: the credentials are already in the
1455
+ * process serving this request. The standalone multi-site orchestrator
1456
+ * wires no adapter at all, so the editor sends the per-site connection
1457
+ * details it holds and the route builds a reader from them.
1458
+ *
1459
+ * The adapter wins when both are available — a site that implemented the
1460
+ * seam meant it, and its own reader can see things a generic one cannot.
1461
+ *
1462
+ * POST rather than GET because the second form carries a token, and a
1463
+ * token in a query string is a token in every access log between here and
1464
+ * the browser.
1465
+ */
1466
+ if (request.method === "POST" && path === "/media/cms") {
1467
+ const runtime = await getRuntime();
1468
+ await runtime.ready;
1469
+ let raw;
1470
+ try {
1471
+ raw = await request.json();
1472
+ }
1473
+ catch {
1474
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1475
+ }
1476
+ const body = (raw ?? {});
1477
+ const query = typeof body.query === "string" ? body.query.trim() : "";
1478
+ const page = Math.max(1, Math.trunc(Number(body.page) || 1));
1479
+ const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
1480
+ const source = typeof runtime.adapter?.getMedia === "function"
1481
+ ? runtime.adapter.getMedia.bind(runtime.adapter)
1482
+ : mediaSourceFromUnknown(body.config);
1483
+ /* No adapter method and no usable config is "this site has no media
1484
+ * library", which is a 404 and not an empty page — the editor hides the
1485
+ * tab on the former and renders an empty grid on the latter. */
1486
+ if (!source)
1487
+ return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
1488
+ try {
1489
+ const result = await source({ query: query || undefined, page, limit });
1490
+ return jsonResponse(result, { cors });
1491
+ }
1492
+ catch (error) {
1493
+ runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
1494
+ return jsonResponse({ items: [], totalPages: 0 }, { cors });
1495
+ }
1496
+ }
1392
1497
  /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1393
1498
  * empty result set — "no key" and "no matches" are different answers. */
1394
1499
  if (request.method === "GET" && path === "/unsplash/search") {
@@ -19,7 +19,7 @@
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
20
  * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
- * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
22
+ * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
@@ -19,7 +19,7 @@
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
20
  * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
- * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
22
+ * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
- export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./cms/adapter.ts";
4
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, type JsonFileAdapterOptions, type EditorApiAdapterOptions } from "./cms/index.ts";
3
+ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
4
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
package/dist/index.js CHANGED
@@ -18,4 +18,4 @@
18
18
  //
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "./cms/index.js";
21
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
@@ -4,6 +4,23 @@ import type { ContentSource } from "../state/content-source.ts";
4
4
  import type { PublishDiff } from "@avocadostudio-ai/shared";
5
5
  export declare const toErrorDetail: typeof _unifiedToErrorDetail;
6
6
  export declare function isNoEffectiveChangeError(reason: string): boolean;
7
+ /**
8
+ * Within a no-effective-change, did the stored values already match?
9
+ *
10
+ * The engine throws two different things and they are not the same fact:
11
+ *
12
+ * "No effective prop change across plan. All update patches matched
13
+ * existing values." → the content really was already what was asked for
14
+ * "Edit plan produced no changes"
15
+ * → the plan had nothing left to apply, which is what
16
+ * happens when every prop in it was stripped as
17
+ * unsupported by the block
18
+ *
19
+ * Reporting the second as the first tells the author their content is up to
20
+ * date when their request was in fact dropped. Both messages are pinned by
21
+ * `isNoEffectiveChangeError` above, so they are already load-bearing strings.
22
+ */
23
+ export declare function isAlreadyCurrentError(reason: string): boolean;
7
24
  export declare function classifyGuardrailError(reason: string): GuardrailErrorCategory;
8
25
  export declare function formatValidationError(reason: string): string;
9
26
  export declare function isDeterministicRepairEligible(reason: string): boolean;
@@ -159,6 +159,25 @@ export function isNoEffectiveChangeError(reason) {
159
159
  // "No effective meta change for…" / "Edit plan produced no changes"
160
160
  return /no effective \w+ change/i.test(reason) || /produced no changes/i.test(reason);
161
161
  }
162
+ /**
163
+ * Within a no-effective-change, did the stored values already match?
164
+ *
165
+ * The engine throws two different things and they are not the same fact:
166
+ *
167
+ * "No effective prop change across plan. All update patches matched
168
+ * existing values." → the content really was already what was asked for
169
+ * "Edit plan produced no changes"
170
+ * → the plan had nothing left to apply, which is what
171
+ * happens when every prop in it was stripped as
172
+ * unsupported by the block
173
+ *
174
+ * Reporting the second as the first tells the author their content is up to
175
+ * date when their request was in fact dropped. Both messages are pinned by
176
+ * `isNoEffectiveChangeError` above, so they are already load-bearing strings.
177
+ */
178
+ export function isAlreadyCurrentError(reason) {
179
+ return /no effective \w+ change/i.test(reason);
180
+ }
162
181
  export function classifyGuardrailError(reason) {
163
182
  const lower = reason.toLowerCase();
164
183
  if (isNoEffectiveChangeError(reason))
@@ -404,7 +423,7 @@ function _resolveBlockIndex(blocks, blockId, fuzzyMatches) {
404
423
  * Drop an adapter's provenance keys from a block that is being copied.
405
424
  *
406
425
  * A projected block carries the adapter's answer to "which upstream thing is
407
- * this", and the adapter routes the write by it: Paintball Arena Bern marks a
426
+ * this", and the adapter routes the write by it: a field-level-i18n CMS marks a
408
427
  * block with `props._sectionId` and publishes anything carrying one into the
409
428
  * *shared section document* rather than into the page. Copied verbatim onto a
410
429
  * duplicate, the first edit to that duplicate's hero is written back into the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.2.3",
3
+ "version": "0.3.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.2.3",
27
- "@avocadostudio-ai/shared": "0.2.3"
26
+ "@avocadostudio-ai/migration-sdk": "^0.3.1",
27
+ "@avocadostudio-ai/shared": "^0.3.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",
@@ -56,7 +56,7 @@
56
56
  "visual-editing"
57
57
  ],
58
58
  "license": "Apache-2.0",
59
- "homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/orchestrator-core#readme",
59
+ "homepage": "https://docs.avocadostudio.dev",
60
60
  "bugs": {
61
61
  "url": "https://github.com/avocadostudio-ai/avocado/issues"
62
62
  },