dreamlayer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,6 @@ dreamlayer generate "A glass greenhouse at dusk" --out greenhouse.png
12
12
  Get a key at [platform.dreamlayer.io](https://platform.dreamlayer.io). A new account
13
13
  starts at zero credits, and each finished image costs one.
14
14
 
15
- > **Not yet publishable.** This package sends an `operation` field that requires
16
- > the gateway build adding it to `ExecuteRequest`. Against the currently deployed
17
- > API every call returns `422 extra_forbidden`. Deploy that build first.
18
-
19
15
  ## Commands
20
16
 
21
17
  ```bash
@@ -31,6 +27,10 @@ dreamlayer capabilities # spends nothing
31
27
  `cutout` and `upscale` name their operation rather than hoping a sentence is read the
32
28
  way you meant, so they run a dedicated chain and never stop to ask a question.
33
29
 
30
+ Image inputs may be PNG, JPEG, WebP, or supported camera RAW files up to 200 MB.
31
+ DreamLayer develops RAW previews, applies camera orientation, and resizes oversized
32
+ sources on the server before any operation runs.
33
+
34
34
  `upscale` doubles each side and finished images are capped at 4096 per side, so the
35
35
  longest side of your input must be 2048 or less. Anything larger is refused before it
36
36
  costs you a credit.
package/dist/cli.js CHANGED
@@ -15,10 +15,12 @@
15
15
  * 6 the run ended asking a question instead of producing an image
16
16
  */
17
17
  import { randomUUID } from "node:crypto";
18
- import { readFile, writeFile } from "node:fs/promises";
18
+ import { openAsBlob, readFileSync } from "node:fs";
19
+ import { stat, writeFile } from "node:fs/promises";
19
20
  import path from "node:path";
20
- import { ApiError, ManagedClient, StreamIdleError, } from "./client.js";
21
+ import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, } from "./client.js";
21
22
  import { Progress, consume } from "./render.js";
23
+ const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
22
24
  const USAGE = `dreamlayer - generate and edit images from your terminal
23
25
 
24
26
  USAGE
@@ -104,24 +106,21 @@ function client() {
104
106
  }
105
107
  return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
106
108
  }
107
- /** Upload a local file and return its asset id, with size and type checked here first. */
109
+ const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
110
+ /** Upload a local file; the server owns RAW, EXIF, alpha, and resize normalization. */
108
111
  async function upload(api, file) {
109
112
  const resolved = path.resolve(file);
110
- const extension = path.extname(resolved).toLowerCase();
111
- if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
112
- throw new UsageError(`${file} is not a PNG, JPEG, or WEBP`);
113
- }
114
- let bytes;
113
+ let fileStat;
115
114
  try {
116
- bytes = await readFile(resolved);
115
+ fileStat = await stat(resolved);
117
116
  }
118
117
  catch {
119
118
  throw new UsageError(`cannot read ${file}`);
120
119
  }
121
- if (bytes.byteLength > 20 * 1024 * 1024) {
122
- throw new UsageError(`${file} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB; the limit is 20 MB`);
120
+ if (fileStat.size > MAX_SOURCE_BYTES) {
121
+ throw new UsageError(`${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`);
123
122
  }
124
- const asset = await api.uploadInput(new Blob([new Uint8Array(bytes)]), path.basename(resolved));
123
+ const asset = await api.uploadInput(await openAsBlob(resolved), path.basename(resolved));
125
124
  return asset.input_asset_id;
126
125
  }
