@avocadostudio-ai/orchestrator-core 0.11.4 → 0.11.5

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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { keyFileHint } from "../env-file-location.js";
2
3
  import { blockManifestSchema } from "@avocadostudio-ai/shared";
3
4
  import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
4
5
  import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
@@ -2997,7 +2998,7 @@ export async function runChatPipeline(ctx, body, options) {
2997
2998
  markPlanningStart();
2998
2999
  try {
2999
3000
  emitStatusTone("planning");
3000
- const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
3001
+ const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType), keyFileHint());
3001
3002
  markPlanningFinish();
3002
3003
  const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
3003
3004
  if (outcome.done)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve the absolute path of the file to add the key to.
3
+ *
4
+ * Walks up from `cwd` for a directory that already holds a `package.json`,
5
+ * taking an existing env file there if there is one and naming the file that
6
+ * *should* exist if there is not. Returns the caller's best guess rather than
7
+ * nothing: a path that does not exist yet is still the right answer to "where
8
+ * do I put this", and creating it is the instruction.
9
+ */
10
+ export declare function resolveEnvFilePath(cwd?: string): string;
11
+ /**
12
+ * The path to show a user, or `undefined` when showing one would be wrong.
13
+ *
14
+ * Withheld in production for two reasons, and the second is the one that
15
+ * matters. It would put a server filesystem path into a browser — mildly
16
+ * careless on its own. But the advice attached to it ("add it to this file and
17
+ * restart the dev server") is also simply **false** in production: there is no
18
+ * dev server, the file is not deployed, and a key added to it changes nothing.
19
+ * A precise path attached to instructions that cannot work is worse than the
20
+ * vague version, because it reads as authoritative.
21
+ */
22
+ export declare function keyFileHint(env?: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Where the user should actually put their API key.
3
+ *
4
+ * Every piece of copy in this product tells someone to "add a key to
5
+ * `.env.local` in your project root — the same folder as `package.json`", and
6
+ * none of it says *which* folder that is. The editor runs on its own origin,
7
+ * usually started by a script the user did not write, from a directory they may
8
+ * not have chosen; the terminal they are reading is running two processes; and
9
+ * the instruction arrives inside a browser tab that has no filesystem at all.
10
+ * "Your project root" is a description, not an address, and the person reading
11
+ * it is precisely the person who does not yet know the layout.
12
+ *
13
+ * The server does know. It is running in that directory. So it says so.
14
+ *
15
+ * Only in development, deliberately — see `keyFileHint`.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ /**
20
+ * The env files a key could live in, in the order the reader should prefer.
21
+ *
22
+ * `.env.local` first because that is what every doc and the scaffolder name,
23
+ * and because it is the one git ignores. `.env` second so an existing project
24
+ * that already keeps its secrets there is told to use the file it has rather
25
+ * than a second one that will silently take precedence.
26
+ */
27
+ const CANDIDATES = [".env.local", ".env"];
28
+ /** How far up to look before giving up. A monorepo puts the app a few levels down. */
29
+ const MAX_DEPTH = 5;
30
+ /**
31
+ * Resolve the absolute path of the file to add the key to.
32
+ *
33
+ * Walks up from `cwd` for a directory that already holds a `package.json`,
34
+ * taking an existing env file there if there is one and naming the file that
35
+ * *should* exist if there is not. Returns the caller's best guess rather than
36
+ * nothing: a path that does not exist yet is still the right answer to "where
37
+ * do I put this", and creating it is the instruction.
38
+ */
39
+ export function resolveEnvFilePath(cwd = process.cwd()) {
40
+ let dir = resolve(cwd);
41
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
42
+ for (const candidate of CANDIDATES) {
43
+ if (existsSync(join(dir, candidate)))
44
+ return join(dir, candidate);
45
+ }
46
+ // No env file here, but if this is the project root then this is where one goes.
47
+ if (existsSync(join(dir, "package.json")))
48
+ return join(dir, CANDIDATES[0]);
49
+ const parent = dirname(dir);
50
+ if (parent === dir)
51
+ break;
52
+ dir = parent;
53
+ }
54
+ return join(resolve(cwd), CANDIDATES[0]);
55
+ }
56
+ /**
57
+ * The path to show a user, or `undefined` when showing one would be wrong.
58
+ *
59
+ * Withheld in production for two reasons, and the second is the one that
60
+ * matters. It would put a server filesystem path into a browser — mildly
61
+ * careless on its own. But the advice attached to it ("add it to this file and
62
+ * restart the dev server") is also simply **false** in production: there is no
63
+ * dev server, the file is not deployed, and a key added to it changes nothing.
64
+ * A precise path attached to instructions that cannot work is worse than the
65
+ * vague version, because it reads as authoritative.
66
+ */
67
+ export function keyFileHint(env = process.env) {
68
+ if ((env.NODE_ENV ?? "").trim() === "production")
69
+ return undefined;
70
+ try {
71
+ return resolveEnvFilePath();
72
+ }
73
+ catch {
74
+ // A sandboxed or read-only filesystem is not a reason to fail a chat turn.
75
+ return undefined;
76
+ }
77
+ }
@@ -22,6 +22,7 @@ import { resolve, basename } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
23
23
  import { z } from "zod";
