@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.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.
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
+ * Where the pages in a draft came from.
3
+ *
4
+ * `GET /draft/slugs` answered 200 with a page list whatever happened, and the
5
+ * list alone cannot be read back to its origin. Three very different situations
6
+ * were indistinguishable:
7
+ *
8
+ * - the caller named a real site and got its content;
9
+ * - the caller omitted `siteId` entirely, so it normalised to the bundled demo
10
+ * site and eight plausible pages came back that belong to no site anyone
11
+ * asked about;
12
+ * - the caller named a site that does not exist, and got an empty list that
13
+ * reads exactly like a real site with no pages yet.
14
+ *
15
+ * An agent is the likeliest victim: it mistypes a `siteId`, sees a plausible
16
+ * answer, and every call after that succeeds against fiction. A human in the
17
+ * editor has the URL bar and a site switcher to notice with; an agent has this
18
+ * response and nothing else.
19
+ *
20
+ * So the response says which one it is. This is deliberately *not* a refusal —
21
+ * a site that has genuinely just been created is empty too, and 404-ing it
22
+ * would break the flow that fills it. What was missing was never the error
23
+ * status; it was any statement at all.
24
+ */
25
+ export type DraftSource =
26
+ /** The bundled demo pages. Nobody's real content. */
27
+ "demo"
28
+ /** Read through a CMS adapter — the site's own content. */
29
+ | "adapter"
30
+ /** Fetched from the orchestrator's configured site origin, which serves one site. */
31
+ | "bootstrap"
32
+ /** This session's draft: seeded from the site earlier, or edited into being. */
33
+ | "draft"
34
+ /** Empty, and no site by this id has ever been seen in this process. */
35
+ | "unknown-site"
36
+ /** Empty, but the site is known — it genuinely has no pages yet. */
37
+ | "empty";
38
+ export type DraftProvenance = {
39
+ siteId: string;
40
+ source: DraftSource;
41
+ /** Plain-language statement of what the caller is looking at. */
42
+ note?: string;
43
+ /** Only on `unknown-site`: what it could have meant. */
44
+ knownSiteIds?: string[];
45
+ };
46
+ /**
47
+ * Every site id this process has real state for.
48
+ *
49
+ * "Has an entry in `draftPages`" is not the test, and using it made this
50
+ * function useless: `getSessionDraft` creates the map on read, so *asking*
51
+ * about a mistyped site made that site known and the next answer said it was
52
+ * fine. The signals below are all writes.
53
+ *
54
+ * `versions` is what keeps a site known after its last page is deleted — an
55
+ * emptied site is not a missing one, and telling a caller to check the spelling
56
+ * of a site it just emptied would be its own small lie.
57
+ */
58
+ export declare function knownSiteIds(): string[];
59
+ export declare function describeDraft(args: {
60
+ /** Exactly what the caller sent, before normalisation — `undefined` matters. */
61
+ requestedSiteId: unknown;
62
+ scopedSession: string;
63
+ pageCount: number;
64
+ /** True when a CMS adapter backs this runtime (library mode with an adapter). */
65
+ hasAdapter?: boolean;
66
+ /** Set when auto-bootstrap filled this draft, to the origin it fetched from. */
67
+ bootstrappedFrom?: string;
68
+ }): DraftProvenance;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Where the pages in a draft came from.
3
+ *
4
+ * `GET /draft/slugs` answered 200 with a page list whatever happened, and the
5
+ * list alone cannot be read back to its origin. Three very different situations
6
+ * were indistinguishable:
7
+ *
8
+ * - the caller named a real site and got its content;
9
+ * - the caller omitted `siteId` entirely, so it normalised to the bundled demo
10
+ * site and eight plausible pages came back that belong to no site anyone
11
+ * asked about;
12
+ * - the caller named a site that does not exist, and got an empty list that
13
+ * reads exactly like a real site with no pages yet.
14
+ *
15
+ * An agent is the likeliest victim: it mistypes a `siteId`, sees a plausible
16
+ * answer, and every call after that succeeds against fiction. A human in the
17
+ * editor has the URL bar and a site switcher to notice with; an agent has this
18
+ * response and nothing else.
19
+ *
20
+ * So the response says which one it is. This is deliberately *not* a refusal —
21
+ * a site that has genuinely just been created is empty too, and 404-ing it
22
+ * would break the flow that fills it. What was missing was never the error
23
+ * status; it was any statement at all.
24
+ */
25
+ import { draftPages, siteConfigs, versions, normalizeSiteId, isLegacySiteId } from "../state/session-state.js";
26
+ /**
27
+ * Every site id this process has real state for.
28
+ *
29
+ * "Has an entry in `draftPages`" is not the test, and using it made this
30
+ * function useless: `getSessionDraft` creates the map on read, so *asking*
31
+ * about a mistyped site made that site known and the next answer said it was
32
+ * fine. The signals below are all writes.
33
+ *
34
+ * `versions` is what keeps a site known after its last page is deleted — an
35
+ * emptied site is not a missing one, and telling a caller to check the spelling
36
+ * of a site it just emptied would be its own small lie.
37
+ */
38
+ export function knownSiteIds() {
39
+ const ids = new Set();
40
+ const add = (key) => {
41
+ const sep = key.indexOf("::");
42
+ if (sep > 0)
43
+ ids.add(key.slice(0, sep));
44
+ };
45
+ for (const key of siteConfigs.keys())
46
+ add(key);
47
+ for (const [key, pages] of draftPages)
48
+ if (pages.size > 0)
49
+ add(key);
50
+ for (const [key, version] of versions)
51
+ if (version > 0)
52
+ add(key);
53
+ return [...ids].sort();
54
+ }
55
+ export function describeDraft(args) {
56
+ const siteId = normalizeSiteId(args.requestedSiteId);
57
+ const namedASite = typeof args.requestedSiteId === "string" && args.requestedSiteId.trim().length > 0;
58
+ /*
59
+ * A key with no `::` is the legacy/demo route, and the only way to reach it
60
+ * is a legacy site id — which is also what an *absent* `siteId` normalises to.
61
+ * That collapse is the whole trap: forgetting the parameter looks identical to
62
+ * asking for the demo site on purpose.
63
+ */
64
+ if (!args.scopedSession.includes("::") && isLegacySiteId(siteId)) {
65
+ return {
66
+ siteId,
67
+ source: "demo",
68
+ note: namedASite
69
+ ? `These are Avocado's bundled demo pages, served for the demo site "${siteId}".`
70
+ : "No siteId was supplied, so this is Avocado's bundled demo content — not any real site's " +
71
+ "pages. Pass siteId to reach the site you meant."
72
+ };
73
+ }
74
+ if (args.bootstrappedFrom) {
75
+ return {
76
+ siteId,
77
+ source: "bootstrap",
78
+ note: `These pages were fetched from ${args.bootstrappedFrom}, which serves one site and answers ` +
79
+ `with its own content whatever siteId is asked for. They are stored under "${siteId}", but ` +
80
+ "that does not make them that site's pages — confirm the siteId is the one you meant."
81
+ };
82
+ }
83
+ if (args.pageCount > 0) {
84
+ return {
85
+ siteId,
86
+ source: args.hasAdapter ? "adapter" : "draft"
87
+ };
88
+ }
89
+ const known = knownSiteIds();
90
+ if (!known.includes(siteId) && !args.hasAdapter) {
91
+ return {
92
+ siteId,
93
+ source: "unknown-site",
94
+ note: `No site "${siteId}" has been seen in this process, so this empty list is not a report ` +
95
+ "that the site has no pages — it is that nothing here knows the site. Check the siteId, " +
96
+ "or register it with POST /sites/register.",
97
+ knownSiteIds: known
98
+ };
99
+ }
100
+ return { siteId, source: "empty", note: `Site "${siteId}" is known and currently has no pages.` };
101
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Undo, redo, restore and the history log, as transport-agnostic actions.
3
+ *
4
+ * These lived only in `apps/orchestrator/src/routes/history.ts`, wired
5
+ * directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
6
+ * — serves the same editor from a plain `Request`/`Response` handler, and it
7
+ * reimplements by hand whichever routes somebody remembered to add. It never
8
+ * had these, so `POST /history/undo` answered "not handled by
9
+ * createOrchestrator()" in a UI that shows an Undo button unconditionally,
10
+ * while `/ops` had been faithfully calling `pushUndo` all along: the stack
11
+ * filled up and nothing could ever pop it.
12
+ *
13
+ * A second hand-kept copy would drift the same way the first one did, so the
14
+ * logic moves 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 type { Logger } from "../logger.js";
19
+ /** What a caller sends. `siteId` scopes the session; both transports pass it through. */
20
+ export type HistoryScope = {
21
+ session?: string;
22
+ siteId?: string;
23
+ slug?: string;
24
+ };
25
+ export type ActionResult = {
26
+ code: number;
27
+ body: unknown;
28
+ };
29
+ /** Whether undo and redo have anything to offer for this slug. */
30
+ export declare function historyStatus(query: HistoryScope): ActionResult;
31
+ /** The version log, without the snapshots — those are large and the list view never reads them. */
32
+ export declare function historyLog(query: HistoryScope & {
33
+ limit?: string | number;
34
+ }): ActionResult;
35
+ /**
36
+ * Step one entry back on a slug's undo stack.
37
+ *
38
+ * A `null` entry means "the page did not exist at this point", so undoing onto
39
+ * it removes the page rather than restoring one — which is how undoing a
40
+ * `create_page` works. The same `null` goes onto the redo stack when the page
41
+ * exists now and did not before.
42
+ */
43
+ export declare function historyUndoAction(body: HistoryScope, log: Logger): ActionResult;
44
+ /** The mirror of undo, popping the redo stack and pushing current onto undo. */
45
+ export declare function historyRedoAction(body: HistoryScope, log: Logger): ActionResult;
46
+ /**
47
+ * Jump to any entry in the version log by applying its stored snapshot.
48
+ *
49
+ * Independent of the undo/redo stacks, so every entry stays reachable even
50
+ * after jumping elsewhere. The restore is itself an edit: the current state
51
+ * goes onto the undo stack (so undo reverses the restore) and the log gains a
52
+ * `restore` entry with its own snapshot.
53
+ */
54
+ export declare function historyRestoreAction(body: {
55
+ session?: string;
56
+ siteId?: string;
57
+ targetVersion?: number;
58
+ }, log: Logger): ActionResult;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Undo, redo, restore and the history log, as transport-agnostic actions.
3
+ *
4
+ * These lived only in `apps/orchestrator/src/routes/history.ts`, wired
5
+ * directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
6
+ * — serves the same editor from a plain `Request`/`Response` handler, and it
7
+ * reimplements by hand whichever routes somebody remembered to add. It never
8
+ * had these, so `POST /history/undo` answered "not handled by
9
+ * createOrchestrator()" in a UI that shows an Undo button unconditionally,
10
+ * while `/ops` had been faithfully calling `pushUndo` all along: the stack
11
+ * filled up and nothing could ever pop it.
12
+ *
13
+ * A second hand-kept copy would drift the same way the first one did, so the
14
+ * logic moves 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 { scopedSessionKey, historyUndo, historyRedo, getHistoryMap, getPage, setPage, removePage, pushUndo, pushCappedHistory, bumpVersion, pushVersionEntry, getVersionLog, versions, versionLog, schedulePersistState } from "../state/session-state.js";
19
+ const badRequest = (error) => ({ code: 400, body: { error } });
20
+ /** Whether undo and redo have anything to offer for this slug. */
21
+ export function historyStatus(query) {
22
+ if (!query.session || !query.slug)
23
+ return badRequest("session and slug are required");
24
+ const session = scopedSessionKey(query.session, query.siteId);
25
+ const undoList = getHistoryMap(historyUndo, session).get(query.slug) ?? [];
26
+ const redoList = getHistoryMap(historyRedo, session).get(query.slug) ?? [];
27
+ return { code: 200, body: { canUndo: undoList.length > 0, canRedo: redoList.length > 0 } };
28
+ }
29
+ /** The version log, without the snapshots — those are large and the list view never reads them. */
30
+ export function historyLog(query) {
31
+ if (!query.session)
32
+ return badRequest("session is required");
33
+ const session = scopedSessionKey(query.session, query.siteId);
34
+ const requested = typeof query.limit === "string" ? parseInt(query.limit, 10) : query.limit;
35
+ const limit = requested ? Math.min(requested || 50, 100) : 50;
36
+ const entries = getVersionLog(session, query.slug, limit).map(({ snapshot: _snapshot, ...rest }) => rest);
37
+ return { code: 200, body: { entries, currentVersion: versions.get(session) ?? 0 } };
38
+ }
39
+ /**
40
+ * Step one entry back on a slug's undo stack.
41
+ *
42
+ * A `null` entry means "the page did not exist at this point", so undoing onto
43
+ * it removes the page rather than restoring one — which is how undoing a
44
+ * `create_page` works. The same `null` goes onto the redo stack when the page
45
+ * exists now and did not before.
46
+ */
47
+ export function historyUndoAction(body, log) {
48
+ if (!body.session || !body.slug)
49
+ return badRequest("session and slug are required");
50
+ const session = scopedSessionKey(body.session, body.siteId);
51
+ const slug = body.slug;
52
+ const undoMap = getHistoryMap(historyUndo, session);
53
+ const redoMap = getHistoryMap(historyRedo, session);
54
+ const list = undoMap.get(slug) ?? [];
55
+ if (list.length === 0)
56
+ return badRequest("nothing to undo");
57
+ // May be null if the page was deleted; the snapshot still restores it.
58
+ const current = getPage(session, slug);
59
+ const prev = list.pop();
60
+ undoMap.set(slug, list);
61
+ if (prev === undefined)
62
+ return badRequest("nothing to undo");
63
+ const redoList = redoMap.get(slug) ?? [];
64
+ pushCappedHistory(redoList, current);
65
+ redoMap.set(slug, redoList);
66
+ if (prev === null) {
67
+ removePage(session, slug);
68
+ const previewVersion = bumpVersion(session);
69
+ pushVersionEntry(session, { version: previewVersion, slug, summary: "Undo", opTypes: [], opCount: 0, source: "undo", snapshot: null });
70
+ schedulePersistState(log);
71
+ return { code: 200, body: { status: "applied", previewVersion, navigateToSlug: "/", canUndo: list.length > 0, canRedo: true } };
72
+ }
73
+ setPage(session, structuredClone(prev));
74
+ const previewVersion = bumpVersion(session);
75
+ pushVersionEntry(session, { version: previewVersion, slug, summary: "Undo", opTypes: [], opCount: 0, source: "undo", snapshot: structuredClone(prev) });
76
+ schedulePersistState(log);
77
+ // The page the editor is looking at is gone, so send it somewhere real.
78
+ const navigateToSlug = current === null ? prev.slug : undefined;
79
+ return {
80
+ code: 200,
81
+ body: { status: "applied", previewVersion, canUndo: list.length > 0, canRedo: true, ...(navigateToSlug ? { navigateToSlug } : {}) }
82
+ };
83
+ }
84
+ /** The mirror of undo, popping the redo stack and pushing current onto undo. */
85
+ export function historyRedoAction(body, log) {
86
+ if (!body.session || !body.slug)
87
+ return badRequest("session and slug are required");
88
+ const session = scopedSessionKey(body.session, body.siteId);
89
+ const slug = body.slug;
90
+ const undoMap = getHistoryMap(historyUndo, session);
91
+ const redoMap = getHistoryMap(historyRedo, session);
92
+ const list = redoMap.get(slug) ?? [];
93
+ if (list.length === 0)
94
+ return badRequest("nothing to redo");
95
+ const current = getPage(session, slug);
96
+ const next = list.pop();
97
+ redoMap.set(slug, list);
98
+ if (next === undefined)
99
+ return badRequest("nothing to redo");
100
+ const undoList = undoMap.get(slug) ?? [];
101
+ pushCappedHistory(undoList, current);
102
+ undoMap.set(slug, undoList);
103
+ if (next === null) {
104
+ removePage(session, slug);
105
+ const previewVersion = bumpVersion(session);
106
+ pushVersionEntry(session, { version: previewVersion, slug, summary: "Redo", opTypes: [], opCount: 0, source: "redo", snapshot: null });
107
+ schedulePersistState(log);
108
+ return { code: 200, body: { status: "applied", previewVersion, navigateToSlug: "/", canUndo: true, canRedo: list.length > 0 } };
109
+ }
110
+ setPage(session, structuredClone(next));
111
+ const previewVersion = bumpVersion(session);
112
+ pushVersionEntry(session, { version: previewVersion, slug, summary: "Redo", opTypes: [], opCount: 0, source: "redo", snapshot: structuredClone(next) });
113
+ schedulePersistState(log);
114
+ const navigateToSlug = current === null ? next.slug : undefined;
115
+ return {
116
+ code: 200,
117
+ body: { status: "applied", previewVersion, canUndo: true, canRedo: list.length > 0, ...(navigateToSlug ? { navigateToSlug } : {}) }
118
+ };
119
+ }
120
+ /**
121
+ * Jump to any entry in the version log by applying its stored snapshot.
122
+ *
123
+ * Independent of the undo/redo stacks, so every entry stays reachable even
124
+ * after jumping elsewhere. The restore is itself an edit: the current state
125
+ * goes onto the undo stack (so undo reverses the restore) and the log gains a
126
+ * `restore` entry with its own snapshot.
127
+ */
128
+ export function historyRestoreAction(body, log) {
129
+ if (!body.session || typeof body.targetVersion !== "number") {
130
+ return badRequest("session and targetVersion are required");
131
+ }
132
+ const session = scopedSessionKey(body.session, body.siteId);
133
+ const entries = versionLog.get(session) ?? [];
134
+ const target = entries.find((entry) => entry.version === body.targetVersion);
135
+ if (!target)
136
+ return { code: 404, body: { error: "target version not found" } };
137
+ if (target.snapshot === undefined) {
138
+ return badRequest("target version has no restorable snapshot (legacy entry)");
139
+ }
140
+ // pushUndo also clears redo, which is right: a restore starts a new branch.
141
+ pushUndo(session, target.slug, getPage(session, target.slug));
142
+ if (target.snapshot === null)
143
+ removePage(session, target.slug);
144
+ else
145
+ setPage(session, structuredClone(target.snapshot));
146
+ const previewVersion = bumpVersion(session);
147
+ pushVersionEntry(session, {
148
+ version: previewVersion,
149
+ slug: target.slug,
150
+ summary: `Restored to v${body.targetVersion}`,
151
+ opTypes: [],
152
+ opCount: 0,
153
+ source: "restore",
154
+ snapshot: target.snapshot === null ? null : structuredClone(target.snapshot)
155
+ });
156
+ schedulePersistState(log);
157
+ const undoList = getHistoryMap(historyUndo, session).get(target.slug) ?? [];
158
+ const redoList = getHistoryMap(historyRedo, session).get(target.slug) ?? [];
159
+ return {
160
+ code: 200,
161
+ body: {
162
+ status: "applied",
163
+ previewVersion,
164
+ navigateToSlug: target.slug,
165
+ canUndo: undoList.length > 0,
166
+ canRedo: redoList.length > 0
167
+ }
168
+ };
169
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Image generation — one-shot, multi-turn Gemini chat, and screenshot
3
+ * interpretation — as transport-agnostic actions.
4
+ *
5
+ * These three handlers lived only in `apps/orchestrator/src/routes/media.ts`,
6
+ * wired directly into Fastify, even though almost everything they call has been
7
+ * in orchestrator-core all along. Library mode — `createOrchestrator()` in the
8
+ * site SDK — serves the same editor from a plain `Request`/`Response` handler
9
+ * and reimplements by hand whichever routes somebody remembered to add. It
10
+ * never had these, so the editor's image picker answered "not handled by
11
+ * createOrchestrator()" for a Generate tab it shows unconditionally.
12
+ *
13
+ * Two things made a second hand-kept copy worse than usual here. The multi-turn
14
+ * chat keeps *live* Gemini sessions in a closure-scoped Map, so a duplicate
15
+ * implementation would also duplicate the session store and hand the same
16
+ * `chatId` two different conversations. And the streaming variant has an SSE
17
+ * frame vocabulary — `chatId`/`status`/`text`/`image`/`error`/`done` — that
18
+ * existed only inside the Fastify handler, while the editor parses those names
19
+ * by hand: nothing type-checks a string written on one side of a socket against
20
+ * the string read on the other. So the frames are built here, once, and
21
+ * `formatImageChatFrame` writes the wire bytes.
22
+ *
23
+ * Each function returns the status code and body to send, or emits frames;
24
+ * neither Fastify nor `Response` appears in this file.
25
+ */
26
+ import type { Logger } from "../logger.js";
27
+ import type { ActionResult } from "./history-actions.js";
28
+ export type { ActionResult };
29
+ /**
30
+ * The one seam that decides where a generated image is written and what URL
31
+ * points back at it.
32
+ *
33
+ * The Fastify route wrote straight to `ctx.generatedImageDir` and built URLs
34
+ * from `ctx.orchestratorPublicOrigin`; the core helpers read the equivalent
35
+ * environment variables. Neither is right for a library-mode consumer, which
36
+ * has its own `imageDir` and serves `/generated-images/…` under the base path
37
+ * its route is mounted at. Without an injected store the extraction would
38
+ * "work" everywhere and return URLs that 404 in exactly one of the two
39
+ * transports, which is the failure that is hardest to notice from a test.
40
+ */
41
+ export type ImageStore = {
42
+ save(bytes: Buffer, prefix: string, ext: string): Promise<{
43
+ url: string;
44
+ }>;
45
+ };
46
+ /**
47
+ * The historical behaviour: `ORCHESTRATOR_GENERATED_IMAGE_DIR` +
48
+ * `ORCHESTRATOR_PUBLIC_ORIGIN`, via the same helper the rest of core uses. This
49
+ * is the default so a caller that passes no store is byte-for-byte unchanged.
50
+ */
51
+ export declare function envImageStore(): ImageStore;
52
+ /** A store for a transport that already knows its own directory and public base. */
53
+ export declare function fileImageStore(options: {
54
+ dir: string;
55
+ publicBaseUrl: string;
56
+ }): ImageStore;
57
+ /** Only the two calls these actions make, so a test can supply a plain object. */
58
+ export type OpenAIImagesClient = {
59
+ images: {
60
+ generate(args: {
61
+ model: string;
62
+ prompt: string;
63
+ size: string;
64
+ }): Promise<{
65
+ data?: Array<{
66
+ b64_json?: string | null;
67
+ url?: string | null;
68
+ }> | null;
69
+ }>;
70
+ };
71
+ };
72
+ export type OpenAIVisionClient = {
73
+ chat: {
74
+ completions: {
75
+ create(args: unknown): Promise<{
76
+ choices?: Array<{
77
+ message?: {
78
+ content?: string | null;
79
+ };
80
+ }>;
81
+ }>;
82
+ };
83
+ };
84
+ };
85
+ export type GeminiPart = {
86
+ text?: string;
87
+ inlineData?: {
88
+ data?: string;
89
+ mimeType?: string;
90
+ };
91
+ };
92
+ export type GeminiContentResponse = {
93
+ candidates?: Array<{
94
+ content?: {
95
+ parts?: GeminiPart[];
96
+ };
97
+ }>;
98
+ };
99
+ export type GeminiImageChat = {
100
+ sendMessage(args: {
101
+ message: unknown;
102
+ }): Promise<GeminiContentResponse>;
103
+ sendMessageStream(args: {
104
+ message: unknown;
105
+ }): Promise<AsyncIterable<GeminiContentResponse>>;
106
+ };
107
+ export type GeminiImageClient = {
108
+ models: {
109
+ generateContent(args: unknown): Promise<GeminiContentResponse>;
110
+ };
111
+ chats: {
112
+ create(args: unknown): GeminiImageChat;
113
+ };
114
+ };
115
+ export type ImageActionDeps = {
116
+ log: Logger;
117
+ /** Defaults to `envImageStore()`. */
118
+ store?: ImageStore;
119
+ /** Test seams; the real clients are built from the environment when absent. */
120
+ openaiImages?: OpenAIImagesClient;
121
+ openaiVision?: OpenAIVisionClient;
122
+ gemini?: GeminiImageClient;
123
+ fetchFn?: typeof fetch;
124
+ };
125
+ export type GenerateImageBody = {
126
+ prompt?: string;
127
+ aspectRatio?: string;
128
+ provider?: string;
129
+ model?: string;
130
+ };
131
+ /**
132
+ * One image from one prompt, from whichever provider is configured.
133
+ *
134
+ * The provider is chosen per request (`body.provider`) and falls back to
135
+ * `IMAGE_GEN_PROVIDER`, then to OpenAI. Asking for Gemini without a Google key
136
+ * silently falls through to OpenAI rather than failing, because the editor
137
+ * sends a provider hint it inferred from the user's wording and a deployment
138
+ * that funds only one key should still generate images.
139
+ */
140
+ export declare function generateImageAction(body: GenerateImageBody, deps: ImageActionDeps): Promise<ActionResult>;
141
+ /** Drop every live session and stop the sweeper. Tests only. */
142
+ export declare function resetImageChatSessionsForTests(): void;
143
+ /**
144
+ * Detect an aspect-ratio override in the prompt text.
145
+ *
146
+ * The picker has a ratio control, but people also just type "make it square",
147
+ * and a Gemini chat's `imageConfig` is fixed for the life of the session — so
148
+ * the wording has to be read here, before the session is created, or the
149
+ * request silently produces the previous shape.
150
+ */
151
+ export declare function detectAspectRatioFromPrompt(text: string): string | null;
152
+ export type ImageChatBody = {
153
+ prompt?: string;
154
+ chatId?: string;
155
+ aspectRatio?: string;
156
+ stream?: boolean;
157
+ referenceImageUrl?: string;
158
+ referenceImageUrls?: string[];
159
+ };
160
+ /**
161
+ * The cheap guards, separate from the actions because the streaming transport
162
+ * has to answer them *before* it commits SSE headers — after that the status
163
+ * code is spent and the only way to say "no key" is a frame the client would
164
+ * have to special-case.
165
+ */
166
+ export declare function validateImageChatRequest(body: ImageChatBody): ActionResult | null;
167
+ /**
168
+ * Find or create the Gemini chat this request belongs to.
169
+ *
170
+ * Ratio precedence is prompt > client hint > the session's current ratio > 3:2.
171
+ * A ratio change on an existing session cannot be applied in place — Gemini
172
+ * freezes `imageConfig` when the chat is created — so the session is replaced
173
+ * and the caller gets a new `chatId`. That loses the conversation, which is the
174
+ * honest trade: keeping the old chat would keep producing the old shape.
175
+ */
176
+ export declare function resolveImageChatSession(body: ImageChatBody, ai: GeminiImageClient, log: Logger): {
177
+ chatId: string;
178
+ chat: GeminiImageChat;
179
+ aspectRatio: string;
180
+ };
181
+ /**
182
+ * The message to send: the prompt alone, or the prompt plus inlined reference
183
+ * images on the first turn (Gemini accepts up to 14).
184
+ *
185
+ * References are only inlined when the client sent no `chatId`, because after
186
+ * the first turn they are already in the conversation and re-sending them
187
+ * re-pays their tokens on every edit. A reference that will not load is warned
188
+ * about and skipped rather than failing the request: losing one of five moodboard
189
+ * images should not lose the generation.
190
+ */
191
+ export declare function buildImageChatMessage(body: ImageChatBody, deps: ImageActionDeps): Promise<unknown>;
192
+ /**
193
+ * The SSE frames the streaming variant emits, in the order they can occur: one
194
+ * `chatId`, one `status`, then any number of `text` and `image` frames, and
195
+ * exactly one `done` — with an `error` before it if the generation broke.
196
+ *
197
+ * Unlike the variations stream, the event name goes on an SSE `event:` line
198
+ * rather than inside the payload, because the editor reads this one with
199
+ * `EventSource`-style named listeners.
200
+ */
201
+ export type ImageChatFrame = {
202
+ event: "chatId";
203
+ chatId: string;
204
+ aspectRatio: string;
205
+ } | {
206
+ event: "status";
207
+ stage: string;
208
+ } | {
209
+ event: "text";
210
+ text: string;
211
+ } | {
212
+ event: "image";
213
+ url: string;
214
+ alt: string;
215
+ } | {
216
+ event: "error";
217
+ error: string;
218
+ } | {
219
+ event: "done";
220
+ };
221
+ /** The wire encoding of one frame. */
222
+ export declare function formatImageChatFrame(frame: ImageChatFrame): string;
223
+ /**
224
+ * One turn of the image chat, answered in a single JSON body.
225
+ *
226
+ * `url` is null when the model replied with text only — a refusal, or a
227
+ * question about the prompt — which is a normal turn, not an error.
228
+ */
229
+ export declare function imageChatAction(body: ImageChatBody, deps: ImageActionDeps): Promise<ActionResult>;
230
+ /**
231
+ * The same turn, streamed.
232
+ *
233
+ * Resolves once the terminal `done` frame has been emitted; ending the stream
234
+ * stays with the transport, which owns the socket and has to close it on client
235
+ * disconnect too. Everything that goes wrong after the first frame — including
236
+ * a failure to reach Gemini at all — is reported as an `error` frame rather
237
+ * than thrown, because the status code is committed the moment the transport
238
+ * writes SSE headers and in-band is the only channel left. Callers should run
239
+ * `validateImageChatRequest` before those headers so a missing key is still a
240
+ * real 503.
241
+ */
242
+ export declare function imageChatStreamAction(body: ImageChatBody, deps: ImageActionDeps, emit: (frame: ImageChatFrame) => void): Promise<void>;
243
+ /**
244
+ * The checks on an uploaded image, shared so both transports answer alike.
245
+ *
246
+ * Each transport decodes multipart its own way — Fastify streams
247
+ * `request.file()`, library mode awaits `request.formData()` — and it was the
248
+ * *error bodies* around that decoding, not the decoding itself, that a second
249
+ * implementation would get subtly different. Returns null when the upload is
250
+ * acceptable.
251
+ */
252
+ export declare function validateImageAnalysisInput(input: {
253
+ mimeType: string;
254
+ byteLength: number;
255
+ }): ActionResult | null;
256
+ /**
257
+ * Describe a pasted screenshot in one sentence, for the chat composer to attach
258
+ * to the user's instruction.
259
+ *
260
+ * The image is inlined as a data URL rather than uploaded: the orchestrator has
261
+ * no public URL for a paste that never touched disk, and these are one-shot
262
+ * reads that nothing refers to again.
263
+ */
264
+ export declare function interpretImageAction(input: {
265
+ bytes: Buffer;
266
+ byteLength: number;
267
+ mimeType: string;
268
+ }, deps: ImageActionDeps): Promise<ActionResult>;