@avocadostudio-ai/orchestrator-core 0.2.1 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @avocadostudio-ai/orchestrator-core
2
+
3
+ The Avocado Studio orchestrator as a library: session state, AI planning, the
4
+ operations engine, and publishing, behind one Web-standard
5
+ `(Request) => Promise<Response>`.
6
+
7
+ This is the package **library mode is built on**. If you are integrating a
8
+ Next.js site you almost certainly want
9
+ [`@avocadostudio-ai/site-sdk`](https://www.npmjs.com/package/@avocadostudio-ai/site-sdk)
10
+ instead — it re-exports everything below from
11
+ `@avocadostudio-ai/site-sdk/server` and adds the Next-specific pieces (draft
12
+ mode, the page factory, the editor API route). Reach for this package directly
13
+ only when your host is not Next.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @avocadostudio-ai/orchestrator-core
19
+ ```
20
+
21
+ `better-sqlite3` is a real dependency, not optional: session state — draft
22
+ pages, undo/redo, the version log, chat history — lives in SQLite. Prebuilt
23
+ binaries ship for linux-x64 (glibc 2.28+), darwin-arm64 and darwin-x64 on
24
+ Node 22.
25
+
26
+ Two peers are **optional**: `googleapis` and `@google/genai`. They are reached
27
+ through `await import(...)` so a deployment that uses neither need not install
28
+ them — but a bundler resolves dynamic imports statically and will fail the build
29
+ over a package that is deliberately absent. Mark them external. On Next,
30
+ `withAvocado` from `@avocadostudio-ai/site-sdk/next-config` does it for you.
31
+
32
+ ## Mount it
33
+
34
+ ```ts
35
+ import { createOrchestrator } from "@avocadostudio-ai/orchestrator-core"
36
+
37
+ const handler = createOrchestrator({
38
+ basePath: "/api/avocado",
39
+ adapter: myAdapter,
40
+ auth: async (request) => Boolean(await getSession(request)),
41
+ })
42
+
43
+ // Any host that speaks Request/Response
44
+ export { handler as GET, handler as POST, handler as OPTIONS }
45
+ ```
46
+
47
+ `createOrchestrator` gates **every** route. With neither an `auth` hook nor a
48
+ credential (`ACCESS_PASSWORD_HASH` or `ORCHESTRATOR_ACCESS_TOKEN`) it refuses
49
+ all requests under `NODE_ENV=production` rather than serving your content
50
+ openly. Only `/auth/status` and `/auth/verify` are public.
51
+
52
+ ## The adapter
53
+
54
+ `adapter` is your content store. SQLite is the working copy; the adapter is the
55
+ source of truth, read on a cold session and written on publish.
56
+
57
+ ```ts
58
+ import type { CmsAdapter } from "@avocadostudio-ai/orchestrator-core/cms"
59
+
60
+ const myAdapter: CmsAdapter = {
61
+ id: "my-cms",
62
+ perspectives: true, // does getPages honour options.perspective?
63
+ getPages: (options) => fetchPages(options),
64
+ onPublish: (pages, config, context) => writeBack(pages, context?.published),
65
+ capabilities: { createPage: false }, // static — /whoami answers it offline
66
+ }
67
+ ```
68
+
69
+ `jsonFileAdapter` and `editorApiAdapter` are bundled from the same subpath. The
70
+ full contract — including why `perspectives` defaults to *no* while capabilities
71
+ default to *yes*, and why `onPublish` has to diff rather than overwrite — is
72
+ documented in
73
+ [the site-sdk README](https://www.npmjs.com/package/@avocadostudio-ai/site-sdk#the-adapter-contract).
74
+
75
+ ## What it does not do
76
+
77
+ It serves the API. It does not serve the editor UI — that is
78
+ `@avocadostudio-ai/cli` or your own deployment of the Studio — and it does not
79
+ render your pages.
80
+
81
+ `/health` is **not** a route, so a client that probes it to check compatibility
82
+ gets a 405. The body lists every route that does exist.
83
+
84
+ ## Persistence
85
+
86
+ | variable | meaning |
87
+ |---|---|
88
+ | `ORCHESTRATOR_DB_FILE` | Path to the SQLite file. Empty for the default; `:memory:` to force ephemeral. Auto-`:memory:` under `NODE_ENV=test` |
89
+ | `ORCHESTRATOR_DB_BACKUP_INTERVAL_HOURS` | Periodic `VACUUM INTO` snapshot interval (default 24) |
90
+ | `ORCHESTRATOR_DB_BACKUP_LIMIT` | Rolling snapshots to keep (default 14) |
91
+
92
+ Undo/redo is capped at 50 entries per slug per direction, the version log at
93
+ 100, recent edits at 10, chat history at 6 messages.
94
+
95
+ ## Providers
96
+
97
+ At least one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GOOGLE_GENAI_API_KEY`
98
+ is required for planning. Keys never leave the process that holds them.
99
+
100
+ ## License
101
+
102
+ Apache-2.0
@@ -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
  }