24
24
  import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings } from "@avocadostudio-ai/shared";
25
+ import { keyFileHint } from "../env-file-location.js";
25
26
  import { chatRequestBodySchema } from "../nlp/intent-detection.js";
26
27
  import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
27
28
  import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
@@ -316,6 +317,7 @@ const SUPPORTED_ROUTES = [
316
317
  "POST /image/generate",
317
318
  "POST /image/generate/chat",
318
319
  "POST /image/interpret",
320
+ "POST /attachment/upload",
319
321
  "POST /image/upload",
320
322
  "GET /generated-images/:fileName"
321
323
  ];
@@ -347,7 +349,14 @@ const EXT_TO_MIME = {
347
349
  webp: "image/webp",
348
350
  gif: "image/gif",
349
351
  avif: "image/avif",
350
- svg: "image/svg+xml"
352
+ svg: "image/svg+xml",
353
+ /*
354
+ * Not an image, and it has to be here anyway: `/attachment/upload` accepts
355
+ * PDFs, and this map is what `/generated-images/` serves them back through.
356
+ * Without it a PDF uploads successfully and then 415s on the GET — stored,
357
+ * referenced in the chat, and unreadable.
358
+ */
359
+ pdf: "application/pdf"
351
360
  };
352
361
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "../cms/index.js";
353
362
  function stripBasePath(pathname, basePath) {
@@ -1129,6 +1138,17 @@ export function createOrchestrator(config = {}) {
1129
1138
  ...(config.siteName ? { name: config.siteName } : {}),
1130
1139
  demoContent: config.demoContent === true
1131
1140
  },
1141
+ /*
1142
+ * Where the user should put their key, as an absolute path.
1143
+ *
1144
+ * Every piece of copy naming `.env.local` describes a file without
1145
+ * addressing it, and the reader is in a browser tab with no
1146
+ * filesystem, on an origin that is not the site's. The server is
1147
+ * running in that directory, so it is the only party that can answer.
1148
+ * Absent in production, where the advice attached to it would be
1149
+ * false — see `env-file-location.ts`.
1150
+ */
1151
+ ...(keyFileHint() ? { keyFilePath: keyFileHint() } : {}),
1132
1152
  features: {
1133
1153
  googleDrive: false,
1134
1154
  unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
@@ -1334,8 +1354,29 @@ export function createOrchestrator(config = {}) {
1334
1354
  const scopedSession = scope(body.session, body.siteId);
1335
1355
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
1336
1356
  const parsedOps = z.array(operationSchema).safeParse(body.ops);
1337
- if (!parsedOps.success)
1338
- return jsonResponse({ error: "invalid ops payload", details: parsedOps.error.issues }, { status: 400, cors });
1357
+ if (!parsedOps.success) {
1358
+ /*
1359
+ * Name the key, and say what an operation looks like.
1360
+ *
1361
+ * Zod reports the *failure*, not the *expectation*: a body with no
1362
+ * `ops` array produced `{"expected":"array","path":[],"message":
1363
+ * "expected array, received undefined"}` — a root-level path pointing
1364
+ * at nothing, never naming `ops`. Someone wiring this by hand from the
1365
+ * documented op vocabulary needed three consecutive 400s to discover
1366
+ * the envelope key, then that the discriminator is `op` and not `type`,
1367
+ * and only then saw the real shape. Each error revealed exactly one
1368
+ * layer, which is the most expensive way to publish a schema.
1369
+ *
1370
+ * `example` is a valid minimal operation on purpose: it answers all
1371
+ * three of those questions at once, in the first response.
1372
+ */
1373
+ return jsonResponse({
1374
+ error: "invalid ops payload",
1375
+ expected: "a JSON body of { session, siteId, ops: Operation[] }",
1376
+ example: { session: "dev", siteId: "my-site", ops: [{ op: "update_props", pageSlug: "/", blockId: "hero-1", patch: { heading: "New heading" } }] },
1377
+ details: parsedOps.error.issues
1378
+ }, { status: 400, cors });
1379
+ }
1339
1380
  if (parsedOps.data.length === 0)
1340
1381
  return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
1341
1382
  let manifest;
@@ -2122,6 +2163,72 @@ export function createOrchestrator(config = {}) {
2122
2163
  const bytes = Buffer.from(await file.arrayBuffer());
2123
2164
  return actionResponse(await interpretImageAction({ bytes, byteLength: bytes.byteLength, mimeType: file.type }, { log: runtime.log }), cors);
2124
2165
  }
2166
+ /*
2167
+ * The composer's paperclip, which library mode never answered.
2168
+ *
2169
+ * The editor's `useMediaInput` posts every chat attachment here,
2170
+ * unconditionally and with no capability probe, and this handler had no
2171
+ * such route — so the control shipped enabled on the first keyless screen
2172
+ * of every scaffolded project and answered `405` with
2173
+ * "not handled by createOrchestrator()" rendered in red under the composer.
2174
+ * It existed only in the standalone Fastify orchestrator
2175
+ * (`apps/orchestrator/src/routes/media.ts`), while library mode is what the
2176
+ * scaffold ships and what the docs call the shape most integrations use.
2177
+ * `features/chat-attachments.md` documents the flow with no caveat.
2178
+ *
2179
+ * Implemented rather than hidden, because nothing was actually missing:
2180
+ * `/image/upload` below already writes to `imageDir` and
2181
+ * `/generated-images/` already serves it back. The route was the only
2182
+ * absent part, and a documented feature is better answered than suppressed.
2183
+ *
2184
+ * Accepts either field name. Fastify's multipart reader takes the first
2185
+ * file part whatever it is called, so the standalone route never had to
2186
+ * care; a Web `FormData` does. The composer sends `file`, the image
2187
+ * endpoints send `image`, and making the two disagree across modes is how
2188
+ * this class of defect started.
2189
+ */
2190
+ if (request.method === "POST" && path === "/attachment/upload") {
2191
+ let form;
2192
+ try {
2193
+ form = await request.formData();
2194
+ }
2195
+ catch {
2196
+ return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
2197
+ }
2198
+ const file = form.get("file") ?? form.get("image");
2199
+ if (!(file instanceof File)) {
2200
+ return jsonResponse({ error: "missing 'file' field" }, { status: 400, cors });
2201
+ }
2202
+ /*
2203
+ * PDFs are attachable and are not images, so the image MIME map is not
2204
+ * the right gate here — it is the gate for what a `<img>` can render.
2205
+ */
2206
+ const ext = file.type === "application/pdf" ? "pdf" : MIME_TO_EXT[file.type];
2207
+ if (!ext) {
2208
+ return jsonResponse({ error: `unsupported attachment type: ${file.type || "unknown"}` }, { status: 415, cors });
2209
+ }
2210
+ if (file.size > MAX_UPLOAD_BYTES) {
2211
+ return jsonResponse({ error: `attachment exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
2212
+ }
2213
+ const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
2214
+ try {
2215
+ await mkdir(imageDir, { recursive: true });
2216
+ await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
2217
+ }
2218
+ catch (err) {
2219
+ return jsonResponse({ error: "attachment upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
2220
+ }
2221
+ return jsonResponse({
2222
+ url: `${basePath}/generated-images/${fileName}`,
2223
+ bytes: file.size,
2224
+ mimeType: file.type,
2225
+ // `name` and `kind` are what the composer renders in the attachment
2226
+ // chip. Without them it falls back to the local File, which is right
2227
+ // but makes the two modes' responses differ for no reason.
2228
+ name: file.name,
2229
+ kind: file.type === "application/pdf" ? "pdf" : "image"
2230
+ }, { status: 200, cors });
2231
+ }
2125
2232
  if (request.method === "POST" && path === "/image/upload") {
2126
2233
  let form;
2127
2234
  try {
@@ -74,7 +74,22 @@ export declare function blockContractsSummary(manifest?: BlockManifest): Record<
74
74
  * they are the phrasings this planner *can* execute without a key.
75
75
  */
76
76
  export declare const KEYLESS_PLANNER_NOTICE: string;
77
- export declare function withKeylessNotice(plan: EditPlan): EditPlan;
77
+ /**
78
+ * The same notice, naming the file by its full path when we can.
79
+ *
80
+ * "Add it to `.env.local`" is a description of a file, not an address, and the
81
+ * person reading it is by definition the one who does not yet know the layout:
82
+ * the message arrives in a browser tab, on an origin that is not the site's,
83
+ * from a server started by a script they did not write. Saying *which*
84
+ * `.env.local` removes the only step in this instruction that requires a guess.
85
+ *
86
+ * Falls back to the constant above whenever a path would be wrong or
87
+ * unavailable — in production there is no dev server to restart and the file is
88
+ * not deployed, so a precise path there would be authoritative and false. See
89
+ * `keyFileHint`.
90
+ */
91
+ export declare function keylessPlannerNotice(envFilePath?: string): string;
92
+ export declare function withKeylessNotice(plan: EditPlan, envFilePath?: string): EditPlan;
78
93
  /**
79
94
  * Drop the suggestions this planner could not act on.
80
95
  *
@@ -627,10 +627,30 @@ export function blockContractsSummary(manifest) {
627
627
  */
628
628
  export const KEYLESS_PLANNER_NOTICE = "There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
629
629
  "Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
630
- export function withKeylessNotice(plan) {
630
+ /**
631
+ * The same notice, naming the file by its full path when we can.
632
+ *
633
+ * "Add it to `.env.local`" is a description of a file, not an address, and the
634
+ * person reading it is by definition the one who does not yet know the layout:
635
+ * the message arrives in a browser tab, on an origin that is not the site's,
636
+ * from a server started by a script they did not write. Saying *which*
637
+ * `.env.local` removes the only step in this instruction that requires a guess.
638
+ *
639
+ * Falls back to the constant above whenever a path would be wrong or
640
+ * unavailable — in production there is no dev server to restart and the file is
641
+ * not deployed, so a precise path there would be authoritative and false. See
642
+ * `keyFileHint`.
643
+ */
644
+ export function keylessPlannerNotice(envFilePath) {
645
+ if (!envFilePath)
646
+ return KEYLESS_PLANNER_NOTICE;
647
+ return ("There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
648
+ `Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to ${envFilePath} and restart the dev server to chat for real.`);
649
+ }
650
+ export function withKeylessNotice(plan, envFilePath) {
631
651
  if (plan.ops.length > 0)
632
652
  return plan;
633
- return { ...plan, summary_for_user: KEYLESS_PLANNER_NOTICE };
653
+ return { ...plan, summary_for_user: keylessPlannerNotice(envFilePath) };
634
654
  }
635
655
  /**
636
656
  * Drop the suggestions this planner could not act on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -22,8 +22,8 @@
22
22
  "openai": "^4.87.1",
23
23
  "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/shared": "^0.11.4",
26
- "@avocadostudio-ai/migration-sdk": "^0.11.4"
25
+ "@avocadostudio-ai/migration-sdk": "^0.11.5",
26
+ "@avocadostudio-ai/shared": "^0.11.5"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",