@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.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.
Files changed (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Thumbs-up / thumbs-down on a chat trace, as transport-agnostic actions.
3
+ *
4
+ * `POST /telemetry/chat/feedback` was written inline in
5
+ * `apps/orchestrator/src/index.ts` rather than in a route plugin, so library
6
+ * mode — `createOrchestrator()` in the site SDK, which reimplements by hand
7
+ * whichever routes somebody remembered to add — never got it. The editor sends
8
+ * the rating best-effort and ignores the response, so on a library-mode site
9
+ * every thumb the user clicked was answered "not handled by
10
+ * createOrchestrator()" and silently thrown away: the UI kept showing the
11
+ * button as accepted and no signal ever reached the store.
12
+ *
13
+ * A second hand-kept copy would drift the way the first one did, so the
14
+ * validation lives here and both transports call it. Each function returns the
15
+ * status code and body to send; neither Fastify nor `Response` appears in this
16
+ * file.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ const badRequest = (error) => ({ code: 400, body: { error } });
20
+ /** Free-text notes are stored verbatim in an append-only file, so cap what one row can cost. */
21
+ const NOTE_MAX_CHARS = 2000;
22
+ /**
23
+ * Record one rating against a chat trace.
24
+ *
25
+ * The rating is compared against the two literals rather than merely checked
26
+ * for being a string: the store's readers filter and count by rating, and a
27
+ * third value would quietly split the totals. An unusable payload is rejected
28
+ * rather than stored partially, because the editor fires this and forgets it —
29
+ * a row nobody can attribute to a trace is worse than no row.
30
+ */
31
+ export function telemetryFeedbackSubmitAction(raw, store) {
32
+ const body = (raw ?? {});
33
+ const traceId = typeof body.traceId === "string" ? body.traceId.trim() : "";
34
+ const session = typeof body.session === "string" ? body.session.trim() : "";
35
+ const rating = body.rating === "up" || body.rating === "down" ? body.rating : null;
36
+ const note = typeof body.note === "string" ? body.note.trim().slice(0, NOTE_MAX_CHARS) : undefined;
37
+ if (!traceId || !session || !rating) {
38
+ return badRequest("traceId, session, and rating ('up' | 'down') are required");
39
+ }
40
+ const entry = {
41
+ id: randomUUID(),
42
+ at: new Date().toISOString(),
43
+ traceId,
44
+ session,
45
+ rating,
46
+ ...(note ? { note } : {})
47
+ };
48
+ store.push(entry);
49
+ return { code: 200, body: { ok: true, id: entry.id } };
50
+ }
51
+ /**
52
+ * Read the ratings back, filtered.
53
+ *
54
+ * `limit` arrives as a query string over HTTP and as a number from anything
55
+ * calling this directly, so it is coerced here; the store clamps the range.
56
+ * Left absent rather than defaulted so the store's own default still applies.
57
+ */
58
+ export function telemetryFeedbackListAction(query, store) {
59
+ return {
60
+ code: 200,
61
+ body: store.list({
62
+ session: query.session,
63
+ rating: query.rating,
64
+ traceId: query.traceId,
65
+ limit: query.limit !== undefined ? Number(query.limit) : undefined
66
+ })
67
+ };
68
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The Unsplash search proxy behind the editor's image picker, as a
3
+ * transport-agnostic action.
4
+ *
5
+ * It lived only in `apps/orchestrator/src/routes/media.ts`, wired straight into
6
+ * Fastify. Library mode — `createOrchestrator()` in the site SDK — serves the
7
+ * same editor from a plain `Request`/`Response` handler and reimplements
8
+ * whichever routes somebody remembered to add. It never had this one, so the
9
+ * picker's Unsplash tab, which `/status/planner` advertises from the very same
10
+ * `UNSPLASH_ACCESS_KEY`, rendered and then answered "not handled by
11
+ * createOrchestrator()".
12
+ *
13
+ * The route is entirely session-free: no SQLite, no runtime, nothing from
14
+ * `session-state.js`. That is why it can be dispatched before a host's
15
+ * orchestrator runtime is ready, and why this file needs no collaborators
16
+ * beyond an access key and a `fetch`.
17
+ */
18
+ import type { Logger } from "../logger.js";
19
+ import type { ActionResult } from "./history-actions.js";
20
+ export type { ActionResult };
21
+ /**
22
+ * Raw query values, straight off the wire.
23
+ *
24
+ * Fastify hands these over as strings; `Object.fromEntries(url.searchParams)`
25
+ * does the same. Numbers are accepted too so a direct caller (a test, an MCP
26
+ * tool) doesn't have to stringify first.
27
+ */
28
+ export type UnsplashSearchQuery = {
29
+ q?: string;
30
+ limit?: string | number;
31
+ page?: string | number;
32
+ };
33
+ export type UnsplashSearchDeps = {
34
+ /** Overrides `process.env.UNSPLASH_ACCESS_KEY`. An empty string means "not configured". */
35
+ accessKey?: string;
36
+ /** Seam for tests: the whole point of this action is that it can be exercised without the network. */
37
+ fetchImpl?: typeof fetch;
38
+ log?: Logger;
39
+ };
40
+ /** One picker row. `thumbUrl` falls back to the full image when Unsplash omits the small variant. */
41
+ export type UnsplashSearchItem = {
42
+ id: string;
43
+ imageUrl: string;
44
+ thumbUrl: string;
45
+ alt: string;
46
+ author: string;
47
+ };
48
+ export type UnsplashSearchBody = {
49
+ items: UnsplashSearchItem[];
50
+ totalPages: number;
51
+ };
52
+ /**
53
+ * Search Unsplash for picker candidates.
54
+ *
55
+ * Every failure that isn't "no key at all" answers 200 with an empty result
56
+ * set. That is deliberately preserved: both clients — the editor's
57
+ * ImagePickerModal and the immersive widget's — read `{ items, totalPages }`
58
+ * off a successful response and treat a non-ok status as a hard error banner,
59
+ * so promoting an upstream 403 to a 5xx here would turn a rate limit into a
60
+ * broken tab. The cost is that a revoked key looks exactly like "no photos
61
+ * match", which is invisible in both transports — hence the `log.warn`, the one
62
+ * thing this action adds over the Fastify original.
63
+ */
64
+ export declare function unsplashSearchAction(query: UnsplashSearchQuery, deps?: UnsplashSearchDeps): Promise<ActionResult>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The Unsplash search proxy behind the editor's image picker, as a
3
+ * transport-agnostic action.
4
+ *
5
+ * It lived only in `apps/orchestrator/src/routes/media.ts`, wired straight into
6
+ * Fastify. Library mode — `createOrchestrator()` in the site SDK — serves the
7
+ * same editor from a plain `Request`/`Response` handler and reimplements
8
+ * whichever routes somebody remembered to add. It never had this one, so the
9
+ * picker's Unsplash tab, which `/status/planner` advertises from the very same
10
+ * `UNSPLASH_ACCESS_KEY`, rendered and then answered "not handled by
11
+ * createOrchestrator()".
12
+ *
13
+ * The route is entirely session-free: no SQLite, no runtime, nothing from
14
+ * `session-state.js`. That is why it can be dispatched before a host's
15
+ * orchestrator runtime is ready, and why this file needs no collaborators
16
+ * beyond an access key and a `fetch`.
17
+ */
18
+ // A fresh object each time: a single shared literal would let one caller's
19
+ // mutation of the response body leak into every later empty result.
20
+ const empty = () => ({ code: 200, body: { items: [], totalPages: 0 } });
21
+ /**
22
+ * Search Unsplash for picker candidates.
23
+ *
24
+ * Every failure that isn't "no key at all" answers 200 with an empty result
25
+ * set. That is deliberately preserved: both clients — the editor's
26
+ * ImagePickerModal and the immersive widget's — read `{ items, totalPages }`
27
+ * off a successful response and treat a non-ok status as a hard error banner,
28
+ * so promoting an upstream 403 to a 5xx here would turn a rate limit into a
29
+ * broken tab. The cost is that a revoked key looks exactly like "no photos
30
+ * match", which is invisible in both transports — hence the `log.warn`, the one
31
+ * thing this action adds over the Fastify original.
32
+ */
33
+ export async function unsplashSearchAction(query, deps = {}) {
34
+ const accessKey = (deps.accessKey ?? process.env.UNSPLASH_ACCESS_KEY)?.trim();
35
+ if (!accessKey)
36
+ return { code: 404, body: { error: "Unsplash not configured" } };
37
+ const q = typeof query.q === "string" ? query.q.trim() : "";
38
+ if (!q)
39
+ return empty();
40
+ // `Number("") || 8` is why an absent or unparseable value lands on the
41
+ // default rather than on NaN; the clamp then keeps a hand-edited URL from
42
+ // asking Unsplash for a page size it will reject outright.
43
+ const limit = Math.min(20, Math.max(1, Number(query.limit) || 8));
44
+ const page = Math.max(1, Math.trunc(Number(query.page) || 1));
45
+ const doFetch = deps.fetchImpl ?? fetch;
46
+ try {
47
+ const endpoint = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&orientation=landscape&per_page=${limit}&page=${page}&content_filter=high`;
48
+ const res = await doFetch(endpoint, {
49
+ headers: { Authorization: `Client-ID ${accessKey}`, "Accept-Version": "v1" }
50
+ });
51
+ if (!res.ok) {
52
+ deps.log?.warn({ status: res.status, q }, "unsplash search failed; returning an empty result set");
53
+ return empty();
54
+ }
55
+ const payload = (await res.json());
56
+ const items = (payload.results ?? [])
57
+ .map((r) => {
58
+ const imageUrl = typeof r.urls?.regular === "string" ? r.urls.regular : "";
59
+ const thumbUrl = typeof r.urls?.small === "string" ? r.urls.small : imageUrl;
60
+ return {
61
+ id: r.id ?? "",
62
+ imageUrl,
63
+ thumbUrl,
64
+ alt: typeof r.alt_description === "string"
65
+ ? r.alt_description
66
+ : typeof r.description === "string"
67
+ ? r.description
68
+ : "",
69
+ author: r.user?.name ?? "Unsplash"
70
+ };
71
+ })
72
+ // A row with no id or no URL cannot be selected or rendered, so it would
73
+ // only ever show up as a broken tile.
74
+ .filter((item) => item.id && item.imageUrl);
75
+ return { code: 200, body: { items, totalPages: payload.total_pages ?? 0 } };
76
+ }
77
+ catch (err) {
78
+ deps.log?.warn({ err, q }, "unsplash search threw; returning an empty result set");
79
+ return empty();
80
+ }
81
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * "Give me three ways to word this hero" — the variations endpoints, as
3
+ * transport-agnostic actions.
4
+ *
5
+ * The pipeline itself already lives in core and already speaks `{ code,
6
+ * payload }`. What was still duplicated is everything around it: the 400 body
7
+ * for a malformed request, and — worse — the SSE *frame vocabulary* of the
8
+ * streaming variant, which existed only inside the Fastify handler. Library
9
+ * mode (`createOrchestrator()` in the site SDK) answered "not handled" for both
10
+ * routes, so a second transport had to invent `variations_ready` /
11
+ * `image_resolved` / `done` / `error` from scratch and stay in step with a
12
+ * client that parses the frames by hand. Frame names drift silently: nothing
13
+ * type-checks a string written on one side of a socket against the string read
14
+ * on the other.
15
+ *
16
+ * So the frames are built here, once, and `formatVariationFrame` writes the
17
+ * wire bytes for callers that don't already have an SSE writer. Neither Fastify
18
+ * nor `Response` appears in this file.
19
+ */
20
+ import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.js";
21
+ import type { ActionResult } from "./history-actions.js";
22
+ export type { ActionResult };
23
+ export type { VariationRequestBody, VariationPipelineContext };
24
+ export type ParsedVariationRequest = {
25
+ ok: true;
26
+ body: VariationRequestBody;
27
+ } | {
28
+ ok: false;
29
+ result: ActionResult;
30
+ };
31
+ /**
32
+ * Validate a raw request body, kept deliberately separate from the actions.
33
+ *
34
+ * Both transports need to do work *between* parsing and running: library mode
35
+ * awaits its bootstrap cache so the adapter's pages are in session state before
36
+ * the pipeline reads them, and it can only do that once it knows the scoped
37
+ * session key. Folding the parse into the actions would either hide that step
38
+ * or force this file to know about an adapter it has no business knowing about.
39
+ */
40
+ export declare function parseVariationRequest(raw: unknown): ParsedVariationRequest;
41
+ /**
42
+ * The session key the pipeline should read and write under.
43
+ *
44
+ * Every route that touches session state scopes the raw `session` with the
45
+ * request's `siteId` before using it, and the variations routes are no
46
+ * exception: unscoped, a multi-site request reads a draft that belongs to
47
+ * nobody, so `getPage` misses and every variation request 404s. The
48
+ * normalization matters just as much — a request with no `session` at all
49
+ * becomes the default key here, where leaving it `undefined` would instead trip
50
+ * the pipeline's "session, slug, and message are required" guard and skip
51
+ * `rememberVariations`, so a follow-up "regenerate" would read an empty cache.
52
+ *
53
+ * `siteIdOverride` exists for library mode, which pins the site from
54
+ * `createOrchestrator()` config and deliberately ignores whatever `siteId` the
55
+ * body carries; scoping from the body there would address a different session
56
+ * than the one its bootstrap cache just filled.
57
+ */
58
+ export declare function scopeVariationSession(body: VariationRequestBody, siteIdOverride?: string): string;
59
+ /**
60
+ * Generate variations and return them in one response.
61
+ *
62
+ * `session` is a separate required argument rather than something read off
63
+ * `body` because the two transports derive it differently (see
64
+ * `scopeVariationSession`) and a body whose `session` was never scoped
65
+ * type-checks perfectly — the mistake would only ever show up as an empty
66
+ * draft at runtime. Making it explicit is what stops a future call site from
67
+ * silently reintroducing that.
68
+ */
69
+ export declare function variationsAction(ctx: VariationPipelineContext, body: VariationRequestBody, session: string): Promise<ActionResult>;
70
+ /**
71
+ * The SSE frames the streaming variant emits, in the order they can occur:
72
+ * one `variations_ready` (text-only variants, so the modal can open in ~1s),
73
+ * then one `image_resolved` per variant as its image finishes, then exactly one
74
+ * terminal `done` or `error`.
75
+ *
76
+ * The `event` name travels *inside* the JSON payload rather than as an SSE
77
+ * `event:` line, because the client reads this with fetch + `getReader()`
78
+ * rather than `EventSource` and would never see the line.
79
+ */
80
+ export type VariationStreamFrame = ({
81
+ event: "variations_ready";
82
+ imagesPending: boolean;
83
+ } & VariationResult) | ({
84
+ event: "image_resolved";
85
+ } & VariationImageUpdate) | ({
86
+ event: "done";
87
+ } & VariationResult) | {
88
+ event: "error";
89
+ error: string;
90
+ };
91
+ /** The wire encoding of one frame. Identical to what `sseWrite` produces. */
92
+ export declare function formatVariationFrame(frame: VariationStreamFrame): string;
93
+ /**
94
+ * Generate variations, emitting frames as they become available.
95
+ *
96
+ * Resolves once the terminal frame has been emitted; ending the stream stays
97
+ * with the transport, which owns the socket and has to close it on client
98
+ * disconnect too. A thrown pipeline is reported as an `error` frame rather than
99
+ * rethrown: the response status is long since committed by the time images
100
+ * resolve, so the only way to tell the client anything is in-band.
101
+ */
102
+ export declare function variationsStreamAction(ctx: VariationPipelineContext, body: VariationRequestBody, session: string, emit: (frame: VariationStreamFrame) => void): Promise<void>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * "Give me three ways to word this hero" — the variations endpoints, as
3
+ * transport-agnostic actions.
4
+ *
5
+ * The pipeline itself already lives in core and already speaks `{ code,
6
+ * payload }`. What was still duplicated is everything around it: the 400 body
7
+ * for a malformed request, and — worse — the SSE *frame vocabulary* of the
8
+ * streaming variant, which existed only inside the Fastify handler. Library
9
+ * mode (`createOrchestrator()` in the site SDK) answered "not handled" for both
10
+ * routes, so a second transport had to invent `variations_ready` /
11
+ * `image_resolved` / `done` / `error` from scratch and stay in step with a
12
+ * client that parses the frames by hand. Frame names drift silently: nothing
13
+ * type-checks a string written on one side of a socket against the string read
14
+ * on the other.
15
+ *
16
+ * So the frames are built here, once, and `formatVariationFrame` writes the
17
+ * wire bytes for callers that don't already have an SSE writer. Neither Fastify
18
+ * nor `Response` appears in this file.
19
+ */
20
+ import { scopedSessionKey } from "../state/session-state.js";
21
+ import { variationRequestBodySchema, runVariationPipeline, runVariationPipelineStreaming } from "../chat/variation-pipeline.js";
22
+ /**
23
+ * Validate a raw request body, kept deliberately separate from the actions.
24
+ *
25
+ * Both transports need to do work *between* parsing and running: library mode
26
+ * awaits its bootstrap cache so the adapter's pages are in session state before
27
+ * the pipeline reads them, and it can only do that once it knows the scoped
28
+ * session key. Folding the parse into the actions would either hide that step
29
+ * or force this file to know about an adapter it has no business knowing about.
30
+ */
31
+ export function parseVariationRequest(raw) {
32
+ const parsed = variationRequestBodySchema.safeParse(raw);
33
+ if (!parsed.success) {
34
+ return { ok: false, result: { code: 400, body: { error: "invalid request body", details: parsed.error.issues } } };
35
+ }
36
+ return { ok: true, body: parsed.data };
37
+ }
38
+ /**
39
+ * The session key the pipeline should read and write under.
40
+ *
41
+ * Every route that touches session state scopes the raw `session` with the
42
+ * request's `siteId` before using it, and the variations routes are no
43
+ * exception: unscoped, a multi-site request reads a draft that belongs to
44
+ * nobody, so `getPage` misses and every variation request 404s. The
45
+ * normalization matters just as much — a request with no `session` at all
46
+ * becomes the default key here, where leaving it `undefined` would instead trip
47
+ * the pipeline's "session, slug, and message are required" guard and skip
48
+ * `rememberVariations`, so a follow-up "regenerate" would read an empty cache.
49
+ *
50
+ * `siteIdOverride` exists for library mode, which pins the site from
51
+ * `createOrchestrator()` config and deliberately ignores whatever `siteId` the
52
+ * body carries; scoping from the body there would address a different session
53
+ * than the one its bootstrap cache just filled.
54
+ */
55
+ export function scopeVariationSession(body, siteIdOverride) {
56
+ return scopedSessionKey(body.session, siteIdOverride ?? body.siteId);
57
+ }
58
+ /**
59
+ * Generate variations and return them in one response.
60
+ *
61
+ * `session` is a separate required argument rather than something read off
62
+ * `body` because the two transports derive it differently (see
63
+ * `scopeVariationSession`) and a body whose `session` was never scoped
64
+ * type-checks perfectly — the mistake would only ever show up as an empty
65
+ * draft at runtime. Making it explicit is what stops a future call site from
66
+ * silently reintroducing that.
67
+ */
68
+ export async function variationsAction(ctx, body, session) {
69
+ const { code, payload } = await runVariationPipeline(ctx, { ...body, session });
70
+ return { code, body: payload };
71
+ }
72
+ /** The wire encoding of one frame. Identical to what `sseWrite` produces. */
73
+ export function formatVariationFrame(frame) {
74
+ return `data: ${JSON.stringify(frame)}\n\n`;
75
+ }
76
+ /**
77
+ * Generate variations, emitting frames as they become available.
78
+ *
79
+ * Resolves once the terminal frame has been emitted; ending the stream stays
80
+ * with the transport, which owns the socket and has to close it on client
81
+ * disconnect too. A thrown pipeline is reported as an `error` frame rather than
82
+ * rethrown: the response status is long since committed by the time images
83
+ * resolve, so the only way to tell the client anything is in-band.
84
+ */
85
+ export async function variationsStreamAction(ctx, body, session, emit) {
86
+ try {
87
+ const result = await runVariationPipelineStreaming(ctx, { ...body, session }, {
88
+ onInitial: (initial) => emit({ event: "variations_ready", ...initial }),
89
+ onImageResolved: (update) => emit({ event: "image_resolved", ...update })
90
+ });
91
+ // The `error` test is redundant with the status check — a 200 payload is
92
+ // always a VariationResult — but it is what narrows the union for the
93
+ // `done` spread below, so it stays.
94
+ if (result.code !== 200 || "error" in result.payload) {
95
+ emit({ event: "error", error: "error" in result.payload ? result.payload.error : "unknown" });
96
+ return;
97
+ }
98
+ emit({ event: "done", ...result.payload });
99
+ }
100
+ catch (err) {
101
+ ctx.log.warn({ err }, "variations stream failed");
102
+ emit({ event: "error", error: err instanceof Error ? err.message : "stream failed" });
103
+ }
104
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
- export {};
1
+ export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
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";
package/dist/index.js CHANGED
@@ -1 +1,21 @@
1
- export {};
1
+ // @avocadostudio-ai/orchestrator-core
2
+ //
3
+ // Transport-agnostic "brain" of the orchestrator: planner, ops engine, session
4
+ // state, NLP, publish helpers, image generation, tools, telemetry, eval — plus
5
+ // `createOrchestrator`, the Web-standard handler that mounts all of it at a
6
+ // route in someone else's Next.js app.
7
+ //
8
+ // THIS FILE IS THE PACKAGE'S PUBLIC API. If a symbol is not exported here it is
9
+ // not part of the contract, and a consumer installing from the registry cannot
10
+ // reach it: `publishConfig.exports` lists `.` and `./cms` and nothing else.
11
+ //
12
+ // The development `exports` map still carries `"./*": "./src/*.ts"` wildcards,
13
+ // because apps/orchestrator (the Fastify host, never published) resolves 99
14
+ // files through them. That asymmetry is deliberate and guarded — see
15
+ // packages/shared/src/published-exports.test.ts, which fails the build if a
16
+ // wildcard reappears in publishConfig or if a published package deep-imports
17
+ // another published package outside its published subpaths.
18
+ //
19
+ // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
+ export { createOrchestrator } from "./handler/create-orchestrator.js";
21
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "./cms/index.js";
@@ -6,7 +6,7 @@ export declare function resolveBlockRef(args: {
6
6
  activeBlockId?: string;
7
7
  fallbackType?: BlockType | null;
8
8
  }): PageDoc["blocks"][number] | null;
9
- export declare function ordinalToIndex(value: string): 1 | 0 | -1 | 2 | 3 | 4 | null;
9
+ export declare function ordinalToIndex(value: string): 2 | 0 | 1 | -1 | 3 | 4 | null;
10
10
  export declare function resolveByDescriptor(args: {
11
11
  descriptor: string;
12
12
  currentPage: PageDoc;
@@ -34,6 +34,16 @@ type BlockContract = {
34
34
  allowedProps: string[];
35
35
  required: string[];
36
36
  optional?: string[];
37
+ /**
38
+ * Props whose value is rendered as markdown, as `key` or `listKey[].key`.
39
+ *
40
+ * Without this the planner cannot tell `RichText.body` from `Hero.heading`:
41
+ * the contract carries prop *names* and nothing else, so the whole rich-text
42
+ * capability was invisible to chat and only reachable by hand in the property
43
+ * panel. Derived from field metadata rather than written by hand, so a block
44
+ * that gains a richtext field announces it without anyone remembering to.
45
+ */
46
+ richTextProps?: string[];
37
47
  notes: string;
38
48
  };
39
49
  /**
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { allowedBlockTypes, blockSchemas, getAllBlockMeta, getBlockMeta, getPropDisplayName, deriveFieldMetaFromSchema } from "@avocadostudio-ai/shared";
2
+ import { allowedBlockTypes, blockSchemas, getAllBlockMeta, getBlockMeta, getPropDisplayName, resolveManifestFieldMeta } from "@avocadostudio-ai/shared";
3
3
  export function editablePropsFromBlock(block) {
4
4
  if (!block || !block.props || typeof block.props !== "object")
5
5
  return [];
@@ -463,10 +463,25 @@ const _blockNotes = {
463
463
  CardGrid: "cards is a non-empty array of {title, description, ctaText, ctaHref, imageUrl?, imageAlt?}. columns controls the grid layout: '2', '3' (default), or '4'. cardVariant can be 'default' or 'full-bleed' — applies to all cards in the grid. Use 'full-bleed' for background images with dark overlay and white text (requires imageUrl on cards). subtitle is optional.",
464
464
  Tabs: "tabs is a non-empty array of {label, content}. content supports richtext markdown. title is an optional section heading above the tab bar.",
465
465
  Banner: "Full-width announcement bar. variant sets preset theme: 'info' (default blue), 'success' (green), or 'warning' (amber). For custom brand colors, set backgroundColor and/or textColor (CSS color strings, e.g. '#1a1a2e' or 'rgb(26,26,46)'). Custom colors override the variant background/text. ctaText/ctaHref are optional: set both to show a button.",
466
- FAQAccordion: "items is a non-empty array of {q, a}. The answer (a) supports richtext: **bold**, *italic*, [link](url), lists (- item), and paragraph breaks (\\n\\n).",
467
- TwoColumn: "Image + text side-by-side layout. imagePosition is 'left' or 'right' (default 'right'). body supports inline markdown (**bold**, *italic*, [link](url)). ctaText/ctaHref are optional: set both to show a CTA button. For imageUrl: use any placeholder value (the system resolves images separately).",
466
+ FAQAccordion: "items is a non-empty array of {q, a}. The question (q) is plain text; the answer (a) is a richtext prop.",
467
+ TwoColumn: "Image + text side-by-side layout. left and right are arrays of typed children; each child's `type` decides how its `text` is read — for type 'paragraph' the text is rendered as markdown, for type 'heading' it is plain text. (That is why `text` is not listed in richTextProps: it depends on the sibling `type`.) ctaText/ctaHref are optional: set both to show a CTA button. For imageUrl: use any placeholder value (the system resolves images separately).",
468
468
  Footer: "columns must be a non-empty array of {title, links}. links is a string with one 'Label|URL' per line (use \\n to separate). Example: 'Home|/\\nAbout|/about\\nBlog|/blog'."
469
469
  };
470
+ /** Collect the markdown-bearing props of a block, flat and list-item alike. */
471
+ function collectRichTextProps(fields, listFields) {
472
+ const props = [];
473
+ for (const [key, field] of Object.entries(fields ?? {})) {
474
+ if (field.kind === "richtext")
475
+ props.push(key);
476
+ }
477
+ for (const [listKey, listMeta] of Object.entries(listFields ?? {})) {
478
+ for (const [itemKey, itemMeta] of Object.entries(listMeta.itemFields)) {
479
+ if (itemMeta.kind === "richtext")
480
+ props.push(`${listKey}[].${itemKey}`);
481
+ }
482
+ }
483
+ return props;
484
+ }
470
485
  /**
471
486
  * Derive block contracts from the registry so new blocks are automatically
472
487
  * included without maintaining a parallel hardcoded map.
@@ -479,13 +494,19 @@ const _blockNotes = {
479
494
  export function blockContractsSummary(manifest) {
480
495
  const allMeta = getAllBlockMeta();
481
496
  const result = {};
482
- // Build a lookup of manifest block types → propsSchema properties
497
+ /*
498
+ * A lookup of manifest block types → the whole definition, not just its
499
+ * properties. The definition may carry the site's own field metadata, and
500
+ * `kind` is what decides whether a prop is described to the planner as rich
501
+ * text — dropping it here is how a site's prose field ended up presented as
502
+ * a plain string in the block contract.
503
+ */
483
504
  const manifestByType = new Map();
484
505
  if (manifest) {
485
506
  for (const def of manifest.blocks) {
486
507
  const props = def.propsSchema?.properties;
487
508
  if (props && typeof props === "object" && !Array.isArray(props)) {
488
- manifestByType.set(def.type, props);
509
+ manifestByType.set(def.type, def);
489
510
  }
490
511
  }
491
512
  }
@@ -508,9 +529,9 @@ export function blockContractsSummary(manifest) {
508
529
  }
509
530
  }
510
531
  // Merge manifest-declared props that the shared Zod schema doesn't know about
511
- const manifestProps = manifestByType.get(type);
512
- if (manifestProps) {
513
- for (const key of Object.keys(manifestProps)) {
532
+ const manifestDef = manifestByType.get(type);
533
+ if (manifestDef) {
534
+ for (const key of Object.keys(manifestDef.propsSchema.properties ?? {})) {
514
535
  if (!allProps.includes(key)) {
515
536
  allProps.push(key);
516
537
  optional.push(key);
@@ -556,12 +577,15 @@ export function blockContractsSummary(manifest) {
556
577
  };
557
578
  if (optional.length > 0)
558
579
  entry.optional = optional;
580
+ const richTextProps = collectRichTextProps(meta.fields, meta.listFields);
581
+ if (richTextProps.length > 0)
582
+ entry.richTextProps = richTextProps;
559
583
  result[type] = entry;
560
584
  }
561
585
  // Add manifest-only blocks (not in shared registry) — derive contracts from JSON schema
562
- for (const [type, manifestProps] of manifestByType) {
563
- const derived = deriveFieldMetaFromSchema({ type: "object", properties: manifestProps });
564
- const allProps = Object.keys(manifestProps);
586
+ for (const [type, manifestDef] of manifestByType) {
587
+ const derived = resolveManifestFieldMeta(manifestDef);
588
+ const allProps = Object.keys(manifestDef.propsSchema.properties ?? {});
565
589
  // Derive list-field notes
566
590
  const listParts = [];
567
591
  for (const [listKey, listMeta] of Object.entries(derived.listFields)) {
@@ -569,9 +593,11 @@ export function blockContractsSummary(manifest) {
569
593
  listParts.push(`${listKey} must be a non-empty array of {${itemKeys}}`);
570
594
  }
571
595
  const autoNotes = listParts.length > 0 ? listParts.join(". ") + "." : `${type} block. Never invent prop names.`;
596
+ const manifestRichText = collectRichTextProps(derived.fields, derived.listFields);
572
597
  result[type] = {
573
598
  allowedProps: allProps,
574
599
  required: allProps, // conservative: treat all as required
600
+ ...(manifestRichText.length > 0 ? { richTextProps: manifestRichText } : {}),
575
601
  notes: autoNotes
576
602
  };
577
603
  }
@@ -1,4 +1,4 @@
1
- import { allowedBlockTypes, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
1
+ import { allowedBlockTypes, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
2
2
  import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
3
  // ---------------------------------------------------------------------------
4
4
  // Op reordering: create_page must precede ops targeting the same slug
@@ -459,8 +459,24 @@ export function nextBlockId(type, page) {
459
459
  // ---------------------------------------------------------------------------
460
460
  // Default block props
461
461
  // ---------------------------------------------------------------------------
462
+ /*
463
+ * Defaults to seed a newly planned block with, before the plan's own props are
464
+ * merged over the top.
465
+ *
466
+ * A block type the registry knows but that declares no defaults is a site's own
467
+ * block, and it gets nothing. The generic CTA-shaped fallback underneath
468
+ * `defaultPropsForType` is for a type nobody has registered — where anything
469
+ * renderable beats an empty block — and applying it to a registered custom
470
+ * block put a `title`, a `description` and a CTA into props whose schema has no
471
+ * such fields. On a site that registers its blocks with a catchall (the way
472
+ * they must, so chat edits don't strip props Avocado doesn't declare) those
473
+ * four keys are not rejected: they are stored, and travel back to the CMS.
474
+ */
462
475
  export function defaultPropsForType(type) {
463
- return sharedDefaultPropsForType(type);
476
+ const declared = declaredDefaultPropsForType(type);
477
+ if (declared)
478
+ return declared;
479
+ return allowedBlockTypes.includes(type) ? {} : sharedDefaultPropsForType(type);
464
480
  }
465
481
  // ---------------------------------------------------------------------------
466
482
  // Patch helpers