@@ -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";
@@ -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
+ }
@@ -29,7 +29,13 @@ import { isAccessGateEnabled, isValidAccessToken, extractAccessToken } from "../
29
29
  * gating it would blank every uploaded image in the live site, not just in the
30
30
  * editor. Both are read-only and neither reveals session content.
31
31
  */
32
- const PUBLIC_PATHS = new Set(["/auth/status", "/auth/verify"]);
32
+ /*
33
+ * `/health` is here because a probe that needs a credential is not a probe. The
34
+ * CLI calls it before it has one, to compare protocol versions, and it answers
35
+ * nothing about the site's content — only that this is an Avocado orchestrator
36
+ * and which protocol it speaks.
37
+ */
38
+ const PUBLIC_PATHS = new Set(["/auth/status", "/auth/verify", "/health"]);
33
39
  export function isPublicPath(path, method) {
34
40
  if (method === "OPTIONS")
35
41
  return true;
@@ -21,7 +21,7 @@ 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 } from "@avocadostudio-ai/shared";
24
+ import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION } from "@avocadostudio-ai/shared";
25
25
  import { chatRequestBodySchema } from "../nlp/intent-detection.js";
26
26
  import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
27
27
  import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
@@ -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 = () => ({
@@ -196,6 +197,7 @@ function jsonResponse(body, init = {}) {
196
197
  * only thing that keeps a list like this honest.
197
198
  */
198
199
  const SUPPORTED_ROUTES = [
200
+ "GET /health",
199
201
  "GET /auth/status",
200
202
  "POST /auth/verify",
201
203
  "POST /chat",
@@ -228,6 +230,7 @@ const SUPPORTED_ROUTES = [
228
230
  "POST /restore/snapshot",
229
231
  "DELETE /restore/snapshot",
230
232
  "GET /unsplash/search",
233
+ "POST /media/cms",
231
234
  "GET+POST /telemetry/chat/feedback",
232
235
  "POST /preview/screenshot",
233
236
  "POST /audio/transcribe",
@@ -834,6 +837,10 @@ export function createOrchestrator(config = {}) {
834
837
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
835
838
  imageGenerate: hasImageBackend,
836
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,
837
844
  agentMode: false
838
845
  },
839
846
  /*
@@ -899,7 +906,9 @@ export function createOrchestrator(config = {}) {
899
906
  })),
900
907
  // Which content this is — see orchestrator-core/http/draft-provenance.ts.
901
908
  ...describeDraft({
902
- requestedSiteId: siteId ?? effectiveSiteId,
909
+ // Config first, matching every other route: a mount that names its
910
+ // site knows better than a request that forgot to.
911
+ requestedSiteId: effectiveSiteId ?? siteId,
903
912
  scopedSession,
904
913
  pageCount: pages.length,
905
914
  hasAdapter: Boolean(runtime.adapter)
@@ -1243,12 +1252,38 @@ export function createOrchestrator(config = {}) {
1243
1252
  }
1244
1253
  return actionResponse(blocksManifestAction(), cors);
1245
1254
  }
1255
+ /*
1256
+ * Liveness and protocol version, for a client deciding whether it can talk
1257
+ * to this build at all.
1258
+ *
1259
+ * The standalone orchestrator has always answered this; library mode never
1260
+ * did, so `avocado-cli start` probed it, got a 405, and printed
1261
+ * "Orchestrator /health returned 405. CLI started anyway." on every start —
1262
+ * a warning that means nothing, from the CLI's only compatibility check,
1263
+ * which could therefore never pass against an embedded mount.
1264
+ *
1265
+ * Deliberately before the auth gate (see `PUBLIC_PATHS`) and deliberately
1266
+ * content-free: it says what this process is, never what it holds.
1267
+ */
1268
+ if (request.method === "GET" && path === "/health") {
1269
+ return jsonResponse({ ok: true, mode: "library", protocolVersion: EDITOR_PROTOCOL_VERSION }, { cors });
1270
+ }
1246
1271
  if (request.method === "GET" && path === "/whoami") {
1247
1272
  const runtime = await getRuntime();
1248
1273
  await runtime.ready;
1249
1274
  const query = Object.fromEntries(url.searchParams);
1250
1275
  await runtime.bootstrapCache.ensure(scope(query.session, query.siteId), runtime.adapter, runtime.log);
1251
- return actionResponse(whoamiAction(query, url.origin, { hasAdapter: Boolean(runtime.adapter) }), cors);
1276
+ /*
1277
+ * `scope()` resolves the site the mount serves; `whoamiAction` re-derives
1278
+ * the session key from what it is handed. Handing it the raw query meant
1279
+ * the two disagreed whenever the caller omitted `siteId` — the bootstrap
1280
+ * seeded `library::default` and the answer was read from the legacy key,
1281
+ * so a correctly configured mount reported Avocado's bundled demo
1282
+ * content, `source: "demo"`, and all-true capabilities in place of the
1283
+ * adapter's own. The note even said "No siteId was supplied", which was
1284
+ * true of the request and false of the configuration.
1285
+ */
1286
+ return actionResponse(whoamiAction({ ...query, siteId: effectiveSiteId ?? query.siteId }, url.origin, { hasAdapter: Boolean(runtime.adapter) }), cors);
1252
1287
  }
1253
1288
  /*
1254
1289
  * Publishing status, history and preview.
@@ -1360,6 +1395,53 @@ export function createOrchestrator(config = {}) {
1360
1395
  }
1361
1396
  return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
1362
1397
  }
1398
+ /*
1399
+ * The CMS tab of the image picker.
1400
+ *
1401
+ * Two callers, one route. A library-mode site whose adapter implements
1402
+ * `getMedia` needs to send nothing: the credentials are already in the
1403
+ * process serving this request. The standalone multi-site orchestrator
1404
+ * wires no adapter at all, so the editor sends the per-site connection
1405
+ * details it holds and the route builds a reader from them.
1406
+ *
1407
+ * The adapter wins when both are available — a site that implemented the
1408
+ * seam meant it, and its own reader can see things a generic one cannot.
1409
+ *
1410
+ * POST rather than GET because the second form carries a token, and a
1411
+ * token in a query string is a token in every access log between here and
1412
+ * the browser.
1413
+ */
1414
+ if (request.method === "POST" && path === "/media/cms") {
1415
+ const runtime = await getRuntime();
1416
+ await runtime.ready;
1417
+ let raw;
1418
+ try {
1419
+ raw = await request.json();
1420
+ }
1421
+ catch {
1422
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1423
+ }
1424
+ const body = (raw ?? {});
1425
+ const query = typeof body.query === "string" ? body.query.trim() : "";
1426
+ const page = Math.max(1, Math.trunc(Number(body.page) || 1));
1427
+ const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
1428
+ const source = typeof runtime.adapter?.getMedia === "function"
1429
+ ? runtime.adapter.getMedia.bind(runtime.adapter)
1430
+ : mediaSourceFromUnknown(body.config);
1431
+ /* No adapter method and no usable config is "this site has no media
1432
+ * library", which is a 404 and not an empty page — the editor hides the
1433
+ * tab on the former and renders an empty grid on the latter. */
1434
+ if (!source)
1435
+ return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
1436
+ try {
1437
+ const result = await source({ query: query || undefined, page, limit });
1438
+ return jsonResponse(result, { cors });
1439
+ }
1440
+ catch (error) {
1441
+ runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
1442
+ return jsonResponse({ items: [], totalPages: 0 }, { cors });
1443
+ }
1444
+ }
1363
1445
  /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1364
1446
  * empty result set — "no key" and "no matches" are different answers. */
1365
1447
  if (request.method === "GET" && path === "/unsplash/search") {
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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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.1",
27
- "@avocadostudio-ai/shared": "0.2.1"
26
+ "@avocadostudio-ai/migration-sdk": "^0.3.0",
27
+ "@avocadostudio-ai/shared": "^0.3.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",
@@ -44,6 +44,17 @@
44
44
  "dist"
45
45
  ],
46
46
  "description": "Core Avocado Studio orchestrator — session state, AI planning, operations engine, publishing",
47
+ "keywords": [
48
+ "avocado",
49
+ "avocado-studio",
50
+ "cms",
51
+ "ai",
52
+ "llm",
53
+ "content-editor",
54
+ "page-builder",
55
+ "headless-cms",
56
+ "visual-editing"
57
+ ],
47
58
  "license": "Apache-2.0",
48
59
  "homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/orchestrator-core#readme",
49
60
  "bugs": {