127
126
  function defaultOut() {
@@ -187,6 +186,45 @@ function recoveryHint(error) {
187
186
  : null;
188
187
  return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
189
188
  }
189
+ /**
190
+ * Say so when this build and the server disagree about what exists.
191
+ *
192
+ * The MCP package solves this by asking the server at startup and shaping its tool
193
+ * schema from the answer. The CLI cannot: `ManagedOperation` is a compile-time union and
194
+ * `cutout` / `upscale` are compile-time commands, so deriving the list at runtime would
195
+ * buy consistency by giving up type safety at every call site.
196
+ *
197
+ * So it reports instead of adapting, and it does so HERE because `capabilities` is free,
198
+ * spends nothing, and is the command people are told to run first. Both directions are
199
+ * worth naming:
200
+ *
201
+ * - the server offers something this build cannot reach -> the user is missing a
202
+ * feature they are paying for and would never know
203
+ * - this build names something the server will not run -> the failure that shipped on
204
+ * 2026-08-21, where a call looked like a client bug rather than a version skew
205
+ *
206
+ * stderr, never stdout: `dreamlayer capabilities` is piped into jq.
207
+ */
208
+ function warnIfOperationsDrifted(capabilities) {
209
+ const listed = capabilities.operations;
210
+ if (!Array.isArray(listed) || listed.some((o) => typeof o !== "string"))
211
+ return;
212
+ const server = new Set(listed);
213
+ const mine = new Set(KNOWN_OPERATIONS);
214
+ const serverOnly = [...server].filter((o) => !mine.has(o));
215
+ const clientOnly = [...mine].filter((o) => !server.has(o));
216
+ if (serverOnly.length === 0 && clientOnly.length === 0)
217
+ return;
218
+ process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
219
+ if (serverOnly.length > 0) {
220
+ process.stderr.write(` The server offers, this version cannot use: ${serverOnly.join(", ")}\n` +
221
+ " Upgrade with: npm i -g dreamlayer\n");
222
+ }
223
+ if (clientOnly.length > 0) {
224
+ process.stderr.write(` This version names, the server will not run: ${clientOnly.join(", ")}\n` +
225
+ " Those commands will fail validation until the server catches up.\n");
226
+ }
227
+ }
190
228
  function exitCodeFor(error) {
191
229
  if (error.status === 401 || error.status === 403)
192
230
  return 2;
@@ -203,7 +241,7 @@ async function main(argv) {
203
241
  return command ? 0 : 1;
204
242
  }
205
243
  if (command === "--version" || command === "-v") {
206
- process.stdout.write("0.1.0\n");
244
+ process.stdout.write(`${PACKAGE_VERSION}\n`);
207
245
  return 0;
208
246
  }
209
247
  const { positional, options } = parseOptions(rest);
@@ -259,6 +297,7 @@ async function main(argv) {
259
297
  case "capabilities": {
260
298
  const capabilities = await client().getCapabilities();
261
299
  process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
300
+ warnIfOperationsDrifted(capabilities);
262
301
  return 0;
263
302
  }
264
303
  default:
@@ -297,6 +336,12 @@ main(process.argv.slice(2))
297
336
  process.exitCode = 5;
298
337
  return;
299
338
  }
339
+ if (error instanceof UploadTimeoutError) {
340
+ process.stderr.write(`${error.message}\n`);
341
+ process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
342
+ process.exitCode = 5;
343
+ return;
344
+ }
300
345
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
301
346
  process.stderr.write(recoveryHint(error));
302
347
  process.exitCode = 1;
package/dist/client.d.ts CHANGED
@@ -34,7 +34,17 @@ export type ManagedEvent = {
34
34
  * anyway. Confirmed against the deployment, not the source: the live /openapi.json
35
35
  * advertises exactly these four in both ExecuteRequest and ImageJobCreate.
36
36
  */
37
- export type ManagedOperation = "text_to_image" | "image_to_image" | "background_remove" | "upscale";
37
+ export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale"];
38
+ /**
39
+ * Derived from the array above, not written twice.
40
+ *
41
+ * The first version of this declared the union by hand and pinned an array to it with
42
+ * `satisfies`. That catches a WRONG entry and not a MISSING one, because a shorter array
43
+ * still satisfies a wider union, so the exact drift this file exists to detect could
44
+ * slip through the check meant to prevent it. Deriving the type makes the array the
45
+ * single definition and the question unaskable.
46
+ */
47
+ export type ManagedOperation = (typeof KNOWN_OPERATIONS)[number];
38
48
  export type ManagedExecuteInput = {
39
49
  prompt?: string;
40
50
  respond?: string;
@@ -79,6 +89,10 @@ export declare class StreamIdleError extends Error {
79
89
  readonly idleMs: number;
80
90
  constructor();
81
91
  }
92
+ export declare class UploadTimeoutError extends Error {
93
+ constructor();
94
+ }
95
+ export declare function uploadTimeoutMs(bytes: number): number;
82
96
  /**
83
97
  * Validate one sanitized event against the published contract.
84
98
  *
@@ -90,6 +104,7 @@ export declare function managedEvent(event: string, id: string | null, value: un
90
104
  export declare class ManagedClient {
91
105
  private readonly apiKey;
92
106
  private readonly baseUrl;
107
+ private capabilitiesPromise;
93
108
  constructor(apiKey: string, baseUrl?: string);
94
109
  /**
95
110
  * Run or continue an execution, yielding each validated event as it arrives.
package/dist/client.js CHANGED
@@ -12,6 +12,35 @@
12
12
  * client instead of the dead local-proxy class, and that timeouts and an explicit
13
13
  * redirect policy were added, which the original lacked.
14
14
  */
15
+ /**
16
+ * Every operation the Agent API can execute.
17
+ *
18
+ * REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
19
+ * deployment this field is rejected with 422 extra_forbidden, because the request model
20
+ * is closed. That is a sequencing constraint, not a reason to drop it: naming the
21
+ * operation is what stops a cutout or an upscale being re-read from the prompt and
22
+ * coming back as a clarifying question instead of an image.
23
+ *
24
+ * SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
25
+ * in the workflow engine, which had been split across two releases: the gateway
26
+ * accepted `operation` from prodbeta174 while the half that acts on it was still on
27
+ * prodbeta172, so naming an operation returned 200 and was then inferred from prose
28
+ * anyway. Confirmed against the deployment, not the source: the live /openapi.json
29
+ * advertises exactly these four in both ExecuteRequest and ImageJobCreate.
30
+ */
31
+ export const KNOWN_OPERATIONS = [
32
+ "text_to_image",
33
+ "image_to_image",
34
+ "background_remove",
35
+ "upscale",
36
+ ];
37
+ const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
38
+ const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
39
+ const LEGACY_INPUT_SUFFIXES = new Set([
40
+ ...RASTER_INPUT_SUFFIXES,
41
+ ".arw", ".cr2", ".cr3", ".crw", ".dng", ".nef", ".nrw", ".orf", ".pef",
42
+ ".raf", ".rw2", ".sr2", ".srw",
43
+ ]);
15
44
  export class ApiError extends Error {
16
45
  status;
17
46
  detail;
@@ -48,12 +77,27 @@ export class StreamIdleError extends Error {
48
77
  this.name = "StreamIdleError";
49
78
  }
50
79
  }
80
+ export class UploadTimeoutError extends Error {
81
+ constructor() {
82
+ super("the staged upload stopped before it completed; no image job was started");
83
+ this.name = "UploadTimeoutError";
84
+ }
85
+ }
51
86
  const ERROR_BODY_LIMIT = 16 * 1024;
52
87
  const ERROR_DETAIL_LIMIT = 300;
53
88
  /**
54
89
  * A plain request: send, get a body back. Bounded work, so a total cap is right.
55
90
  */
56
91
  const REQUEST_TIMEOUT_MS = 130_000;
92
+ const UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
93
+ const UPLOAD_MAX_TIMEOUT_MS = 15 * 60_000;
94
+ export function uploadTimeoutMs(bytes) {
95
+ const override = Number(process.env.DREAMLAYER_UPLOAD_TIMEOUT_MS);
96
+ if (Number.isFinite(override) && override > 0) {
97
+ return Math.min(Math.max(override, 100), UPLOAD_MAX_TIMEOUT_MS);
98
+ }
99
+ return Math.min(Math.max(REQUEST_TIMEOUT_MS, 60_000 + Math.ceil(bytes / UPLOAD_MIN_BYTES_PER_SECOND) * 1000), UPLOAD_MAX_TIMEOUT_MS);
100
+ }
57
101
  /**
58
102
  * A STREAM is different, and conflating the two shipped a broken `upscale`.
59
103
  *
@@ -292,6 +336,7 @@ function requireEventStream(response) {
292
336
  export class ManagedClient {
293
337
  apiKey;
294
338
  baseUrl;
339
+ capabilitiesPromise = null;
295
340
  constructor(apiKey, baseUrl = "https://api.dreamlayer.io") {
296
341
  this.apiKey = apiKey;
297
342
  if (!apiKey.trim())
@@ -326,7 +371,13 @@ export class ManagedClient {
326
371
  yield* this.parse(stream);
327
372
  }
328
373
  async getCapabilities() {
329
- const capabilities = await this.request("/v1/capabilities");
374
+ this.capabilitiesPromise ??= this.request("/v1/capabilities").catch((error) => {
375
+ // Cache a successful contract for the process, but never pin a transient
376
+ // capabilities failure as a permanent result.
377
+ this.capabilitiesPromise = null;
378
+ throw error;
379
+ });
380
+ const capabilities = await this.capabilitiesPromise;
330
381
  if (capabilities.api_version !== "1") {
331
382
  throw new Error("Unsupported DreamLayer Agent API version");
332
383
  }
@@ -348,7 +399,57 @@ export class ManagedClient {
348
399
  method: "DELETE",
349
400
  });
350
401
  }
351
- uploadInput(file, filename = "input.png") {
402
+ async uploadInput(file, filename = "input.png") {
403
+ const suffix = filename.slice(filename.lastIndexOf(".")).toLowerCase();
404
+ const capabilities = await this.getCapabilities();
405
+ const advertised = capabilities.supported_input_extensions;
406
+ const supported = Array.isArray(advertised)
407
+ ? new Set(advertised.filter((item) => typeof item === "string"))
408
+ : LEGACY_INPUT_SUFFIXES;
409
+ if (!supported.has(suffix)) {
410
+ throw new Error(`${filename} is not a supported image or camera RAW file`);
411
+ }
412
+ if (file.size > DIRECT_INPUT_BYTES || !RASTER_INPUT_SUFFIXES.has(suffix)) {
413
+ const contentType = file.type || "application/octet-stream";
414
+ const upload = await this.request("/v1/input-assets/uploads", {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({ filename, size_bytes: file.size, content_type: contentType }),
418
+ });
419
+ const targetUrl = new URL(upload.upload_url, `${this.baseUrl}/`);
420
+ const headers = new Headers({ "Content-Type": upload.content_type });
421
+ if (upload.mode === "signed") {
422
+ headers.set("x-goog-content-length-range", `0,${upload.maximum_bytes}`);
423
+ }
424
+ else {
425
+ if (targetUrl.origin !== new URL(this.baseUrl).origin) {
426
+ throw new ApiError(502, "DreamLayer input upload", "refused an off-origin upload URL");
427
+ }
428
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
429
+ headers.set("DreamLayer-Version", "1");
430
+ }
431
+ let response;
432
+ try {
433
+ response = await fetch(targetUrl, {
434
+ method: upload.http_method,
435
+ headers,
436
+ body: file,
437
+ redirect: "manual",
438
+ signal: AbortSignal.timeout(uploadTimeoutMs(file.size)),
439
+ });
440
+ }
441
+ catch (error) {
442
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
443
+ throw new UploadTimeoutError();
444
+ }
445
+ throw error;
446
+ }
447
+ if (!response.ok)
448
+ throw await apiError(response, "DreamLayer input upload");
449
+ return this.request(`/v1/input-assets/uploads/${encodeURIComponent(upload.upload_id)}/finalize`, {
450
+ method: "POST",
451
+ });
452
+ }
352
453
  const body = new FormData();
353
454
  body.append("file", file, filename);
354
455
  return this.request("/v1/input-assets", { method: "POST", body });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Generate and edit images from your terminal, over local files, with one API key.",
5
5
  "license": "MIT",
6
6
  "type": "module",