@avocadostudio-ai/orchestrator-core 0.4.0 → 0.5.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.
- package/dist/agent/agent-logger.js +2 -1
- package/dist/agent/sites-agent-context.js +10 -2
- package/dist/chat/anthropic-planner.js +13 -7
- package/dist/chat/hallucination-validator.js +50 -2
- package/dist/chat/planner.js +10 -1
- package/dist/cms/adapter.d.ts +25 -0
- package/dist/handler/create-orchestrator.js +38 -3
- package/dist/handler/library-mount.d.ts +6 -0
- package/dist/handler/library-mount.js +45 -0
- package/dist/image/image-helpers.js +3 -2
- package/dist/nlp/deterministic-planner-context.d.ts +19 -0
- package/dist/nlp/deterministic-planner-context.js +73 -3
- package/dist/publish/publish-helpers.js +2 -1
- package/dist/state/data-dir.d.ts +6 -0
- package/dist/state/data-dir.js +35 -0
- package/dist/state/session-state.d.ts +21 -0
- package/dist/state/session-state.js +24 -1
- package/dist/state/sqlite-store-singleton.js +3 -17
- package/package.json +3 -3
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { appendFileSync } from "node:fs";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
-
|
|
7
|
+
import { resolveDataDir } from "../state/data-dir.js";
|
|
8
|
+
const LOG_PATH = resolve(resolveDataDir(), "agent-log.ndjson");
|
|
8
9
|
export function logAgent(streamId, event, detail, startedAt) {
|
|
9
10
|
const entry = {
|
|
10
11
|
ts: Date.now(),
|
|
@@ -44,8 +44,16 @@ export function buildBlockCatalog() {
|
|
|
44
44
|
continue; // skip chrome blocks
|
|
45
45
|
const guide = BLOCK_VISUAL_GUIDE[type];
|
|
46
46
|
// Compact: Type — description | props | list fields
|
|
47
|
+
/*
|
|
48
|
+
* `reference` is dropped rather than annotated. It is a pointer the CMS
|
|
49
|
+
* owns — a story link, an entry link — and a model handed the prop name
|
|
50
|
+
* will eventually write a string into it, which replaces the pointer with a
|
|
51
|
+
* hard-coded URL that stops following renames. A prop that must never be
|
|
52
|
+
* written is not a prop worth naming.
|
|
53
|
+
*/
|
|
54
|
+
const writable = ([, fm]) => fm.kind !== "headingLevel" && fm.kind !== "reference";
|
|
47
55
|
const fieldParts = Object.entries(meta.fields)
|
|
48
|
-
.filter(
|
|
56
|
+
.filter(writable) // headingLevel is always optional noise
|
|
49
57
|
.map(([key, fm]) => {
|
|
50
58
|
const req = fm.required ? "" : "?";
|
|
51
59
|
const opts = fm.kind === "enum" && fm.options ? `(${fm.options.join("|")})` : "";
|
|
@@ -54,7 +62,7 @@ export function buildBlockCatalog() {
|
|
|
54
62
|
const listParts = meta.listFields
|
|
55
63
|
? Object.entries(meta.listFields).map(([listKey, listMeta]) => {
|
|
56
64
|
const items = Object.entries(listMeta.itemFields)
|
|
57
|
-
.filter(
|
|
65
|
+
.filter(writable)
|
|
58
66
|
.map(([k, fm]) => `${k}${fm.required ? "" : "?"}`);
|
|
59
67
|
return `${listKey}[]{${items.join(",")}}`;
|
|
60
68
|
})
|
|
@@ -336,13 +336,19 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
336
336
|
const attachmentBlocks = resolvedAttachments.map((attachment) => attachment.kind === "pdf"
|
|
337
337
|
? { type: "document", source: { type: "base64", media_type: "application/pdf", data: attachment.base64 } }
|
|
338
338
|
: { type: "image", source: { type: "base64", media_type: attachment.mediaType, data: attachment.base64 } });
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
/*
|
|
340
|
+
* A URL source is only ever https. Anthropic rejects anything else with
|
|
341
|
+
* `400 … Only HTTPS URLs are supported`, and it rejects the *request*, not the
|
|
342
|
+
* block — so one http image on a selected block failed the whole turn, and the
|
|
343
|
+
* user saw a raw provider error in place of their answer. `fetchImageAsBase64`
|
|
344
|
+
* inlines the loopback case; an image we can neither inline nor link is simply
|
|
345
|
+
* not shown to the model, which costs this turn its vision and nothing else.
|
|
346
|
+
*/
|
|
347
|
+
const visionBlocks = imageBase64
|
|
348
|
+
? [{ type: "image", source: { type: "base64", media_type: imageBase64.mediaType, data: imageBase64.base64 } }]
|
|
349
|
+
: imageUrlForVision && /^https:\/\//i.test(imageUrlForVision)
|
|
350
|
+
? [{ type: "image", source: { type: "url", url: imageUrlForVision } }]
|
|
351
|
+
: [];
|
|
346
352
|
const userContent = visionBlocks.length > 0 || attachmentBlocks.length > 0
|
|
347
353
|
? [...visionBlocks, ...attachmentBlocks, { type: "text", text: JSON.stringify(user) }]
|
|
348
354
|
: JSON.stringify(user);
|
|
@@ -26,6 +26,26 @@ function allowedPropKeysForBlockType(blockType) {
|
|
|
26
26
|
return null;
|
|
27
27
|
return new Set(Object.keys(shape));
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Props that hold a pointer the CMS owns.
|
|
31
|
+
*
|
|
32
|
+
* These are in the schema and therefore survive every check above, which is the
|
|
33
|
+
* problem: a Storyblok story link is `{ linktype, id, cached_url }`, a model
|
|
34
|
+
* writes the rendered href it saw, and the CMS ends up holding a hard-coded URL
|
|
35
|
+
* where a reference was. It renders identically and silently stops following
|
|
36
|
+
* renames — the one thing the reference was for. There is no valid value a
|
|
37
|
+
* planner can produce here, because constructing one needs the CMS's own
|
|
38
|
+
* document ids, so the op is dropped rather than repaired.
|
|
39
|
+
*/
|
|
40
|
+
function referencePropKeysForBlockType(blockType) {
|
|
41
|
+
const meta = getBlockMeta(blockType);
|
|
42
|
+
const keys = new Set();
|
|
43
|
+
for (const [key, field] of Object.entries(meta?.fields ?? {})) {
|
|
44
|
+
if (field.kind === "reference")
|
|
45
|
+
keys.add(key);
|
|
46
|
+
}
|
|
47
|
+
return keys;
|
|
48
|
+
}
|
|
29
49
|
/**
|
|
30
50
|
* Look up the current block by id across all pages in the draft. Preferring
|
|
31
51
|
* the targeted page when supplied avoids cross-page collisions on id reuse.
|
|
@@ -78,6 +98,7 @@ function humanBlockName(blockType) {
|
|
|
78
98
|
export function validateAndStripHallucinatedProps(args) {
|
|
79
99
|
const { plan, draft } = args;
|
|
80
100
|
const hallucinatedProps = [];
|
|
101
|
+
const referenceProps = [];
|
|
81
102
|
for (const op of plan.ops) {
|
|
82
103
|
if (op.op !== "update_props")
|
|
83
104
|
continue;
|
|
@@ -93,7 +114,13 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
93
114
|
const patchCandidate = rawPatch.props && typeof rawPatch.props === "object" && !Array.isArray(rawPatch.props)
|
|
94
115
|
? rawPatch.props
|
|
95
116
|
: rawPatch;
|
|
117
|
+
const referenceKeys = referencePropKeysForBlockType(blockType);
|
|
96
118
|
for (const key of Object.keys(patchCandidate)) {
|
|
119
|
+
if (referenceKeys.has(key)) {
|
|
120
|
+
delete patchCandidate[key];
|
|
121
|
+
referenceProps.push({ blockType, propName: key });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
97
124
|
if (allowedKeys.has(key))
|
|
98
125
|
continue;
|
|
99
126
|
delete patchCandidate[key];
|
|
@@ -105,7 +132,28 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
105
132
|
});
|
|
106
133
|
}
|
|
107
134
|
}
|
|
108
|
-
|
|
135
|
+
/*
|
|
136
|
+
* Said separately from the hallucinated-prop note below, because it is a
|
|
137
|
+
* different fact about a different kind of field. "The block has no such
|
|
138
|
+
* field" is false here — the field exists, it is on the page, and the person
|
|
139
|
+
* can see what it points at. What they cannot do is change it from here.
|
|
140
|
+
*/
|
|
141
|
+
const referenceNoteParts = [];
|
|
142
|
+
if (referenceProps.length > 0) {
|
|
143
|
+
const byBlock = new Map();
|
|
144
|
+
for (const entry of referenceProps) {
|
|
145
|
+
const bucket = byBlock.get(entry.blockType) ?? new Set();
|
|
146
|
+
bucket.add(entry.propName);
|
|
147
|
+
byBlock.set(entry.blockType, bucket);
|
|
148
|
+
}
|
|
149
|
+
for (const [blockType, props] of byBlock) {
|
|
150
|
+
const name = humanBlockName(blockType);
|
|
151
|
+
for (const prop of props) {
|
|
152
|
+
referenceNoteParts.push(`“${prop}” on the ${name} block points at a document in your CMS, so it is changed there rather than here.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (hallucinatedProps.length > 0 || referenceNoteParts.length > 0) {
|
|
109
157
|
/*
|
|
110
158
|
* Two different events used to share one sentence, and the wrong one was
|
|
111
159
|
* the default.
|
|
@@ -130,7 +178,7 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
130
178
|
bucket.add(entry.propName);
|
|
131
179
|
byBlockType.set(entry.blockType, bucket);
|
|
132
180
|
}
|
|
133
|
-
const noteParts = [];
|
|
181
|
+
const noteParts = [...referenceNoteParts];
|
|
134
182
|
for (const [blockType, props] of byBlockType) {
|
|
135
183
|
const name = humanBlockName(blockType);
|
|
136
184
|
const visual = [...props].filter(isVisualPropName);
|
package/dist/chat/planner.js
CHANGED
|
@@ -1190,9 +1190,18 @@ export async function generatePlanWithOpenAI(args) {
|
|
|
1190
1190
|
? args.contextPack.selected.imageUrlForVision
|
|
1191
1191
|
: null;
|
|
1192
1192
|
const imageBase64 = imageUrlForVision ? await fetchImageAsBase64(imageUrlForVision) : null;
|
|
1193
|
+
/*
|
|
1194
|
+
* Same rule as the Anthropic path: a bare URL goes to the provider only when
|
|
1195
|
+
* it is https. A root-relative `/media/...` — which is what a site most often
|
|
1196
|
+
* writes in an image prop — is not a URL any provider can fetch, and sending
|
|
1197
|
+
* it fails the whole call rather than the image. Inline it when we can reach
|
|
1198
|
+
* it, drop it when we cannot.
|
|
1199
|
+
*/
|
|
1193
1200
|
const visionImageUrl = imageBase64
|
|
1194
1201
|
? `data:${imageBase64.mediaType};base64,${imageBase64.base64}`
|
|
1195
|
-
: imageUrlForVision
|
|
1202
|
+
: imageUrlForVision && /^https:\/\//i.test(imageUrlForVision)
|
|
1203
|
+
? imageUrlForVision
|
|
1204
|
+
: null;
|
|
1196
1205
|
// User-attached files: images as image_url parts, PDFs as `file` parts with
|
|
1197
1206
|
// inline base64 data. The `file` content part is only accepted by the gpt-4o /
|
|
1198
1207
|
// gpt-4.1 / gpt-5 families on chat.completions; sending it to other models
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -35,14 +35,39 @@ export interface CmsPublishContext {
|
|
|
35
35
|
* destroys what it was projected from. A publisher has to diff, and it cannot
|
|
36
36
|
* diff against nothing.
|
|
37
37
|
*
|
|
38
|
+
* **This is the CMS's draft, not the live site**, and the distinction decides
|
|
39
|
+
* whether a publish is correct. `getPages({ perspective: "draft" })` is what
|
|
40
|
+
* seeded the session, and the baseline is that same list, so the diff
|
|
41
|
+
* compares like with like and reports what *this session* changed. Diff
|
|
42
|
+
* against the live site instead and the difference includes every
|
|
43
|
+
* unpublished edit anybody made in the CMS — an Avocado publish then ships
|
|
44
|
+
* all of it, from a button whose label says nothing about that. An adapter
|
|
45
|
+
* that does not declare `perspectives` ignores the argument and answers with
|
|
46
|
+
* whatever it has, in which case the two are the same thing anyway.
|
|
47
|
+
*
|
|
38
48
|
* Absent when the session was never bootstrapped from the adapter, so treat
|
|
39
49
|
* `undefined` as "no baseline available" and not as "the site was empty" —
|
|
40
50
|
* publishing every field on that assumption is the overwrite this exists to
|
|
41
51
|
* prevent. An integration that embeds its own source snapshot in block props
|
|
42
52
|
* (the more precise approach, since only it knows what it projected) can
|
|
43
53
|
* ignore this.
|
|
54
|
+
*
|
|
55
|
+
* Prefer `baseline`. This field holds the same array and is kept because
|
|
56
|
+
* adapters are written against it.
|
|
57
|
+
*
|
|
58
|
+
* @deprecated Misnamed — read `baseline`.
|
|
44
59
|
*/
|
|
45
60
|
published?: PageDoc[];
|
|
61
|
+
/**
|
|
62
|
+
* The same array as `published`, under the name that says what it holds.
|
|
63
|
+
*
|
|
64
|
+
* The old name taught the opposite of the truth: an integrator reading
|
|
65
|
+
* "published" reasons that the baseline is the live site, concludes the diff
|
|
66
|
+
* would carry colleagues' unpublished work, and designs around a problem the
|
|
67
|
+
* orchestrator does not have — or, worse, does a second read of the published
|
|
68
|
+
* perspective and creates it.
|
|
69
|
+
*/
|
|
70
|
+
baseline?: PageDoc[];
|
|
46
71
|
}
|
|
47
72
|
/**
|
|
48
73
|
* Result of an `onPublish` call. `void` means "success, nothing to report";
|
|
@@ -51,6 +51,7 @@ import { createCmsBootstrapCache } from "../cms/bootstrap.js";
|
|
|
51
51
|
import { resolveCapabilities } from "../cms/adapter.js";
|
|
52
52
|
import { mediaSourceFromUnknown } from "../cms/media-sources.js";
|
|
53
53
|
import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
|
|
54
|
+
import { declareLibraryMount, observeLibraryMount } from "./library-mount.js";
|
|
54
55
|
import { checkAuth, resolveAuth } from "./auth.js";
|
|
55
56
|
import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
|
|
56
57
|
const defaultModelLookup = () => ({
|
|
@@ -416,16 +417,40 @@ export function createOrchestrator(config = {}) {
|
|
|
416
417
|
const gateLogger = config.logger ?? consoleLogger();
|
|
417
418
|
{
|
|
418
419
|
const resolved = resolveAuth(config.auth);
|
|
419
|
-
|
|
420
|
+
/*
|
|
421
|
+
* `next build` evaluates this route module to collect its exports, with
|
|
422
|
+
* `NODE_ENV=production` and none of the deployment's environment. With no
|
|
423
|
+
* credential configured the gate resolves to `closed` and this printed a
|
|
424
|
+
* red `[error]` in the middle of an otherwise clean, *successful* build —
|
|
425
|
+
* about a request that is not being served, on a machine that is not the
|
|
426
|
+
* deployment.
|
|
427
|
+
*
|
|
428
|
+
* It is still the only notice anyone gets of a real problem, so it is not
|
|
429
|
+
* silenced: during a build it is a warning that says which state it is
|
|
430
|
+
* describing. At runtime it stays an error, because then it means every
|
|
431
|
+
* request is actually being refused.
|
|
432
|
+
*/
|
|
433
|
+
const building = process.env.NEXT_PHASE === "phase-production-build";
|
|
434
|
+
const line = building
|
|
435
|
+
? `[auth] library mode: ${resolved.mode} — ${resolved.reason} ` +
|
|
436
|
+
`(evaluated during the build; set ACCESS_PASSWORD_HASH or ` +
|
|
437
|
+
`ORCHESTRATOR_ACCESS_TOKEN in the deployment's environment, not here)`
|
|
438
|
+
: `[auth] library mode: ${resolved.mode} — ${resolved.reason}`;
|
|
420
439
|
if (resolved.mode === "closed")
|
|
421
|
-
gateLogger.error(line);
|
|
440
|
+
building ? gateLogger.warn(line) : gateLogger.error(line);
|
|
422
441
|
else if (resolved.mode === "open-dev")
|
|
423
442
|
gateLogger.warn(line);
|
|
424
443
|
else
|
|
425
444
|
gateLogger.info(line);
|
|
426
445
|
}
|
|
446
|
+
// Tell the SDK's draft fetch where we are, so it stops defaulting to the
|
|
447
|
+
// standalone orchestrator on :4200 that a library-mode site does not run.
|
|
448
|
+
// See `library-mount.ts` for why this is not simply a config value.
|
|
449
|
+
if (config.previewUrl)
|
|
450
|
+
declareLibraryMount(`${config.previewUrl.replace(/\/+$/, "")}${basePath}`);
|
|
427
451
|
const handler = async function handler(request) {
|
|
428
452
|
const url = new URL(request.url);
|
|
453
|
+
observeLibraryMount(`${url.origin}${basePath}`);
|
|
429
454
|
const path = stripBasePath(url.pathname, basePath);
|
|
430
455
|
const cors = corsHeadersFor(request, config);
|
|
431
456
|
if (request.method === "OPTIONS") {
|
|
@@ -881,8 +906,18 @@ export function createOrchestrator(config = {}) {
|
|
|
881
906
|
return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
|
|
882
907
|
}
|
|
883
908
|
const config = selection.siteConfig;
|
|
909
|
+
/*
|
|
910
|
+
* `baseline` and `published` are the same array. The second name is what
|
|
911
|
+
* adapters were written against and the first is what it actually holds
|
|
912
|
+
* — the CMS's *draft*, read with `perspective: "draft"` when the adapter
|
|
913
|
+
* declares one, which is the only baseline that makes a publish mean
|
|
914
|
+
* "what this session changed".
|
|
915
|
+
*/
|
|
884
916
|
const context = body.assets || published
|
|
885
|
-
? {
|
|
917
|
+
? {
|
|
918
|
+
...(body.assets ? { assets: body.assets } : {}),
|
|
919
|
+
...(published ? { published, baseline: published } : {})
|
|
920
|
+
}
|
|
886
921
|
: undefined;
|
|
887
922
|
let unsupported = [];
|
|
888
923
|
// Default true: an adapter that does not mention `written` means what
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const LIBRARY_MOUNT_KEY = "__avocado_library_mount__";
|
|
2
|
+
/** Called at construction, from `previewUrl` + `basePath`. */
|
|
3
|
+
export declare function declareLibraryMount(url: string): void;
|
|
4
|
+
/** Called on each request, from the URL the runtime actually received. */
|
|
5
|
+
export declare function observeLibraryMount(url: string): void;
|
|
6
|
+
export declare function getLibraryMount(): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where this process's own orchestrator is mounted, if it has one.
|
|
3
|
+
*
|
|
4
|
+
* In library mode the orchestrator is a route inside the site's own Next app,
|
|
5
|
+
* and the SDK's draft fetch had no way to know that: with `ORCHESTRATOR_URL`
|
|
6
|
+
* unset it fell back to `http://127.0.0.1:4200`, the *standalone* orchestrator,
|
|
7
|
+
* which a library-mode site by definition does not run. The failure is silent
|
|
8
|
+
* when nothing is listening there (the draft fetch fails, the preview falls back
|
|
9
|
+
* to published content, and every edit appears to do nothing) and worse when
|
|
10
|
+
* something is — the fetch succeeds against a foreign process and the preview
|
|
11
|
+
* renders another project's pages. Anyone integrating is likely to have a :4200
|
|
12
|
+
* up, because that is what the standalone stack runs on.
|
|
13
|
+
*
|
|
14
|
+
* So the handler records its own address, and `getOrchestratorUrl()` in the SDK
|
|
15
|
+
* reads it through the same `globalThis` key. The key is a string on both sides
|
|
16
|
+
* rather than an import because `@avocadostudio-ai/orchestrator-core` is an
|
|
17
|
+
* *optional* peer of the SDK — a site that is not in library mode does not have
|
|
18
|
+
* it installed, and neither package may require the other.
|
|
19
|
+
*
|
|
20
|
+
* Two sources, in order of trust:
|
|
21
|
+
* - the origin of a real request this handler served, which cannot be wrong;
|
|
22
|
+
* - `config.previewUrl + basePath` at construction time, which is available
|
|
23
|
+
* before any request and is what covers a preview page that renders before
|
|
24
|
+
* the orchestrator route module has ever been evaluated.
|
|
25
|
+
*/
|
|
26
|
+
export const LIBRARY_MOUNT_KEY = "__avocado_library_mount__";
|
|
27
|
+
function slot() {
|
|
28
|
+
const g = globalThis;
|
|
29
|
+
return g[LIBRARY_MOUNT_KEY] ?? (g[LIBRARY_MOUNT_KEY] = {});
|
|
30
|
+
}
|
|
31
|
+
/** Called at construction, from `previewUrl` + `basePath`. */
|
|
32
|
+
export function declareLibraryMount(url) {
|
|
33
|
+
slot().declared = url.replace(/\/+$/, "");
|
|
34
|
+
}
|
|
35
|
+
/** Called on each request, from the URL the runtime actually received. */
|
|
36
|
+
export function observeLibraryMount(url) {
|
|
37
|
+
const s = slot();
|
|
38
|
+
const next = url.replace(/\/+$/, "");
|
|
39
|
+
if (s.observed !== next)
|
|
40
|
+
s.observed = next;
|
|
41
|
+
}
|
|
42
|
+
export function getLibraryMount() {
|
|
43
|
+
const s = slot();
|
|
44
|
+
return s.observed ?? s.declared ?? null;
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import OpenAI from "openai";
|
|
4
|
+
import { resolveDataDir } from "../state/data-dir.js";
|
|
4
5
|
import { listImages, fileNameToAlt, resolveGdriveFolderId } from "./gdrive-client.js";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Image generation timing — rolling average for progress estimation
|
|
@@ -218,7 +219,7 @@ export async function generateVariationImageWithOpenAI(args) {
|
|
|
218
219
|
const background = args.background ?? "auto";
|
|
219
220
|
const outputFormat = args.outputFormat ?? "png";
|
|
220
221
|
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
221
|
-
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(
|
|
222
|
+
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(resolveDataDir(), "generated-images");
|
|
222
223
|
const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
223
224
|
args.log?.info({ event: "openai_image_start", model, size, background, outputFormat, promptLength: args.prompt.length }, "Starting OpenAI image generation");
|
|
224
225
|
const genStartMs = Date.now();
|
|
@@ -270,7 +271,7 @@ export async function generateVariationImageWithOpenAI(args) {
|
|
|
270
271
|
// Shared image save utility
|
|
271
272
|
// ---------------------------------------------------------------------------
|
|
272
273
|
export async function saveGeneratedImage(bytes, prefix = "gen", ext = "png") {
|
|
273
|
-
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(
|
|
274
|
+
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(resolveDataDir(), "generated-images");
|
|
274
275
|
const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
275
276
|
const fileName = `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
276
277
|
await mkdir(generatedImageDir, { recursive: true });
|
|
@@ -11,6 +11,25 @@ export declare function readPathValue(root: unknown, path: string): unknown;
|
|
|
11
11
|
* - "items[0].image.alt" → props.items[0].image.src
|
|
12
12
|
*/
|
|
13
13
|
export declare function resolveImageUrlForAltField(blockProps: Record<string, unknown>, editablePath: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Turn whatever the page wrote in an image prop into a URL something can
|
|
16
|
+
* actually fetch, or nothing at all.
|
|
17
|
+
*
|
|
18
|
+
* `resolveImageUrlForAltField` reads its answer straight out of block props, so
|
|
19
|
+
* it is whatever the site put there — and a site most often puts a root-relative
|
|
20
|
+
* path (`/media/pool.webp`), which names a file on the *site* and is not a URL
|
|
21
|
+
* any model, or this process, can resolve on its own. It went to the provider
|
|
22
|
+
* verbatim, and Anthropic answered `400 … Only HTTPS URLs are supported` for the
|
|
23
|
+
* whole request: selecting a hero image and asking an unrelated question failed
|
|
24
|
+
* the turn outright, over the least important thing it was carrying.
|
|
25
|
+
*
|
|
26
|
+
* `previewUrl` is the site's own origin — the same value `/preview/screenshot`
|
|
27
|
+
* resolves a page path against — so a relative path becomes an absolute one the
|
|
28
|
+
* loopback fetch below can turn into base64. A site that never declared one
|
|
29
|
+
* gets `undefined` and loses vision for that turn, which is the correct trade:
|
|
30
|
+
* the alt text is a nicety, the answer is the request.
|
|
31
|
+
*/
|
|
32
|
+
export declare function absolutizeVisionImageUrl(raw: string, previewUrl?: unknown): string | undefined;
|
|
14
33
|
/**
|
|
15
34
|
* Fetch an image URL and return base64 + media type.
|
|
16
35
|
* For localhost URLs the AI APIs can't reach, we fetch locally.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSessionDraft, getRecentEdits, getSiteConfig, orderSlugsHomeFirst } from "../state/session-state.js";
|
|
2
|
+
import { getLibraryMount } from "../handler/library-mount.js";
|
|
2
3
|
import { resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Path traversal
|
|
@@ -58,6 +59,71 @@ export function resolveImageUrlForAltField(blockProps, editablePath) {
|
|
|
58
59
|
const value = readPathValue(blockProps, companionPath);
|
|
59
60
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Turn whatever the page wrote in an image prop into a URL something can
|
|
64
|
+
* actually fetch, or nothing at all.
|
|
65
|
+
*
|
|
66
|
+
* `resolveImageUrlForAltField` reads its answer straight out of block props, so
|
|
67
|
+
* it is whatever the site put there — and a site most often puts a root-relative
|
|
68
|
+
* path (`/media/pool.webp`), which names a file on the *site* and is not a URL
|
|
69
|
+
* any model, or this process, can resolve on its own. It went to the provider
|
|
70
|
+
* verbatim, and Anthropic answered `400 … Only HTTPS URLs are supported` for the
|
|
71
|
+
* whole request: selecting a hero image and asking an unrelated question failed
|
|
72
|
+
* the turn outright, over the least important thing it was carrying.
|
|
73
|
+
*
|
|
74
|
+
* `previewUrl` is the site's own origin — the same value `/preview/screenshot`
|
|
75
|
+
* resolves a page path against — so a relative path becomes an absolute one the
|
|
76
|
+
* loopback fetch below can turn into base64. A site that never declared one
|
|
77
|
+
* gets `undefined` and loses vision for that turn, which is the correct trade:
|
|
78
|
+
* the alt text is a nicety, the answer is the request.
|
|
79
|
+
*/
|
|
80
|
+
export function absolutizeVisionImageUrl(raw, previewUrl) {
|
|
81
|
+
if (!raw)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (/^data:/i.test(raw))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw))
|
|
86
|
+
return raw;
|
|
87
|
+
// Protocol-relative (`//host/x.png`) has an origin already; anything else
|
|
88
|
+
// relative needs one, and only the site can supply it.
|
|
89
|
+
if (raw.startsWith("//"))
|
|
90
|
+
return `https:${raw}`;
|
|
91
|
+
if (typeof previewUrl !== "string" || previewUrl.trim() === "")
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
return new URL(raw, previewUrl.replace(/\/+$/, "") + "/").toString();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The origin a relative image path belongs to: this site's own.
|
|
102
|
+
*
|
|
103
|
+
* Two sources, because neither covers both modes. `previewUrl` is what
|
|
104
|
+
* `/sites/register` writes and what `/preview/screenshot` resolves page paths
|
|
105
|
+
* against — but library mode passes it to `createOrchestrator()` rather than
|
|
106
|
+
* registering it, so it is absent from the config of the very sites whose
|
|
107
|
+
* content is most likely to be relative. The library mount is that site's own
|
|
108
|
+
* address, observed from a request it actually served, so its origin is the
|
|
109
|
+
* site: exactly the base a `/media/...` path is written against.
|
|
110
|
+
*/
|
|
111
|
+
function siteOriginForVision(session) {
|
|
112
|
+
// `previewUrl` is not in `siteConfigSchema` — `/sites/register` merges it in
|
|
113
|
+
// as an unvalidated extra, so it can only be read off the config as unknown.
|
|
114
|
+
const registered = getSiteConfig(session).previewUrl;
|
|
115
|
+
if (typeof registered === "string" && registered.trim() !== "")
|
|
116
|
+
return registered;
|
|
117
|
+
const mount = getLibraryMount();
|
|
118
|
+
if (!mount)
|
|
119
|
+
return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return new URL(mount).origin;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
61
127
|
function isLocalUrl(url) {
|
|
62
128
|
try {
|
|
63
129
|
const parsed = new URL(url);
|
|
@@ -302,6 +368,12 @@ export function plannerContextPack(args) {
|
|
|
302
368
|
const { session, slug, message, currentPage, activeBlockId, activeBlockType, activeEditablePath } = args;
|
|
303
369
|
const pageRoutes = orderSlugsHomeFirst(Array.from(getSessionDraft(session).keys()));
|
|
304
370
|
const siteConfigSummary = summarizeSiteConfigForPlanner(getSiteConfig(session), pageRoutes);
|
|
371
|
+
const rawVisionImageUrl = activeBlockId && activeEditablePath
|
|
372
|
+
? resolveImageUrlForAltField((currentPage.blocks.find((b) => b.id === activeBlockId)?.props ?? {}), activeEditablePath)
|
|
373
|
+
: undefined;
|
|
374
|
+
const visionImageUrl = rawVisionImageUrl
|
|
375
|
+
? absolutizeVisionImageUrl(rawVisionImageUrl, siteOriginForVision(session))
|
|
376
|
+
: undefined;
|
|
305
377
|
const selectedIdx = activeBlockId ? currentPage.blocks.findIndex((b) => b.id === activeBlockId) : -1;
|
|
306
378
|
const neighbors = selectedIdx >= 0
|
|
307
379
|
? {
|
|
@@ -320,9 +392,7 @@ export function plannerContextPack(args) {
|
|
|
320
392
|
blockType: activeBlockType ?? null,
|
|
321
393
|
editablePath: activeEditablePath ?? null,
|
|
322
394
|
block: selectedBlockSnapshot({ currentPage, activeBlockId, activeEditablePath }),
|
|
323
|
-
imageUrlForVision:
|
|
324
|
-
? resolveImageUrlForAltField((currentPage.blocks.find((b) => b.id === activeBlockId)?.props ?? {}), activeEditablePath) ?? null
|
|
325
|
-
: null
|
|
395
|
+
imageUrlForVision: visionImageUrl ?? null
|
|
326
396
|
},
|
|
327
397
|
neighbors: {
|
|
328
398
|
previous: neighbors.previous ? { id: neighbors.previous.id, type: neighbors.previous.type } : null,
|
|
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
+
import { resolveDataDir } from "../state/data-dir.js";
|
|
7
8
|
import { pageDocSchema } from "@avocadostudio-ai/shared";
|
|
8
9
|
import { draftPages, versions, ensureHeroImageProps, persistStateNow, getSessionPages, getSiteConfig, isLegacySiteId } from "../state/session-state.js";
|
|
9
10
|
import { toErrorDetail } from "../ops/ops-engine.js";
|
|
@@ -413,7 +414,7 @@ export async function publishViaGit(session, content) {
|
|
|
413
414
|
let copiedImages = false;
|
|
414
415
|
if (imageUrlMap.size > 0) {
|
|
415
416
|
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ??
|
|
416
|
-
resolve(
|
|
417
|
+
resolve(resolveDataDir(), "generated-images");
|
|
417
418
|
await mkdir(imageDestDir, { recursive: true });
|
|
418
419
|
for (const [, fileName] of imageUrlMap) {
|
|
419
420
|
const src = resolve(generatedImageDir, fileName);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function isLegacyMonorepoLayout(cwd?: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* The `.data` directory for this process, or the legacy monorepo one when this
|
|
4
|
+
* really is a package inside the monorepo and that directory already exists.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveDataDir(cwd?: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/*
|
|
4
|
+
* Where this process keeps its own files: the database, generated images, the
|
|
5
|
+
* agent log.
|
|
6
|
+
*
|
|
7
|
+
* The answer is `<cwd>/.data`. It used to be `<cwd>/../../.data`, which is
|
|
8
|
+
* `apps/orchestrator`'s position in *this* repo and nobody else's — a
|
|
9
|
+
* library-mode host runs from its own project root, so it wrote two directories
|
|
10
|
+
* above itself, outside the project and outside version control, into a
|
|
11
|
+
* directory shared with every sibling checkout that made the same mistake.
|
|
12
|
+
*
|
|
13
|
+
* The legacy location is still honoured for an existing monorepo checkout, but
|
|
14
|
+
* only on proof of workspace membership. The first version of that back-compat
|
|
15
|
+
* check asked `existsSync(legacy)` alone — "has anyone ever created that file",
|
|
16
|
+
* not "am I a package in the workspace that owns it" — so one mistaken write
|
|
17
|
+
* captured every unrelated project under that parent, permanently, because the
|
|
18
|
+
* bug is what created the file that re-triggered it.
|
|
19
|
+
*/
|
|
20
|
+
export function isLegacyMonorepoLayout(cwd = process.cwd()) {
|
|
21
|
+
return (existsSync(resolve(cwd, "../../pnpm-workspace.yaml")) &&
|
|
22
|
+
existsSync(resolve(cwd, "package.json")));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The `.data` directory for this process, or the legacy monorepo one when this
|
|
26
|
+
* really is a package inside the monorepo and that directory already exists.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveDataDir(cwd = process.cwd()) {
|
|
29
|
+
if (isLegacyMonorepoLayout(cwd)) {
|
|
30
|
+
const legacy = resolve(cwd, "../../.data");
|
|
31
|
+
if (existsSync(legacy))
|
|
32
|
+
return legacy;
|
|
33
|
+
}
|
|
34
|
+
return resolve(cwd, ".data");
|
|
35
|
+
}
|
|
@@ -252,6 +252,27 @@ export declare function publishedPageCountGlobal(): number;
|
|
|
252
252
|
export declare function markRecentlyRestored(session: string): void;
|
|
253
253
|
export declare function consumeRecentlyRestored(session: string): boolean;
|
|
254
254
|
export declare const toErrorDetail: typeof _unifiedToErrorDetail;
|
|
255
|
+
/**
|
|
256
|
+
* Fill in the props Avocado's own Hero and TwoColumn need but a stored page may
|
|
257
|
+
* predate.
|
|
258
|
+
*
|
|
259
|
+
* **Only for blocks Avocado defines.** Both halves are keyed on a type *name*,
|
|
260
|
+
* and a name is all a site's own `Hero` shares with ours. Run them against one
|
|
261
|
+
* and they invent content: a `Hero` whose image lives in `backgroundAsset` is
|
|
262
|
+
* given an `imageUrl` pointing at our placeholder and an English alt string on
|
|
263
|
+
* a German page; a `TwoColumn` with its own `variant` has it rewritten to
|
|
264
|
+
* `"default"`, and its `heading`/`body` are copied into a `left`/`right` pair
|
|
265
|
+
* it does not render. Nothing is lost visibly, because the site renders its own
|
|
266
|
+
* props and ignores the additions — but they land in the draft, the publish
|
|
267
|
+
* baseline is the un-mutated page the adapter returned, so the diff attributes
|
|
268
|
+
* every one of them to the session, and a publish writes them into the CMS.
|
|
269
|
+
* This ran on the CMS seed path, which is where a page nobody edited picks them
|
|
270
|
+
* up.
|
|
271
|
+
*
|
|
272
|
+
* `isBuiltinBlock` is written at registration, so a site that registers over
|
|
273
|
+
* `Hero` turns both halves off for its own blocks and leaves them on for any
|
|
274
|
+
* built-in it still uses.
|
|
275
|
+
*/
|
|
255
276
|
export declare function ensureHeroImageProps(page: PageDoc): void;
|
|
256
277
|
export declare function orderSlugsHomeFirst(slugs: string[]): string[];
|
|
257
278
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER, isBuiltinBlock } from "@avocadostudio-ai/shared";
|
|
4
4
|
import { toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
|
|
5
5
|
import { archiveMigratedJson, getStore, notePersistenceFailure, notePersistenceSuccess, readLegacyJson, resolveDbFile, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
|
|
6
6
|
import { discardPendingProposalsForSession, sweepExpiredProposals } from "../durable/durable-store-singleton.js";
|
|
@@ -255,9 +255,32 @@ export const toErrorDetail = _unifiedToErrorDetail;
|
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
256
|
// Hero image prop guard (needed by getSessionDraft, setPage, applyPersistedState)
|
|
257
257
|
// ---------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Fill in the props Avocado's own Hero and TwoColumn need but a stored page may
|
|
260
|
+
* predate.
|
|
261
|
+
*
|
|
262
|
+
* **Only for blocks Avocado defines.** Both halves are keyed on a type *name*,
|
|
263
|
+
* and a name is all a site's own `Hero` shares with ours. Run them against one
|
|
264
|
+
* and they invent content: a `Hero` whose image lives in `backgroundAsset` is
|
|
265
|
+
* given an `imageUrl` pointing at our placeholder and an English alt string on
|
|
266
|
+
* a German page; a `TwoColumn` with its own `variant` has it rewritten to
|
|
267
|
+
* `"default"`, and its `heading`/`body` are copied into a `left`/`right` pair
|
|
268
|
+
* it does not render. Nothing is lost visibly, because the site renders its own
|
|
269
|
+
* props and ignores the additions — but they land in the draft, the publish
|
|
270
|
+
* baseline is the un-mutated page the adapter returned, so the diff attributes
|
|
271
|
+
* every one of them to the session, and a publish writes them into the CMS.
|
|
272
|
+
* This ran on the CMS seed path, which is where a page nobody edited picks them
|
|
273
|
+
* up.
|
|
274
|
+
*
|
|
275
|
+
* `isBuiltinBlock` is written at registration, so a site that registers over
|
|
276
|
+
* `Hero` turns both halves off for its own blocks and leaves them on for any
|
|
277
|
+
* built-in it still uses.
|
|
278
|
+
*/
|
|
258
279
|
export function ensureHeroImageProps(page) {
|
|
259
280
|
for (const block of page.blocks) {
|
|
260
281
|
const props = block.props;
|
|
282
|
+
if (!isBuiltinBlock(block.type))
|
|
283
|
+
continue;
|
|
261
284
|
if (block.type === "Hero") {
|
|
262
285
|
// Skip imageUrl fallback if the block uses carouselImages instead
|
|
263
286
|
if (typeof props.imageUrl !== "string" || props.imageUrl.length === 0) {
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { rename, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, resolve } from "node:path";
|
|
4
4
|
import { SqliteStore } from "./sqlite-store.js";
|
|
5
|
+
import { resolveDataDir } from "./data-dir.js";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Config
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -17,23 +18,8 @@ export function resolveDbFile() {
|
|
|
17
18
|
return ":memory:";
|
|
18
19
|
if (process.env.NODE_ENV === "test")
|
|
19
20
|
return ":memory:";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
* It is right for `apps/orchestrator`, whose cwd is two levels under the repo
|
|
24
|
-
* root, and wrong for everyone else: a library-mode host running from its own
|
|
25
|
-
* project root writes two directories *above* itself. PBA's landed in
|
|
26
|
-
* `~/Projects/.data` — outside the project, outside version control, and
|
|
27
|
-
* shared with any sibling checkout that made the same mistake.
|
|
28
|
-
*
|
|
29
|
-
* The default is now the host's own `.data/`. The legacy path still wins when
|
|
30
|
-
* a database is already sitting there, so an existing monorepo checkout keeps
|
|
31
|
-
* its state without an env var and without a migration step.
|
|
32
|
-
*/
|
|
33
|
-
const legacy = resolve(process.cwd(), "../../.data/orchestrator.db");
|
|
34
|
-
if (existsSync(legacy))
|
|
35
|
-
return legacy;
|
|
36
|
-
return resolve(process.cwd(), ".data/orchestrator.db");
|
|
21
|
+
// `resolveDataDir` carries the whole story of why this is not `../../.data`.
|
|
22
|
+
return resolve(resolveDataDir(), "orchestrator.db");
|
|
37
23
|
}
|
|
38
24
|
export function resolveJsonMigrationTtlDays() {
|
|
39
25
|
const raw = Number(process.env.ORCHESTRATOR_JSON_MIGRATION_TTL_DAYS ?? 14);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"openai": "^4.87.1",
|
|
24
24
|
"sharp": "^0.34.5",
|
|
25
25
|
"zod": "^4.3.6",
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "^0.
|
|
27
|
-
"@avocadostudio-ai/shared": "^0.
|
|
26
|
+
"@avocadostudio-ai/migration-sdk": "^0.5.1",
|
|
27
|
+
"@avocadostudio-ai/shared": "^0.5.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@google/genai": "^1.46.0",
|