toolnexus 0.28.0 → 0.29.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/media.js CHANGED
@@ -1,8 +1,15 @@
1
1
  /**
2
- * Media generation (SPEC §7G, ADR 0036) — one engine, two faces: typed verbs a host calls and
3
- * tools (source "media") an LLM calls. Providers are styles: JSON data, never code.
2
+ * Media generation (SPEC §7G, ADRs 0036 + 0037) — one interface, two faces: typed verbs a host
3
+ * calls and tools (source "media") an LLM calls. Providers are **media clients**: built-in API
4
+ * clients (a2e, ElevenLabs, OpenAI, OpenRouter, fal), CLI clients (Grok, Codex), style clients
5
+ * over the JSON style engine, or any object implementing {@link MediaClient}.
4
6
  */
5
- import { readFileSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import readline from "node:readline";
12
+ import { fileURLToPath } from "node:url";
6
13
  /** Canonical verbs — `conformance/media/verbs.json`, embedded verbatim (byte contract). */
7
14
  export const MEDIA_VERBS = [
8
15
  {
@@ -41,7 +48,7 @@ export const MEDIA_VERBS = [
41
48
  {
42
49
  "verb": "video",
43
50
  "tool": "generate_video",
44
- "description": "Generate a short video from a prompt, optionally starting from an image URL. Can take minutes.",
51
+ "description": "Generate a short video from a prompt, optionally starting from an image. Can take minutes.",
45
52
  "args": [
46
53
  {
47
54
  "name": "prompt",
@@ -52,7 +59,7 @@ export const MEDIA_VERBS = [
52
59
  {
53
60
  "name": "image",
54
61
  "type": "string",
55
- "description": "URL of the starting image."
62
+ "description": "URL or local path of the starting image."
56
63
  },
57
64
  {
58
65
  "name": "seconds",
@@ -80,27 +87,60 @@ export const MEDIA_VERBS = [
80
87
  {
81
88
  "name": "voice",
82
89
  "type": "string",
83
- "description": "Voice or speaker id."
90
+ "description": "Voice name or id, including a cloned voice."
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "verb": "music",
96
+ "tool": "generate_music",
97
+ "description": "Generate music from a text prompt and return the audio.",
98
+ "args": [
99
+ {
100
+ "name": "prompt",
101
+ "type": "string",
102
+ "description": "Style, mood and instruments of the music.",
103
+ "required": true
104
+ },
105
+ {
106
+ "name": "seconds",
107
+ "type": "integer",
108
+ "description": "Length in seconds."
109
+ },
110
+ {
111
+ "name": "instrumental",
112
+ "type": "boolean",
113
+ "description": "True for no vocals."
114
+ },
115
+ {
116
+ "name": "lyrics",
117
+ "type": "string",
118
+ "description": "Lyrics to sing."
84
119
  }
85
120
  ]
86
121
  },
87
122
  {
88
123
  "verb": "transcribe",
89
124
  "tool": "transcribe_audio",
90
- "description": "Transcribe speech in an audio URL to text.",
125
+ "description": "Transcribe speech in an audio file to text.",
91
126
  "args": [
92
127
  {
93
128
  "name": "audio",
94
129
  "type": "string",
95
- "description": "URL of the audio.",
130
+ "description": "URL or local path of the audio.",
96
131
  "required": true
132
+ },
133
+ {
134
+ "name": "language",
135
+ "type": "string",
136
+ "description": "Language of the speech as an ISO 639-1 code, e.g. en."
97
137
  }
98
138
  ]
99
139
  },
100
140
  {
101
141
  "verb": "cloneVoice",
102
142
  "tool": "clone_voice",
103
- "description": "Create a cloned voice from audio sample URLs and return its voice id.",
143
+ "description": "Create a cloned voice from audio samples and return its voice id.",
104
144
  "args": [
105
145
  {
106
146
  "name": "name",
@@ -112,40 +152,379 @@ export const MEDIA_VERBS = [
112
152
  "name": "samples",
113
153
  "type": "array",
114
154
  "items": "string",
115
- "description": "URLs of audio samples of the voice.",
155
+ "description": "URLs or local paths of audio samples of the voice.",
116
156
  "required": true
117
157
  }
118
158
  ]
119
159
  },
120
160
  {
121
- "verb": "talking",
161
+ "verb": "talk",
122
162
  "tool": "talking_video",
123
- "description": "Make the person in an image speak the given audio (lip-synced video).",
163
+ "description": "Make the person in an image speak, from speech audio or from text (lip-synced video).",
124
164
  "args": [
125
165
  {
126
166
  "name": "image",
127
167
  "type": "string",
128
- "description": "URL of the face image.",
168
+ "description": "URL or local path of the face image.",
129
169
  "required": true
130
170
  },
131
171
  {
132
172
  "name": "audio",
133
173
  "type": "string",
134
- "description": "URL of the speech audio.",
135
- "required": true
174
+ "description": "URL or local path of the speech audio."
175
+ },
176
+ {
177
+ "name": "text",
178
+ "type": "string",
179
+ "description": "Text to speak when no audio is given."
180
+ },
181
+ {
182
+ "name": "voice",
183
+ "type": "string",
184
+ "description": "Voice for the text."
136
185
  }
137
186
  ]
138
187
  }
139
188
  ];
140
- /** Read one style file (JSON). */
141
- export function loadMediaStyle(path) {
142
- const style = JSON.parse(readFileSync(path, "utf8"));
189
+ const VERB_SPEC = new Map(MEDIA_VERBS.map((v) => [v.verb, v]));
190
+ const VERB_KIND = {
191
+ image: "image", video: "video", speech: "audio", music: "audio", transcribe: "text", cloneVoice: "text", talk: "video",
192
+ };
193
+ /** A failed media verb (§7G.2): message, status and the job id when known. */
194
+ export class MediaError extends Error {
195
+ status;
196
+ jobId;
197
+ constructor(message, status = "failed", jobId) {
198
+ super(message);
199
+ this.name = "MediaError";
200
+ this.status = status;
201
+ if (jobId !== undefined)
202
+ this.jobId = jobId;
203
+ }
204
+ }
205
+ /** The client cannot serve the call now (no credits, quota, no key, CLI missing, ZDR …). */
206
+ export class MediaUnavailableError extends MediaError {
207
+ constructor(message, jobId) {
208
+ super(message, "unavailable", jobId);
209
+ this.name = "MediaUnavailableError";
210
+ }
211
+ }
212
+ /** The a2e.ai preset — `examples/media-styles/a2e.json`, embedded verbatim. */
213
+ export const A2E_STYLE = {
214
+ "name": "a2e",
215
+ "baseUrl": "https://api.a2e.ai/api",
216
+ "headers": {
217
+ "Authorization": "Bearer ${A2E_API_KEY}"
218
+ },
219
+ "verbs": {
220
+ "image": {
221
+ "mode": "async",
222
+ "kind": "image",
223
+ "start": {
224
+ "method": "POST",
225
+ "path": "/v1/userText2Image/start"
226
+ },
227
+ "defaults": {
228
+ "name": "toolnexus",
229
+ "model_type": "a2e"
230
+ },
231
+ "args": {
232
+ "prompt": "prompt",
233
+ "width": "width",
234
+ "height": "height",
235
+ "seed": "seed"
236
+ },
237
+ "idPath": "data.0._id",
238
+ "poll": {
239
+ "path": "/v1/userText2Image/{id}",
240
+ "statusPath": "data.current_status",
241
+ "done": [
242
+ "completed"
243
+ ],
244
+ "failed": [
245
+ "failed",
246
+ "blocked",
247
+ "canceled"
248
+ ],
249
+ "errorPath": "data.failed_message",
250
+ "intervalMs": 3000,
251
+ "timeoutMs": 300000
252
+ },
253
+ "resultPath": "data.image_urls"
254
+ },
255
+ "video": {
256
+ "mode": "async",
257
+ "kind": "video",
258
+ "start": {
259
+ "method": "POST",
260
+ "path": "/v1/userImage2Video/start"
261
+ },
262
+ "defaults": {
263
+ "name": "toolnexus",
264
+ "generation_mode": "image-to-video",
265
+ "negative_prompt": "blurry, distorted, low quality"
266
+ },
267
+ "args": {
268
+ "image": "image_url",
269
+ "prompt": "prompt",
270
+ "seconds": "video_time"
271
+ },
272
+ "idPath": "data._id",
273
+ "poll": {
274
+ "path": "/v1/userImage2Video/{id}",
275
+ "statusPath": "data.current_status",
276
+ "done": [
277
+ "completed"
278
+ ],
279
+ "failed": [
280
+ "failed",
281
+ "blocked",
282
+ "canceled"
283
+ ],
284
+ "errorPath": "data.failed_message",
285
+ "intervalMs": 5000,
286
+ "timeoutMs": 900000
287
+ },
288
+ "resultPath": "data.result_url"
289
+ },
290
+ "speech": {
291
+ "mode": "async",
292
+ "kind": "audio",
293
+ "start": {
294
+ "method": "POST",
295
+ "path": "/v1/seedAudio/start"
296
+ },
297
+ "args": {
298
+ "text": "text_prompt",
299
+ "voice": "references.0.speaker"
300
+ },
301
+ "idPath": "data._id",
302
+ "poll": {
303
+ "path": "/v1/seedAudio/{id}",
304
+ "statusPath": "data.current_status",
305
+ "done": [
306
+ "completed"
307
+ ],
308
+ "failed": [
309
+ "failed"
310
+ ],
311
+ "errorPath": "data.failed_message",
312
+ "intervalMs": 3000,
313
+ "timeoutMs": 300000
314
+ },
315
+ "resultPath": "data.result_url"
316
+ },
317
+ "talk": {
318
+ "mode": "async",
319
+ "kind": "video",
320
+ "start": {
321
+ "method": "POST",
322
+ "path": "/v1/talkingPhoto/start"
323
+ },
324
+ "defaults": {
325
+ "name": "toolnexus"
326
+ },
327
+ "args": {
328
+ "image": "image_url",
329
+ "audio": "audio_url"
330
+ },
331
+ "required": [
332
+ "audio"
333
+ ],
334
+ "idPath": "data._id",
335
+ "poll": {
336
+ "path": "/v1/talkingPhoto/{id}",
337
+ "statusPath": "data.current_status",
338
+ "done": [
339
+ "completed"
340
+ ],
341
+ "failed": [
342
+ "failed",
343
+ "blocked",
344
+ "canceled"
345
+ ],
346
+ "errorPath": "data.failed_message",
347
+ "intervalMs": 5000,
348
+ "timeoutMs": 900000
349
+ },
350
+ "resultPath": "data.result_url"
351
+ }
352
+ },
353
+ "errorEnvelope": {
354
+ "codePath": "code",
355
+ "okValues": [
356
+ 0
357
+ ],
358
+ "messagePath": "message"
359
+ },
360
+ "_doc": "a2e.ai preset (ADR 0036/0037). Embedded verbatim by every port as A2EMediaClient (SPEC §7G.9). Live-verified 2026-11-02: image, video, speech. talk is implemented per a2e's docs, UNVERIFIED. Set A2E_API_KEY. Result URLs expire (3-30 days); the built-in client downloads them by default."
361
+ };
362
+ /**
363
+ * Built-in CLI presets for {@link loadMediaStyle} (ADR 0042): media through a locally installed,
364
+ * logged-in CLI: the vendor's official CLI in its documented headless mode, signed in by the user; the provider's terms apply. The `*Mock` twins behave the same
365
+ * (name, verbs, args, prompt folding, errors) but never start a process and return a fixed file.
366
+ */
367
+ export const MediaStyle = {
368
+ /** The `grok` CLI: image and video. */
369
+ XaiGrokCli: "xai-grok-cli",
370
+ /** The `codex` CLI: image only. */
371
+ OpenAICodexCli: "openai-codex-cli",
372
+ /** Google's Antigravity CLI `agy`: image only. */
373
+ GoogleAgyCli: "google-agy-cli",
374
+ XaiGrokCliMock: "xai-grok-cli-mock",
375
+ OpenAICodexCliMock: "openai-codex-cli-mock",
376
+ GoogleAgyCliMock: "google-agy-cli-mock",
377
+ };
378
+ const CLI_PRESETS = {
379
+ "xai-grok-cli": "grok", "openai-codex-cli": "codex", "google-agy-cli": "agy",
380
+ "xai-grok-cli-mock": "grok", "openai-codex-cli-mock": "codex", "google-agy-cli-mock": "agy",
381
+ };
382
+ /**
383
+ * A loaded style (§7G.7): an HTTP style file or a built-in CLI preset, configured and usable as a
384
+ * {@link MediaClient} anywhere one is accepted — `createMedia(loadMediaStyle("xai-grok-cli"))`.
385
+ */
386
+ export class LoadedMediaStyle {
387
+ name;
388
+ /** The preset name, when this is a built-in CLI preset. */
389
+ preset;
390
+ /** The parsed style, when this is an HTTP style. */
391
+ style;
392
+ /** The config the style was loaded with. */
393
+ config;
394
+ client;
395
+ constructor(source, config = {}) {
396
+ this.config = config;
397
+ if (typeof source === "string") {
398
+ const label = CLI_PRESETS[source];
399
+ if (label === undefined)
400
+ throw new Error(`style: unknown preset "${source}"`);
401
+ this.preset = source;
402
+ const mock = { mock: source.endsWith("-mock") };
403
+ this.client = label === "grok" ? new CliMediaClient(GROK_SPEC, config, mock)
404
+ : label === "codex" ? new CliMediaClient(CODEX_SPEC, config, mock)
405
+ : new CliMediaClient(AGY_SPEC, config, mock);
406
+ }
407
+ else {
408
+ this.style = source;
409
+ this.client = new StyleMediaClient(source, config);
410
+ }
411
+ this.name = this.client.name;
412
+ }
413
+ supports(verb) { return this.client.supports(verb); }
414
+ /** Who serves calls: the preset id, else the HTTP style's name, else "style" (§7G.10b). */
415
+ get id() {
416
+ return this.preset ?? this.style?.name ?? "style";
417
+ }
418
+ /**
419
+ * Can this style run? Cheap and side-effect free (§7G.10b): a CLI preset's command is found and
420
+ * its own login file exists (never read); an HTTP style's config key or header `${VAR}`s are set.
421
+ */
422
+ available() {
423
+ if (this.preset === undefined) {
424
+ const id = this.id;
425
+ if (this.config.key)
426
+ return { installed: true, loggedIn: true, reason: `${id}: key from config` };
427
+ const vars = new Set();
428
+ for (const v of Object.values(this.style?.headers ?? {}))
429
+ for (const m of v.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g))
430
+ vars.add(m[1]);
431
+ for (const name of [...vars].sort(byCodePoint)) {
432
+ if (!process.env[name])
433
+ return { installed: true, loggedIn: false, reason: `${id}: ${name} is not set` };
434
+ }
435
+ return { installed: true, loggedIn: true, reason: `${id}: env present` };
436
+ }
437
+ const label = CLI_PRESETS[this.preset];
438
+ if (this.preset.endsWith("-mock")) {
439
+ if (process.env[MEDIA_MOCK_AVAILABLE_ENV] === "0") {
440
+ return { installed: false, loggedIn: false, reason: `${label}: mock set unavailable by ${MEDIA_MOCK_AVAILABLE_ENV}=0` };
441
+ }
442
+ return { installed: true, loggedIn: true, reason: `${label}: mock` };
443
+ }
444
+ const cmd = this.config.command;
445
+ const bin = (Array.isArray(cmd) ? cmd[0] : cmd) || label;
446
+ if (!findCommand(bin))
447
+ return { installed: false, loggedIn: false, reason: `${label}: ${JSON.stringify(bin)} not found on PATH` };
448
+ const auth = cliAuthFile(label);
449
+ let present = false;
450
+ try {
451
+ present = !fs.statSync(auth).isDirectory();
452
+ }
453
+ catch {
454
+ present = false;
455
+ }
456
+ return present
457
+ ? { installed: true, loggedIn: true, reason: `${label}: installed, logged in (${auth} present)` }
458
+ : { installed: true, loggedIn: false, reason: `${label}: installed, not logged in (${auth} missing)` };
459
+ }
460
+ args(verb) { return acceptedArgs(this.client, verb); }
461
+ call(verb, args, ctx) {
462
+ const fn = this.client[verb];
463
+ if (typeof fn !== "function")
464
+ return Promise.reject(new MediaError(`media: no style supports "${verb}"`));
465
+ return fn.call(this.client, args, ctx);
466
+ }
467
+ image(args, ctx = {}) { return this.call("image", args, ctx); }
468
+ video(args, ctx = {}) { return this.call("video", args, ctx); }
469
+ speech(args, ctx = {}) { return this.call("speech", args, ctx); }
470
+ music(args, ctx = {}) { return this.call("music", args, ctx); }
471
+ transcribe(args, ctx = {}) { return this.call("transcribe", args, ctx); }
472
+ cloneVoice(args, ctx = {}) { return this.call("cloneVoice", args, ctx); }
473
+ talk(args, ctx = {}) { return this.call("talk", args, ctx); }
474
+ }
475
+ /** Set to "0", every mock preset reports unavailable. */
476
+ export const MEDIA_MOCK_AVAILABLE_ENV = "TOOLNEXUS_MEDIA_MOCK_AVAILABLE";
477
+ function homeDir() {
478
+ return process.env.HOME || os.homedir();
479
+ }
480
+ /** The file each CLI writes when logged in (checked for existence only). */
481
+ function cliAuthFile(label) {
482
+ if (label === "grok")
483
+ return path.join(homeDir(), ".grok", "auth.json");
484
+ if (label === "codex")
485
+ return path.join(process.env.CODEX_HOME || path.join(homeDir(), ".codex"), "auth.json");
486
+ return path.join(homeDir(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
487
+ }
488
+ /** A name with a path separator is checked as a file; otherwise searched on PATH (executable). */
489
+ function findCommand(bin) {
490
+ const exec = (p) => {
491
+ try {
492
+ if (!fs.statSync(p).isFile())
493
+ return false;
494
+ fs.accessSync(p, fs.constants.X_OK);
495
+ return true;
496
+ }
497
+ catch {
498
+ return false;
499
+ }
500
+ };
501
+ if (bin.includes("/") || bin.includes(path.sep))
502
+ return exec(bin);
503
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
504
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
505
+ if (dir === "")
506
+ continue;
507
+ for (const x of exts)
508
+ if (exec(path.join(dir, bin + x)))
509
+ return true;
510
+ }
511
+ return false;
512
+ }
513
+ /**
514
+ * Load a style: a built-in CLI preset ({@link MediaStyle}`.XaiGrokCli`, …) or a style JSON file
515
+ * (throws the §7G.7 style errors). `config` configures the client behind it (model, command, loop, gather).
516
+ */
517
+ export function loadMediaStyle(pathOrPreset, config = {}) {
518
+ if (Object.prototype.hasOwnProperty.call(CLI_PRESETS, pathOrPreset))
519
+ return new LoadedMediaStyle(pathOrPreset, config);
520
+ const style = JSON.parse(fs.readFileSync(pathOrPreset, "utf8"));
143
521
  validateStyle(style);
144
- return style;
522
+ return new LoadedMediaStyle(style, config);
145
523
  }
146
- /** §7G Empty dotPath: an args entry mapped to "" is a style error (sorted, so deterministic). */
147
524
  function validateStyle(style) {
148
525
  for (const verb of Object.keys(style.verbs ?? {}).sort()) {
526
+ if (!VERB_SPEC.has(verb))
527
+ throw new Error(`style: unknown verb "${verb}"`);
149
528
  const args = style.verbs[verb]?.args ?? {};
150
529
  for (const key of Object.keys(args).sort()) {
151
530
  if (args[key] === "")
@@ -155,11 +534,11 @@ function validateStyle(style) {
155
534
  }
156
535
  // ---- dotPath ---------------------------------------------------------------------------------
157
536
  const isIndex = (seg) => /^[0-9]+$/.test(seg);
158
- function readPath(obj, path) {
159
- if (path === "")
160
- return obj; // §7G: empty dotPath is the whole value
537
+ function readPath(obj, p) {
538
+ if (p === "")
539
+ return obj;
161
540
  let cur = obj;
162
- for (const seg of path.split(".")) {
541
+ for (const seg of p.split(".")) {
163
542
  if (cur === null || cur === undefined || typeof cur !== "object")
164
543
  return undefined;
165
544
  if (Array.isArray(cur)) {
@@ -175,8 +554,8 @@ function readPath(obj, path) {
175
554
  }
176
555
  return cur;
177
556
  }
178
- function writePath(obj, path, value) {
179
- const segs = path.split(".");
557
+ function writePath(obj, p, value) {
558
+ const segs = p.split(".");
180
559
  let cur = obj;
181
560
  for (let i = 0; i < segs.length; i++) {
182
561
  const seg = segs[i];
@@ -189,7 +568,6 @@ function writePath(obj, path, value) {
189
568
  return;
190
569
  }
191
570
  let next = cur[key];
192
- // A scalar — or an array followed by a non-numeric segment — is replaced (§7G).
193
571
  if (next === null || next === undefined || typeof next !== "object" || (Array.isArray(next) && !isIndex(segs[i + 1]))) {
194
572
  next = isIndex(segs[i + 1]) ? [] : {};
195
573
  cur[key] = next;
@@ -198,28 +576,49 @@ function writePath(obj, path, value) {
198
576
  }
199
577
  }
200
578
  // ---- helpers ---------------------------------------------------------------------------------
201
- function expandEnv(v) {
202
- return v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, name) => process.env[name] ?? "");
203
- }
204
579
  const MIME = {
205
580
  png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
206
581
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
207
582
  mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
208
583
  };
584
+ const EXT = {
585
+ "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif",
586
+ "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov",
587
+ "audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav", "audio/ogg": ".ogg",
588
+ "audio/mp4": ".m4a", "audio/flac": ".flac", "audio/aac": ".aac", "text/plain": ".txt",
589
+ };
590
+ /** §7G.12 D23: `toolnexus/<version> (js)`, the version read from this package's manifest. */
591
+ const USER_AGENT = (() => {
592
+ let version = "0.0.0";
593
+ try {
594
+ version = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version ?? version;
595
+ }
596
+ catch {
597
+ // keep the fallback
598
+ }
599
+ return `toolnexus/${version} (js)`;
600
+ })();
601
+ /** §7G.12 D22: a URL path's own extension (1–5 ASCII letters/digits), lower-cased, with the dot. */
602
+ function urlExt(u) {
603
+ const e = extOf(u);
604
+ return /^[a-z0-9]{1,5}$/.test(e) ? "." + e : undefined;
605
+ }
209
606
  const KIND_DEFAULT_MIME = { image: "image/png", video: "video/mp4", audio: "audio/mpeg" };
210
607
  const KIND_FILE_TYPE = { image: "image", audio: "audio", video: "file" };
211
- function mimeFor(url, kind) {
212
- let path = url.split("#")[0].split("?")[0];
213
- const scheme = path.indexOf("://");
608
+ /** Extension of the last path segment of a URL or path (query/fragment stripped), lower-case. */
609
+ function extOf(u) {
610
+ let p = u.split("#")[0].split("?")[0];
611
+ const scheme = p.indexOf("://");
214
612
  if (scheme >= 0) {
215
- const slash = path.indexOf("/", scheme + 3);
216
- path = slash >= 0 ? path.slice(slash) : "";
613
+ const slash = p.indexOf("/", scheme + 3);
614
+ p = slash >= 0 ? p.slice(slash) : "";
217
615
  }
218
- const last = path.slice(path.lastIndexOf("/") + 1);
616
+ const last = p.slice(p.lastIndexOf("/") + 1);
219
617
  const dot = last.lastIndexOf(".");
220
- const ext = dot >= 0 ? last.slice(dot + 1).toLowerCase() : "";
221
- return MIME[ext] ?? KIND_DEFAULT_MIME[kind];
618
+ return dot >= 0 ? last.slice(dot + 1).toLowerCase() : "";
222
619
  }
620
+ const mimeFromExt = (u) => MIME[extOf(u)];
621
+ const mimeFor = (u, kind) => mimeFromExt(u) ?? KIND_DEFAULT_MIME[kind];
223
622
  function jsonEqual(a, b) {
224
623
  if (a === b)
225
624
  return true;
@@ -241,7 +640,6 @@ function renderScalar(v) {
241
640
  return String(v);
242
641
  return JSON.stringify(v);
243
642
  }
244
- /** Poll status text (§7G): string as-is, numbers like the envelope code, anything else "". */
245
643
  function statusText(v) {
246
644
  if (typeof v === "string")
247
645
  return v;
@@ -250,21 +648,26 @@ function statusText(v) {
250
648
  return "";
251
649
  }
252
650
  /** RFC 3986: keep unreserved A-Z a-z 0-9 - . _ ~, %XX (uppercase) every other UTF-8 byte. */
253
- function percentEncode(s) {
651
+ function pct(s) {
254
652
  return encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
255
653
  }
256
- /** §7G Redirects: the followed statuses; anything else (or no Location) is an HTTP error. */
654
+ /** First `n` Unicode code points, then "…" when truncated. */
655
+ function cap(text, n) {
656
+ const cps = Array.from(text);
657
+ return cps.length <= n ? text : cps.slice(0, n).join("") + "…";
658
+ }
659
+ const positive = (v) => (typeof v === "number" && v > 0 ? v : undefined);
257
660
  const REDIRECTS = new Set([301, 302, 303, 307, 308]);
258
661
  class TooManyRedirects extends Error {
259
662
  }
260
- /** Echoed error bodies keep their first 1000 code points, then "…" (§7G). */
261
- function capBody(text) {
262
- const cps = Array.from(text);
263
- return cps.length <= 1000 ? text : cps.slice(0, 1000).join("") + "\u2026";
663
+ function originOf(u) {
664
+ try {
665
+ return new URL(u).origin;
666
+ }
667
+ catch {
668
+ return undefined;
669
+ }
264
670
  }
265
- /** §7G: an interval/timeout that is absent, not a number or <= 0 takes the default. */
266
- const positive = (v, dflt) => (typeof v === "number" && v > 0 ? v : dflt);
267
- const originOf = (u) => new URL(u).origin;
268
671
  function abortError(signal) {
269
672
  return signal.reason ?? Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
270
673
  }
@@ -293,237 +696,1392 @@ function flattenStrings(v, out) {
293
696
  flattenStrings(x, out);
294
697
  }
295
698
  }
296
- // ---- engine ----------------------------------------------------------------------------------
297
- export class Media {
298
- opts;
299
- styles;
300
- constructor(styles, opts = {}) {
301
- this.opts = opts;
302
- this.styles = Array.isArray(styles) ? styles : [styles];
303
- for (const s of this.styles)
304
- validateStyle(s);
305
- }
306
- resolve(verb) {
307
- for (const style of this.styles) {
308
- const spec = style.verbs[verb];
309
- if (spec)
310
- return { style, spec };
311
- }
312
- return undefined;
699
+ const decode = (b) => new TextDecoder().decode(b);
700
+ const b64 = (b) => Buffer.from(b).toString("base64");
701
+ const isHttp = (u) => /^https?:\/\//i.test(u);
702
+ const deadlineMs = (d) => (d instanceof Date ? d.getTime() : typeof d === "number" ? d : undefined);
703
+ function present(args) {
704
+ const out = {};
705
+ for (const [k, v] of Object.entries(args ?? {}))
706
+ if (v !== undefined && v !== null)
707
+ out[k] = v;
708
+ return out;
709
+ }
710
+ function overlay(body, ctx) {
711
+ for (const [k, v] of Object.entries(ctx.providerOptions ?? {}))
712
+ body[k] = v;
713
+ return body;
714
+ }
715
+ /** One job's HTTP: redirects by hand, the origin header rule, the per-request deadline. */
716
+ class HttpJob {
717
+ client;
718
+ verb;
719
+ headers;
720
+ doFetch;
721
+ timeoutMs;
722
+ deadline;
723
+ signal;
724
+ jobId;
725
+ origin;
726
+ constructor(client, verb, baseUrl, headers, doFetch, timeoutMs, deadline, signal) {
727
+ this.client = client;
728
+ this.verb = verb;
729
+ this.headers = headers;
730
+ this.doFetch = doFetch;
731
+ this.timeoutMs = timeoutMs;
732
+ this.deadline = deadline;
733
+ this.signal = signal;
734
+ this.origin = originOf(baseUrl);
313
735
  }
314
- /** Generic entry point: run `verb` with normalised `args` (§7G engine). */
315
- async generate(verb, args, callOpts = {}) {
316
- const resolved = this.resolve(verb);
317
- if (!resolved)
318
- throw new Error(`media: no style supports "${verb}"`);
319
- const { style, spec } = resolved;
320
- const present = Object.entries(args ?? {}).filter(([, v]) => v !== undefined && v !== null);
321
- for (const key of present.map(([k]) => k).sort()) {
322
- if (!Object.prototype.hasOwnProperty.call(spec.args, key)) {
323
- throw new Error(`${verb}: argument "${key}" not supported by this style`);
324
- }
325
- }
326
- const body = spec.defaults ? structuredClone(spec.defaults) : {};
327
- for (const [key, value] of present)
328
- writePath(body, spec.args[key], value);
329
- for (const [k, v] of Object.entries(callOpts.providerOptions ?? {}))
330
- body[k] = v;
331
- const base = style.baseUrl.replace(/\/+$/, "");
332
- const signal = callOpts.signal;
333
- const styleHeaders = () => {
334
- const h = {};
335
- for (const [k, v] of Object.entries(style.headers ?? {}))
336
- h[k] = expandEnv(v);
337
- return h;
338
- };
339
- const doFetch = this.opts.fetch ?? fetch;
340
- // Every request is bounded by the time left until the job deadline (§7G Per-request timeout).
341
- const timeout = positive(spec.poll?.timeoutMs, 600000);
342
- const deadline = Date.now() + timeout;
343
- let jobId;
344
- const timedOut = () => new Error(jobId !== undefined ? `job ${jobId} timed out after ${timeout}ms` : `${verb}: timed out after ${timeout}ms`);
345
- // Redirects are followed by hand so style headers never reach another origin (§7G Redirects).
346
- const send = async (url, method, json, reqSignal) => {
347
- const origin = originOf(url);
348
- let body = json !== undefined ? JSON.stringify(json) : undefined;
736
+ timedOut() {
737
+ return new MediaError(this.jobId !== undefined ? `job ${this.jobId} timed out after ${this.timeoutMs}ms` : `${this.verb}: timed out after ${this.timeoutMs}ms`, "timeout", this.jobId);
738
+ }
739
+ async send(url, method, opts = {}) {
740
+ const signal = this.signal;
741
+ if (signal?.aborted)
742
+ throw abortError(signal);
743
+ const remaining = this.deadline - Date.now();
744
+ if (remaining <= 0)
745
+ throw this.timedOut();
746
+ const ctl = new AbortController();
747
+ const timer = setTimeout(() => ctl.abort(), remaining);
748
+ const forward = () => ctl.abort();
749
+ signal?.addEventListener("abort", forward, { once: true });
750
+ let body = opts.json !== undefined ? JSON.stringify(opts.json) : opts.body;
751
+ let contentType = opts.json !== undefined ? "application/json" : opts.contentType;
752
+ try {
349
753
  for (let hops = 0;; hops++) {
350
- const sameOrigin = originOf(url) === origin;
351
- const headers = { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(sameOrigin ? styleHeaders() : {}) };
352
- const res = await doFetch(url, { method, headers, body, signal: reqSignal, redirect: "manual" });
754
+ const h = {};
755
+ if (body !== undefined && contentType)
756
+ h["Content-Type"] = contentType;
757
+ if (opts.auth !== false && this.origin !== undefined && originOf(url) === this.origin)
758
+ Object.assign(h, this.headers());
759
+ if (!Object.keys(h).some((k) => k.toLowerCase() === "user-agent"))
760
+ h["User-Agent"] = USER_AGENT;
761
+ const res = await this.doFetch(url, { method, headers: h, body: body, signal: ctl.signal, redirect: "manual" });
353
762
  const loc = res.headers?.get?.("location");
354
- if (!REDIRECTS.has(res.status) || !loc)
355
- return res;
356
- await res.body?.cancel().catch(() => { });
357
- if (hops >= 10)
358
- throw new TooManyRedirects();
359
- url = new URL(loc, url).toString();
360
- if (res.status <= 303 && method !== "GET" && method !== "HEAD") {
361
- method = "GET";
362
- body = undefined;
763
+ if (REDIRECTS.has(res.status) && loc) {
764
+ await res.body?.cancel().catch(() => { });
765
+ if (hops >= 10)
766
+ throw new TooManyRedirects();
767
+ url = new URL(loc, url).toString();
768
+ if (res.status <= 303 && method !== "GET" && method !== "HEAD") {
769
+ method = "GET";
770
+ body = undefined;
771
+ contentType = undefined;
772
+ }
773
+ continue;
363
774
  }
775
+ const { bytes, over } = await readBody(res, opts.limit);
776
+ return { status: res.status, headers: res.headers, bytes, over };
364
777
  }
365
- };
366
- const call = async (method, path, json) => {
778
+ }
779
+ catch (e) {
367
780
  if (signal?.aborted)
368
781
  throw abortError(signal);
369
- const remaining = deadline - Date.now();
370
- if (remaining <= 0)
371
- throw timedOut();
372
- const ctl = new AbortController();
373
- const timer = setTimeout(() => ctl.abort(), remaining);
374
- const forward = () => ctl.abort();
375
- signal?.addEventListener("abort", forward, { once: true });
376
- let res, text;
377
- try {
378
- res = await send(base + path, method, json, ctl.signal);
379
- text = await res.text();
380
- }
381
- catch (e) {
382
- if (signal?.aborted)
383
- throw abortError(signal);
384
- if (ctl.signal.aborted)
385
- throw timedOut();
386
- if (e instanceof TooManyRedirects)
387
- throw new Error(`${verb}: too many redirects`);
388
- throw new Error(`${verb}: ${e instanceof Error ? e.message : String(e)}`);
389
- }
390
- finally {
391
- clearTimeout(timer);
392
- signal?.removeEventListener("abort", forward);
782
+ if (ctl.signal.aborted)
783
+ throw this.timedOut();
784
+ if (e instanceof TooManyRedirects)
785
+ throw new MediaError(`${this.verb}: too many redirects`, "failed", this.jobId);
786
+ if (e instanceof MediaError)
787
+ throw e;
788
+ throw new MediaError(`${this.verb}: ${e instanceof Error ? e.message : String(e)}`, "failed", this.jobId);
789
+ }
790
+ finally {
791
+ clearTimeout(timer);
792
+ signal?.removeEventListener("abort", forward);
793
+ }
794
+ }
795
+ /** A provider API call: unavailable mapping, then any other non-2xx is `HTTP <s>: <body>`. */
796
+ async api(method, url, opts = {}) {
797
+ const res = await this.send(url, method, opts);
798
+ if (res.status < 200 || res.status > 299) {
799
+ const text = decode(res.bytes);
800
+ if (res.status === 402 || (res.status === 429 && /quota|credit|billing|balance/i.test(text))) {
801
+ throw new MediaUnavailableError(`${this.client}: HTTP ${res.status}: ${cap(text, 1000)}`, this.jobId);
393
802
  }
394
- if (res.status < 200 || res.status > 299)
395
- throw new Error(`HTTP ${res.status}: ${capBody(text)}`);
396
- let data;
397
- try {
398
- data = JSON.parse(text);
803
+ throw new MediaError(`HTTP ${res.status}: ${cap(text, 1000)}`, "failed", this.jobId);
804
+ }
805
+ return res;
806
+ }
807
+ async json(method, url, opts = {}) {
808
+ const res = await this.api(method, url, opts);
809
+ try {
810
+ return { data: JSON.parse(decode(res.bytes)), res };
811
+ }
812
+ catch {
813
+ throw new MediaError(`${this.verb}: response is not JSON`, "failed", this.jobId);
814
+ }
815
+ }
816
+ /** §7G.5 download: GET, no client headers, capped at maxBytes. */
817
+ async download(url, maxBytes) {
818
+ const res = await this.send(url, "GET", { auth: false, limit: maxBytes });
819
+ if (res.status < 200 || res.status > 299)
820
+ throw new MediaError(`${this.verb}: download failed: HTTP ${res.status}`, "failed", this.jobId);
821
+ if (res.over)
822
+ throw new MediaError(`${this.verb}: download failed: file exceeds gather.maxBytes (${maxBytes} bytes)`, "failed", this.jobId);
823
+ return { bytes: res.bytes, contentType: res.headers.get("content-type") ?? undefined };
824
+ }
825
+ }
826
+ async function readBody(res, limit) {
827
+ if (limit === undefined || !res.body)
828
+ return { bytes: new Uint8Array(await res.arrayBuffer()), over: false };
829
+ const reader = res.body.getReader();
830
+ const chunks = [];
831
+ let total = 0;
832
+ for (;;) {
833
+ const { done, value } = await reader.read();
834
+ if (done)
835
+ break;
836
+ chunks.push(value);
837
+ total += value.byteLength;
838
+ if (total > limit) {
839
+ await reader.cancel().catch(() => { });
840
+ return { bytes: new Uint8Array(0), over: true };
841
+ }
842
+ }
843
+ return { bytes: new Uint8Array(Buffer.concat(chunks)), over: false };
844
+ }
845
+ async function readInput(job, value, maxBytes) {
846
+ const fail = (reason) => new MediaError(`${job.verb}: cannot read "${value}": ${reason}`, "failed");
847
+ let bytes;
848
+ let segPath;
849
+ if (isHttp(value)) {
850
+ const res = await job.send(value, "GET", { auth: false, limit: maxBytes });
851
+ if (res.status < 200 || res.status > 299)
852
+ throw fail(`HTTP ${res.status}`);
853
+ if (res.over)
854
+ throw fail(`file exceeds gather.maxBytes (${maxBytes} bytes)`);
855
+ bytes = res.bytes;
856
+ segPath = new URL(value).pathname;
857
+ }
858
+ else {
859
+ let file = value;
860
+ try {
861
+ if (/^file:\/\//i.test(value))
862
+ file = fileURLToPath(value);
863
+ const st = fs.statSync(file);
864
+ if (st.size > maxBytes)
865
+ throw new Error(`file exceeds gather.maxBytes (${maxBytes} bytes)`);
866
+ bytes = new Uint8Array(fs.readFileSync(file));
867
+ }
868
+ catch (e) {
869
+ throw fail(e instanceof Error ? e.message : String(e));
870
+ }
871
+ segPath = file;
872
+ }
873
+ const seg = segPath.split("?")[0].split("/").pop() ?? "";
874
+ const filename = seg === "" ? "input" : seg;
875
+ return { bytes, filename, contentType: mimeFromExt(filename) ?? "application/octet-stream" };
876
+ }
877
+ /** gather.name sanitised (§7G.10b). */
878
+ function mediaSlug(name) {
879
+ return Buffer.from(name ?? "", "utf8").toString("latin1").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "");
880
+ }
881
+ function gatherCfg(config, dflt) {
882
+ const g = config.gather ?? {};
883
+ return {
884
+ mode: g.mode ?? dflt,
885
+ dir: path.resolve(g.dir ?? path.join(os.tmpdir(), "toolnexus-media")),
886
+ maxBytes: positive(g.maxBytes) ?? 104857600,
887
+ keep: g.keep ?? true,
888
+ slug: mediaSlug(g.name),
889
+ };
890
+ }
891
+ let saveSeq = 0;
892
+ class Gatherer {
893
+ cfg;
894
+ client;
895
+ verb;
896
+ kind;
897
+ job;
898
+ constructor(cfg, client, verb, kind, job) {
899
+ this.cfg = cfg;
900
+ this.client = client;
901
+ this.verb = verb;
902
+ this.kind = kind;
903
+ this.job = job;
904
+ }
905
+ tooBig() {
906
+ return new MediaError(`${this.verb}: file exceeds gather.maxBytes (${this.cfg.maxBytes} bytes)`, "failed", this.job?.jobId);
907
+ }
908
+ keepBytes(bytes, mimeType, ext) {
909
+ if (bytes.byteLength > this.cfg.maxBytes)
910
+ throw this.tooBig();
911
+ const type = KIND_FILE_TYPE[this.kind];
912
+ if (this.cfg.mode === "inline")
913
+ return { type, mimeType, data: b64(bytes) };
914
+ const file = this.target(mimeType, ext);
915
+ fs.writeFileSync(file, bytes);
916
+ return { type, mimeType, path: file, url: "file://" + file };
917
+ }
918
+ target(mimeType, ext) {
919
+ fs.mkdirSync(this.cfg.dir, { recursive: true });
920
+ const e = ext ?? EXT[mimeType] ?? ".bin";
921
+ if (this.cfg.slug !== "") {
922
+ // First free <slug><ext>, <slug>-2<ext>, …; created exclusively so concurrent gathers never collide.
923
+ for (let n = 1;; n++) {
924
+ const file = path.join(this.cfg.dir, `${this.cfg.slug}${n > 1 ? `-${n}` : ""}${e}`);
925
+ try {
926
+ fs.closeSync(fs.openSync(file, "wx"));
927
+ return file;
928
+ }
929
+ catch (err) {
930
+ if (err.code !== "EEXIST")
931
+ throw err;
932
+ }
399
933
  }
400
- catch {
401
- throw new Error(`${verb}: response is not JSON`);
934
+ }
935
+ return path.join(this.cfg.dir, `${this.client}-${this.verb}-${Date.now()}-${++saveSeq}${ext ?? EXT[mimeType] ?? ".bin"}`);
936
+ }
937
+ async one(raw) {
938
+ const type = KIND_FILE_TYPE[this.kind];
939
+ if ("bytes" in raw)
940
+ return this.keepBytes(raw.bytes, raw.mimeType);
941
+ if ("base64" in raw)
942
+ return this.keepBytes(new Uint8Array(Buffer.from(raw.base64, "base64")), raw.mimeType);
943
+ if ("dataUrl" in raw) {
944
+ const m = /^data:([^;,]*)((?:;[^;,]*)*),(.*)$/s.exec(raw.dataUrl);
945
+ const mime = (m?.[1] || KIND_DEFAULT_MIME[this.kind]).toLowerCase();
946
+ const isB64 = /;base64/i.test(m?.[2] ?? "");
947
+ const payload = m?.[3] ?? "";
948
+ const bytes = isB64 ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload), "utf8");
949
+ return this.keepBytes(new Uint8Array(bytes), mime);
950
+ }
951
+ if ("url" in raw) {
952
+ if (this.cfg.mode === "url")
953
+ return { type, mimeType: mimeFor(raw.url, this.kind), url: raw.url };
954
+ if (!this.job)
955
+ throw new MediaError(`${this.verb}: cannot download "${raw.url}"`);
956
+ const { bytes, contentType } = await this.job.download(raw.url, this.cfg.maxBytes);
957
+ const ct = contentType?.split(";")[0].trim().toLowerCase();
958
+ const mime = ct && ct !== "application/octet-stream" ? ct : mimeFor(raw.url, this.kind);
959
+ return this.keepBytes(bytes, mime, urlExt(raw.url));
960
+ }
961
+ // Local path (a CLI's own file).
962
+ const src = raw.path;
963
+ const mimeType = mimeFor(src, this.kind);
964
+ if (this.cfg.mode === "url")
965
+ return { type, mimeType, path: src, url: "file://" + src };
966
+ if (fs.statSync(src).size > this.cfg.maxBytes)
967
+ throw this.tooBig();
968
+ if (this.cfg.mode === "inline")
969
+ return { type, mimeType, data: fs.readFileSync(src).toString("base64") };
970
+ const file = this.target(mimeType);
971
+ fs.copyFileSync(src, file);
972
+ if (!this.cfg.keep)
973
+ fs.rmSync(src, { force: true });
974
+ return { type, mimeType, path: file, url: "file://" + file };
975
+ }
976
+ async all(raws) {
977
+ const out = [];
978
+ for (const r of raws)
979
+ out.push(await this.one(r));
980
+ return out;
981
+ }
982
+ }
983
+ function callEnv(client, verb, config, ctx, timeoutMs) {
984
+ const started = Date.now();
985
+ const ctxDeadline = deadlineMs(ctx.deadline);
986
+ const deadline = Math.min(started + timeoutMs, ctxDeadline ?? Infinity);
987
+ const sink = ctx.progress ?? config.loop?.onProgress;
988
+ return {
989
+ started,
990
+ deadline,
991
+ timeoutMs,
992
+ report: (status, jobId) => {
993
+ const p = { verb: verb, client, status, elapsedMs: Date.now() - started };
994
+ if (jobId !== undefined)
995
+ p.jobId = jobId;
996
+ sink?.(p);
997
+ },
998
+ };
999
+ }
1000
+ function withJobId(job, run) {
1001
+ return run().catch((e) => {
1002
+ if (e instanceof MediaError && e.jobId === undefined && job.jobId !== undefined)
1003
+ e.jobId = job.jobId;
1004
+ throw e;
1005
+ });
1006
+ }
1007
+ /** A media client over one JSON style (the ADR 0036 engine). Named `style.name ?? "style"`. */
1008
+ export class StyleMediaClient {
1009
+ name;
1010
+ style;
1011
+ config;
1012
+ internals;
1013
+ constructor(style, config = {}, internals = {}) {
1014
+ validateStyle(style);
1015
+ this.style = style;
1016
+ this.config = config;
1017
+ this.internals = internals;
1018
+ this.name = style.name ?? "style";
1019
+ }
1020
+ supports(verb) {
1021
+ return Object.prototype.hasOwnProperty.call(this.style.verbs ?? {}, verb) && VERB_SPEC.has(verb);
1022
+ }
1023
+ args(verb) {
1024
+ const spec = this.style.verbs[verb];
1025
+ const canon = VERB_SPEC.get(verb);
1026
+ if (!spec || !canon)
1027
+ return [];
1028
+ const extra = new Set(spec.required ?? []);
1029
+ return canon.args
1030
+ .filter((a) => Object.prototype.hasOwnProperty.call(spec.args, a.name))
1031
+ .map((a) => ({ name: a.name, required: a.required === true || extra.has(a.name) }));
1032
+ }
1033
+ image(args, ctx = {}) { return this.run("image", args, ctx); }
1034
+ video(args, ctx = {}) { return this.run("video", args, ctx); }
1035
+ speech(args, ctx = {}) { return this.run("speech", args, ctx); }
1036
+ music(args, ctx = {}) { return this.run("music", args, ctx); }
1037
+ transcribe(args, ctx = {}) { return this.run("transcribe", args, ctx); }
1038
+ cloneVoice(args, ctx = {}) { return this.run("cloneVoice", args, ctx); }
1039
+ talk(args, ctx = {}) { return this.run("talk", args, ctx); }
1040
+ /** Hook for subclasses: the request body's defaults for a verb. */
1041
+ defaults(_verb, spec) {
1042
+ return spec.defaults ? structuredClone(spec.defaults) : {};
1043
+ }
1044
+ baseUrl() {
1045
+ return (this.config.url ?? this.style.baseUrl).replace(/\/+$/, "");
1046
+ }
1047
+ async run(verb, rawArgs, ctx) {
1048
+ const style = this.style;
1049
+ const spec = style.verbs[verb];
1050
+ if (!spec)
1051
+ throw new MediaError(`media: no client supports "${verb}"`);
1052
+ let key = this.config.key;
1053
+ if (this.internals.keyEnv) {
1054
+ key = this.config.key || process.env[this.internals.keyEnv];
1055
+ if (!key)
1056
+ throw new MediaUnavailableError(`${this.name}: no API key (set ${this.internals.keyEnv})`);
1057
+ }
1058
+ const args = present(rawArgs);
1059
+ const body = this.defaults(verb, spec);
1060
+ for (const [k, v] of Object.entries(args)) {
1061
+ if (Object.prototype.hasOwnProperty.call(spec.args, k))
1062
+ writePath(body, spec.args[k], v);
1063
+ }
1064
+ overlay(body, ctx);
1065
+ const headers = () => {
1066
+ const h = {};
1067
+ for (const [k, v] of Object.entries(style.headers ?? {})) {
1068
+ h[k] = v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, n) => (key !== undefined ? key : process.env[n] ?? ""));
402
1069
  }
403
- const env = style.errorEnvelope;
404
- if (env) {
405
- const code = readPath(data, env.codePath);
406
- if (code !== undefined && !env.okValues.some((ok) => jsonEqual(ok, code))) {
407
- const msg = env.messagePath ? readPath(data, env.messagePath) : undefined;
408
- throw new Error(`API error ${renderScalar(code)}: ${typeof msg === "string" ? msg : ""}`);
1070
+ return h;
1071
+ };
1072
+ const loop = this.config.loop ?? {};
1073
+ const timeoutMs = positive(loop.timeoutMs) ?? positive(spec.poll?.timeoutMs) ?? 600000;
1074
+ const env = callEnv(this.name, verb, this.config, ctx, timeoutMs);
1075
+ const base = this.baseUrl();
1076
+ const job = new HttpJob(this.name, verb, base, headers, this.config.fetch ?? fetch, timeoutMs, env.deadline, ctx.signal);
1077
+ const call = async (method, p, json) => {
1078
+ const { data } = await job.json(method, base + p, json !== undefined ? { json } : {});
1079
+ const envl = style.errorEnvelope;
1080
+ if (envl) {
1081
+ const code = readPath(data, envl.codePath);
1082
+ if (code !== undefined && !envl.okValues.some((ok) => jsonEqual(ok, code))) {
1083
+ const msg = envl.messagePath ? readPath(data, envl.messagePath) : undefined;
1084
+ throw new MediaError(`API error ${renderScalar(code)}: ${typeof msg === "string" ? msg : ""}`, "failed", job.jobId);
409
1085
  }
410
1086
  }
411
1087
  return data;
412
1088
  };
413
- let final;
414
- let status = "completed";
415
- const startResp = await call((spec.start.method ?? "POST").toUpperCase(), spec.start.path, body);
416
- if (spec.mode === "async") {
417
- const poll = spec.poll;
418
- const idPath = spec.idPath ?? "";
419
- const rawId = readPath(startResp, idPath);
420
- if (typeof rawId === "string" && rawId !== "")
421
- jobId = rawId;
422
- else if (typeof rawId === "number" && Number.isInteger(rawId))
423
- jobId = String(rawId);
424
- else
425
- throw new Error(`${verb}: no job id at ${idPath}`);
426
- const interval = positive(poll.intervalMs, 5000);
427
- const pollPath = poll.path.split("{id}").join(percentEncode(jobId));
428
- for (;;) {
429
- await sleep(interval, signal);
430
- const resp = await call((poll.method ?? "GET").toUpperCase(), pollPath);
431
- const s = statusText(readPath(resp, poll.statusPath));
432
- this.opts.onProgress?.({ verb: verb, jobId, status: s });
433
- if ((poll.done ?? []).includes(s)) {
434
- final = resp;
435
- status = s;
436
- break;
437
- }
438
- if ((poll.failed ?? []).includes(s)) {
439
- const m = poll.errorPath ? readPath(resp, poll.errorPath) : undefined;
440
- throw new Error(typeof m === "string" && m !== "" ? `job ${jobId} ${s}: ${m}` : `job ${jobId} ${s}`);
1089
+ return withJobId(job, async () => {
1090
+ let final;
1091
+ let status = "completed";
1092
+ const startResp = await call((spec.start.method ?? "POST").toUpperCase(), spec.start.path, body);
1093
+ if (spec.mode === "async") {
1094
+ const poll = spec.poll;
1095
+ const idPath = spec.idPath ?? "";
1096
+ const rawId = readPath(startResp, idPath);
1097
+ if (typeof rawId === "string" && rawId !== "")
1098
+ job.jobId = rawId;
1099
+ else if (typeof rawId === "number" && Number.isInteger(rawId))
1100
+ job.jobId = String(rawId);
1101
+ else
1102
+ throw new MediaError(`${verb}: no job id at ${idPath}`);
1103
+ const jobId = job.jobId;
1104
+ const interval = positive(loop.intervalMs) ?? positive(poll.intervalMs) ?? 5000;
1105
+ const pollPath = poll.path.split("{id}").join(pct(jobId));
1106
+ for (;;) {
1107
+ await sleep(interval, ctx.signal);
1108
+ const resp = await call((poll.method ?? "GET").toUpperCase(), pollPath);
1109
+ const s = statusText(readPath(resp, poll.statusPath));
1110
+ env.report(s, jobId);
1111
+ if ((poll.done ?? []).includes(s)) {
1112
+ final = resp;
1113
+ status = s;
1114
+ break;
1115
+ }
1116
+ if ((poll.failed ?? []).includes(s)) {
1117
+ const m = poll.errorPath ? readPath(resp, poll.errorPath) : undefined;
1118
+ throw new MediaError(typeof m === "string" && m !== "" ? `job ${jobId} ${s}: ${m}` : `job ${jobId} ${s}`, "failed", jobId);
1119
+ }
1120
+ if (Date.now() >= env.deadline)
1121
+ throw job.timedOut();
441
1122
  }
442
- if (Date.now() >= deadline)
443
- throw timedOut();
444
1123
  }
1124
+ else {
1125
+ final = startResp;
1126
+ }
1127
+ const value = readPath(final, spec.resultPath);
1128
+ const result = { files: [], status };
1129
+ if (spec.kind === "text") {
1130
+ if (typeof value === "string")
1131
+ result.text = value;
1132
+ else if (typeof value === "number")
1133
+ result.text = renderScalar(value);
1134
+ else
1135
+ throw new MediaError(`${verb}: no result at ${spec.resultPath}`);
1136
+ }
1137
+ else {
1138
+ const urls = [];
1139
+ flattenStrings(value, urls);
1140
+ if (urls.length === 0)
1141
+ throw new MediaError(`${verb}: no result at ${spec.resultPath}`);
1142
+ const g = new Gatherer(gatherCfg(this.config, this.internals.gatherMode ?? "url"), this.name, verb, spec.kind, job);
1143
+ result.files = await g.all(urls.map((url) => ({ url })));
1144
+ }
1145
+ if (job.jobId !== undefined)
1146
+ result.jobId = job.jobId;
1147
+ return result;
1148
+ });
1149
+ }
1150
+ }
1151
+ /** Make a media client from a parsed style (§7G.7). */
1152
+ export function styleMediaClient(style, config = {}) {
1153
+ if (style instanceof LoadedMediaStyle) {
1154
+ if (!style.style)
1155
+ throw new Error(`style: "${style.preset}" is a CLI preset, not an HTTP style`);
1156
+ return new StyleMediaClient(style.style, config);
1157
+ }
1158
+ return new StyleMediaClient(style, config);
1159
+ }
1160
+ /** a2e.ai (§7G.9): a style client over the embedded preset, `A2E_API_KEY`, gather `download`. */
1161
+ export class A2EMediaClient extends StyleMediaClient {
1162
+ constructor(config = {}) {
1163
+ super(A2E_STYLE, config, { keyEnv: "A2E_API_KEY", gatherMode: "download" });
1164
+ }
1165
+ defaults(verb, spec) {
1166
+ const d = super.defaults(verb, spec);
1167
+ const model = this.config.models?.[verb] ?? (verb === "image" ? this.config.model : undefined);
1168
+ if (verb === "image" && model)
1169
+ d.model_type = model;
1170
+ return d;
1171
+ }
1172
+ }
1173
+ const req = (name) => ({ name, required: true });
1174
+ const opt = (name) => ({ name, required: false });
1175
+ /** The shared HTTP base for code-defined API clients (§7G.8 rules, gather, unavailable). */
1176
+ export class ApiMediaClient {
1177
+ name;
1178
+ config;
1179
+ def;
1180
+ constructor(def, config = {}) {
1181
+ this.def = def;
1182
+ this.name = def.name;
1183
+ this.config = config;
1184
+ }
1185
+ supports(verb) {
1186
+ return Object.prototype.hasOwnProperty.call(this.def.verbs, verb);
1187
+ }
1188
+ args(verb) {
1189
+ return this.def.verbs[verb] ?? [];
1190
+ }
1191
+ /** The model for a verb: `models[verb]`, else `model` for the primary verb, else the default. */
1192
+ model(verb, dflt) {
1193
+ return this.config.models?.[verb] ?? (verb === this.def.primary ? this.config.model : undefined) ?? dflt;
1194
+ }
1195
+ begin(verb, ctx) {
1196
+ const key = this.config.key || process.env[this.def.keyEnv];
1197
+ if (!key)
1198
+ throw new MediaUnavailableError(`${this.name}: no API key (set ${this.def.keyEnv})`);
1199
+ const loop = this.config.loop ?? {};
1200
+ const timeoutMs = positive(loop.timeoutMs) ?? 600000;
1201
+ const env = callEnv(this.name, verb, this.config, ctx, timeoutMs);
1202
+ const base = (this.config.url ?? this.def.defaultUrl).replace(/\/+$/, "");
1203
+ const auth = this.def.auth(key);
1204
+ const job = new HttpJob(this.name, verb, base, () => ({ ...auth }), this.config.fetch ?? fetch, timeoutMs, env.deadline, ctx.signal);
1205
+ const g = gatherCfg(this.config, "download");
1206
+ return {
1207
+ verb, base, job, env,
1208
+ gather: new Gatherer(g, this.name, verb, VERB_KIND[verb], job),
1209
+ interval: positive(loop.intervalMs) ?? this.def.interval,
1210
+ maxBytes: g.maxBytes,
1211
+ };
1212
+ }
1213
+ /** Poll until `check` says done; `check` returns the status text and an optional failure. */
1214
+ async pollUntil(c, ctx, check) {
1215
+ for (;;) {
1216
+ await sleep(c.interval, ctx.signal);
1217
+ const r = await check();
1218
+ c.env.report(r.status, c.job.jobId);
1219
+ if (r.done)
1220
+ return;
1221
+ if (r.error !== undefined)
1222
+ throw new MediaError(r.error, "failed", c.job.jobId);
1223
+ if (Date.now() >= c.env.deadline)
1224
+ throw c.job.timedOut();
1225
+ }
1226
+ }
1227
+ run(c, fn) {
1228
+ return withJobId(c.job, fn);
1229
+ }
1230
+ }
1231
+ function multipart(entries) {
1232
+ const boundary = "toolnexus-" + Math.random().toString(16).slice(2) + Date.now().toString(16);
1233
+ const chunks = [];
1234
+ for (const e of entries) {
1235
+ if ("value" in e) {
1236
+ chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${e.name}"\r\n\r\n${e.value}\r\n`));
445
1237
  }
446
1238
  else {
447
- final = startResp;
448
- }
449
- const value = readPath(final, spec.resultPath);
450
- const result = { files: [], status };
451
- if (spec.kind === "text") {
452
- if (typeof value === "string")
453
- result.text = value;
454
- else if (typeof value === "number")
455
- result.text = renderScalar(value);
1239
+ chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${e.name}"; filename="${e.file.filename.replace(/"/g, "%22")}"\r\nContent-Type: ${e.file.contentType}\r\n\r\n`));
1240
+ chunks.push(Buffer.from(e.file.bytes));
1241
+ chunks.push(Buffer.from("\r\n"));
1242
+ }
1243
+ }
1244
+ chunks.push(Buffer.from(`--${boundary}--\r\n`));
1245
+ return { body: new Uint8Array(Buffer.concat(chunks)), contentType: `multipart/form-data; boundary=${boundary}` };
1246
+ }
1247
+ /** providerOptions on a multipart body: replace a field of the same name, else append. */
1248
+ function overlayForm(entries, ctx) {
1249
+ for (const [k, v] of Object.entries(ctx.providerOptions ?? {})) {
1250
+ const value = typeof v === "string" ? v : JSON.stringify(v);
1251
+ const i = entries.findIndex((e) => e.name === k && "value" in e);
1252
+ if (i >= 0)
1253
+ entries[i] = { name: k, value };
1254
+ else
1255
+ entries.push({ name: k, value });
1256
+ }
1257
+ return entries;
1258
+ }
1259
+ /** ElevenLabs (§7G.9): speech (live-verified), music, transcribe, cloneVoice. */
1260
+ export class ElevenLabsMediaClient extends ApiMediaClient {
1261
+ constructor(config = {}) {
1262
+ super({
1263
+ name: "elevenlabs", keyEnv: "ELEVENLABS_API_KEY", defaultUrl: "https://api.elevenlabs.io",
1264
+ auth: (k) => ({ "xi-api-key": k }), primary: "speech", interval: 5000,
1265
+ verbs: {
1266
+ speech: [req("text"), opt("voice")],
1267
+ music: [req("prompt"), opt("seconds"), opt("instrumental")],
1268
+ transcribe: [req("audio"), opt("language")],
1269
+ cloneVoice: [req("name"), req("samples")],
1270
+ },
1271
+ }, config);
1272
+ }
1273
+ speech(args, ctx = {}) {
1274
+ const c = this.begin("speech", ctx);
1275
+ return this.run(c, async () => {
1276
+ let id = "JBFqnCBsd6RMkjVDRZzb";
1277
+ if (args.voice !== undefined && args.voice !== null) {
1278
+ const voice = String(args.voice);
1279
+ const { data } = await c.job.json("GET", `${c.base}/v2/voices?page_size=100&search=${pct(voice)}`);
1280
+ const voices = Array.isArray(data?.voices) ? data.voices : [];
1281
+ const lower = voice.toLowerCase();
1282
+ const hit = voices.find((v) => (typeof v?.name === "string" && v.name.toLowerCase() === lower) || v?.voice_id === voice);
1283
+ if (hit && typeof hit.voice_id === "string")
1284
+ id = hit.voice_id;
1285
+ else if (voice.length === 20 && !/\s/.test(voice))
1286
+ id = voice;
1287
+ else
1288
+ throw new MediaError(`speech: voice "${voice}" not found`);
1289
+ }
1290
+ const body = overlay({ text: args.text, model_id: this.model("speech", "eleven_flash_v2_5") }, ctx);
1291
+ const res = await c.job.api("POST", `${c.base}/v1/text-to-speech/${pct(id)}?output_format=mp3_22050_32`, { json: body });
1292
+ const files = [await c.gather.one({ bytes: res.bytes, mimeType: "audio/mpeg" })];
1293
+ const result = { files, status: "completed" };
1294
+ const rid = res.headers.get("request-id");
1295
+ if (rid)
1296
+ result.jobId = rid;
1297
+ return result;
1298
+ });
1299
+ }
1300
+ music(args, ctx = {}) {
1301
+ const c = this.begin("music", ctx);
1302
+ return this.run(c, async () => {
1303
+ const seconds = typeof args.seconds === "number" ? args.seconds : 10;
1304
+ const body = { prompt: args.prompt, music_length_ms: seconds * 1000 };
1305
+ if (args.instrumental === true)
1306
+ body.force_instrumental = true;
1307
+ overlay(body, ctx);
1308
+ const res = await c.job.api("POST", `${c.base}/v1/music?output_format=mp3_44100_128`, { json: body });
1309
+ return { files: [await c.gather.one({ bytes: res.bytes, mimeType: "audio/mpeg" })], status: "completed" };
1310
+ });
1311
+ }
1312
+ transcribe(args, ctx = {}) {
1313
+ const c = this.begin("transcribe", ctx);
1314
+ return this.run(c, async () => {
1315
+ const file = await readInput(c.job, String(args.audio), c.maxBytes);
1316
+ const entries = [{ name: "model_id", value: this.config.models?.transcribe ?? "scribe_v1" }];
1317
+ if (args.language !== undefined && args.language !== null)
1318
+ entries.push({ name: "language_code", value: String(args.language) });
1319
+ entries.push({ name: "file", file });
1320
+ const { data } = await c.job.json("POST", `${c.base}/v1/speech-to-text`, multipart(overlayForm(entries, ctx)));
1321
+ return textResult(data?.text, "transcribe", "text");
1322
+ });
1323
+ }
1324
+ cloneVoice(args, ctx = {}) {
1325
+ const c = this.begin("cloneVoice", ctx);
1326
+ return this.run(c, async () => {
1327
+ const entries = [{ name: "name", value: String(args.name) }];
1328
+ const samples = Array.isArray(args.samples) ? args.samples : [args.samples];
1329
+ for (const s of samples)
1330
+ entries.push({ name: "files", file: await readInput(c.job, String(s), c.maxBytes) });
1331
+ const { data } = await c.job.json("POST", `${c.base}/v1/voices/add`, multipart(overlayForm(entries, ctx)));
1332
+ return textResult(data?.voice_id, "cloneVoice", "voice_id");
1333
+ });
1334
+ }
1335
+ }
1336
+ function textResult(v, verb, at) {
1337
+ if (typeof v === "string")
1338
+ return { files: [], text: v, status: "completed" };
1339
+ if (typeof v === "number")
1340
+ return { files: [], text: renderScalar(v), status: "completed" };
1341
+ throw new MediaError(`${verb}: no result at ${at}`);
1342
+ }
1343
+ const multipartOpts = (m) => ({ body: m.body, contentType: m.contentType });
1344
+ /** OpenAI (§7G.9): image (live-verified), video, speech, transcribe; `url` ⇒ any compatible server. */
1345
+ export class OpenAIMediaClient extends ApiMediaClient {
1346
+ constructor(config = {}) {
1347
+ super({
1348
+ name: "openai", keyEnv: "OPENAI_API_KEY", defaultUrl: "https://api.openai.com/v1",
1349
+ auth: (k) => ({ Authorization: `Bearer ${k}` }), primary: "image", interval: 5000,
1350
+ verbs: {
1351
+ image: [req("prompt"), opt("width"), opt("height")],
1352
+ video: [req("prompt"), opt("seconds")],
1353
+ speech: [req("text"), opt("voice")],
1354
+ transcribe: [req("audio"), opt("language")],
1355
+ },
1356
+ }, config);
1357
+ }
1358
+ image(args, ctx = {}) {
1359
+ const c = this.begin("image", ctx);
1360
+ return this.run(c, async () => {
1361
+ const model = this.model("image", "gpt-image-1-mini");
1362
+ const w = typeof args.width === "number" ? args.width : undefined;
1363
+ const h = typeof args.height === "number" ? args.height : undefined;
1364
+ const size = w === undefined && h === undefined ? "1024x1024" : `${w ?? h}x${h ?? w}`;
1365
+ const body = { model, prompt: args.prompt, n: 1, size };
1366
+ if (model.startsWith("gpt-image"))
1367
+ body.quality = "low";
1368
+ const { data } = await c.job.json("POST", `${c.base}/images/generations`, { json: overlay(body, ctx) });
1369
+ const fmt = data?.output_format;
1370
+ const mime = fmt === "jpeg" ? "image/jpeg" : fmt === "webp" ? "image/webp" : "image/png";
1371
+ const raws = [];
1372
+ for (const e of Array.isArray(data?.data) ? data.data : []) {
1373
+ if (typeof e?.b64_json === "string" && e.b64_json !== "")
1374
+ raws.push({ base64: e.b64_json, mimeType: mime });
1375
+ else if (typeof e?.url === "string" && e.url !== "")
1376
+ raws.push({ url: e.url });
1377
+ }
1378
+ if (raws.length === 0)
1379
+ throw new MediaError("image: no result at data");
1380
+ const result = { files: await c.gather.all(raws), status: "completed" };
1381
+ const usage = reportedUsage({ inputTokens: data?.usage?.input_tokens, outputTokens: data?.usage?.output_tokens });
1382
+ if (usage)
1383
+ result.usage = usage;
1384
+ return result;
1385
+ });
1386
+ }
1387
+ speech(args, ctx = {}) {
1388
+ const c = this.begin("speech", ctx);
1389
+ return this.run(c, async () => {
1390
+ const body = overlay({ model: this.model("speech", "gpt-4o-mini-tts"), input: args.text, voice: args.voice ?? "alloy" }, ctx);
1391
+ const res = await c.job.api("POST", `${c.base}/audio/speech`, { json: body });
1392
+ return { files: [await c.gather.one({ bytes: res.bytes, mimeType: "audio/mpeg" })], status: "completed" };
1393
+ });
1394
+ }
1395
+ transcribe(args, ctx = {}) {
1396
+ const c = this.begin("transcribe", ctx);
1397
+ return this.run(c, async () => {
1398
+ const file = await readInput(c.job, String(args.audio), c.maxBytes);
1399
+ const entries = [{ name: "model", value: this.model("transcribe", "gpt-4o-mini-transcribe") }];
1400
+ if (args.language !== undefined && args.language !== null)
1401
+ entries.push({ name: "language", value: String(args.language) });
1402
+ entries.push({ name: "file", file });
1403
+ const { data } = await c.job.json("POST", `${c.base}/audio/transcriptions`, multipartOpts(multipart(overlayForm(entries, ctx))));
1404
+ return textResult(data?.text, "transcribe", "text");
1405
+ });
1406
+ }
1407
+ video(args, ctx = {}) {
1408
+ const c = this.begin("video", ctx);
1409
+ return this.run(c, async () => {
1410
+ const seconds = args.seconds ?? 4;
1411
+ const body = overlay({ model: this.model("video", "sora-2"), prompt: args.prompt, seconds: String(seconds) }, ctx);
1412
+ const { data } = await c.job.json("POST", `${c.base}/videos`, { json: body });
1413
+ const id = data?.id;
1414
+ if (typeof id === "string" && id !== "")
1415
+ c.job.jobId = id;
1416
+ else if (typeof id === "number" && Number.isInteger(id))
1417
+ c.job.jobId = String(id);
456
1418
  else
457
- throw new Error(`${verb}: no result at ${spec.resultPath}`);
1419
+ throw new MediaError("video: no job id at id");
1420
+ const jobId = c.job.jobId;
1421
+ await this.pollUntil(c, ctx, async () => {
1422
+ const { data: p } = await c.job.json("GET", `${c.base}/videos/${pct(jobId)}`);
1423
+ const s = statusText(p?.status);
1424
+ if (s === "failed") {
1425
+ const m = p?.error?.message;
1426
+ return { status: s, done: false, error: typeof m === "string" && m !== "" ? `job ${jobId} failed: ${m}` : `job ${jobId} failed` };
1427
+ }
1428
+ return { status: s, done: s === "completed" };
1429
+ });
1430
+ const res = await c.job.api("GET", `${c.base}/videos/${pct(jobId)}/content`);
1431
+ return { files: [await c.gather.one({ bytes: res.bytes, mimeType: "video/mp4" })], jobId, status: "completed" };
1432
+ });
1433
+ }
1434
+ }
1435
+ /** §7G.12 D21: keep only the numeric fields the provider returned; none ⇒ no usage. */
1436
+ function reportedUsage(fields) {
1437
+ const out = {};
1438
+ for (const [k, v] of Object.entries(fields))
1439
+ if (typeof v === "number" && Number.isFinite(v))
1440
+ out[k] = v;
1441
+ return Object.keys(out).length ? out : undefined;
1442
+ }
1443
+ /** OpenRouter (§7G.9): image through chat completions (live-verified). */
1444
+ export class OpenRouterMediaClient extends ApiMediaClient {
1445
+ constructor(config = {}) {
1446
+ super({
1447
+ name: "openrouter", keyEnv: "OPENROUTER_API_KEY", defaultUrl: "https://openrouter.ai/api/v1",
1448
+ auth: (k) => ({ Authorization: `Bearer ${k}` }), primary: "image", interval: 5000,
1449
+ verbs: { image: [req("prompt")] },
1450
+ }, config);
1451
+ }
1452
+ image(args, ctx = {}) {
1453
+ const c = this.begin("image", ctx);
1454
+ return this.run(c, async () => {
1455
+ const body = overlay({
1456
+ model: this.model("image", "google/gemini-2.5-flash-image"),
1457
+ modalities: ["image", "text"],
1458
+ messages: [{ role: "user", content: args.prompt }],
1459
+ }, ctx);
1460
+ const { data } = await c.job.json("POST", `${c.base}/chat/completions`, { json: body });
1461
+ let text = "";
1462
+ const raws = [];
1463
+ for (const ch of Array.isArray(data?.choices) ? data.choices : []) {
1464
+ const msg = ch?.message;
1465
+ if (typeof msg?.content === "string")
1466
+ text += msg.content;
1467
+ for (const im of Array.isArray(msg?.images) ? msg.images : []) {
1468
+ const u = im?.image_url?.url;
1469
+ if (typeof u !== "string" || u === "")
1470
+ continue;
1471
+ raws.push(/^data:/i.test(u) ? { dataUrl: u } : { url: u });
1472
+ }
1473
+ }
1474
+ if (raws.length === 0)
1475
+ throw new MediaError(`image: no image in response${text !== "" ? ": " + cap(text, 200) : ""}`);
1476
+ const result = { files: await c.gather.all(raws), status: "completed" };
1477
+ if (text !== "")
1478
+ result.text = text;
1479
+ if (typeof data?.id === "string" && data.id !== "")
1480
+ result.jobId = data.id;
1481
+ const usage = reportedUsage({ inputTokens: data?.usage?.prompt_tokens, outputTokens: data?.usage?.completion_tokens, costUsd: data?.usage?.cost });
1482
+ if (usage)
1483
+ result.usage = usage;
1484
+ return result;
1485
+ });
1486
+ }
1487
+ }
1488
+ /** fal (§7G.9): the queue API for any hosted model id — image (live-verified), video. */
1489
+ export class FalMediaClient extends ApiMediaClient {
1490
+ constructor(config = {}) {
1491
+ super({
1492
+ name: "fal", keyEnv: "FAL_API_KEY", defaultUrl: "https://queue.fal.run",
1493
+ auth: (k) => ({ Authorization: `Key ${k}` }), primary: "image", interval: 500,
1494
+ verbs: {
1495
+ image: [req("prompt"), opt("width"), opt("height"), opt("seed")],
1496
+ video: [req("prompt"), opt("image"), opt("seconds"), opt("aspectRatio")],
1497
+ },
1498
+ }, config);
1499
+ }
1500
+ image(args, ctx = {}) {
1501
+ const model = this.model("image", "fal-ai/flux/schnell");
1502
+ const input = { prompt: args.prompt, num_images: 1 };
1503
+ if (model.includes("schnell")) {
1504
+ input.image_size = "square";
1505
+ input.num_inference_steps = 4;
458
1506
  }
459
- else {
1507
+ const w = typeof args.width === "number" ? args.width : undefined;
1508
+ const h = typeof args.height === "number" ? args.height : undefined;
1509
+ if (w !== undefined || h !== undefined)
1510
+ input.image_size = { width: w ?? h, height: h ?? w };
1511
+ if (args.seed !== undefined)
1512
+ input.seed = args.seed;
1513
+ return this.queue("image", model, input, ctx);
1514
+ }
1515
+ video(args, ctx = {}) {
1516
+ const model = this.config.models?.video ??
1517
+ (args.image !== undefined ? "fal-ai/kling-video/v2.1/standard/image-to-video" : "fal-ai/kling-video/v2.1/standard/text-to-video");
1518
+ const input = { prompt: args.prompt, duration: String(args.seconds ?? 5) };
1519
+ if (args.image !== undefined)
1520
+ input.image_url = args.image;
1521
+ if (args.aspectRatio !== undefined)
1522
+ input.aspect_ratio = args.aspectRatio;
1523
+ return this.queue("video", model, input, ctx);
1524
+ }
1525
+ queue(verb, model, input, ctx) {
1526
+ const c = this.begin(verb, ctx);
1527
+ return this.run(c, async () => {
1528
+ const { data } = await c.job.json("POST", `${c.base}/${model.replace(/^\/+/, "")}`, { json: overlay(input, ctx) });
1529
+ const id = data?.request_id;
1530
+ if (typeof id === "string" && id !== "")
1531
+ c.job.jobId = id;
1532
+ else if (typeof id === "number" && Number.isInteger(id))
1533
+ c.job.jobId = String(id);
1534
+ else
1535
+ throw new MediaError(`${verb}: no job id at request_id`);
1536
+ const jobId = c.job.jobId;
1537
+ const statusUrl = typeof data?.status_url === "string" ? data.status_url : `${c.base}/${model}/requests/${pct(jobId)}/status`;
1538
+ const responseUrl = typeof data?.response_url === "string" ? data.response_url : `${c.base}/${model}/requests/${pct(jobId)}`;
1539
+ await this.pollUntil(c, ctx, async () => {
1540
+ const { data: p } = await c.job.json("GET", statusUrl);
1541
+ const s = statusText(p?.status);
1542
+ if (s === "COMPLETED")
1543
+ return { status: s, done: true };
1544
+ const err = p?.error;
1545
+ if (err !== undefined && err !== null && err !== "") {
1546
+ return { status: s, done: false, error: `job ${jobId} failed: ${typeof err === "string" ? err : JSON.stringify(err)}` };
1547
+ }
1548
+ return { status: s, done: false };
1549
+ });
1550
+ const { data: r } = await c.job.json("GET", responseUrl);
460
1551
  const urls = [];
461
- flattenStrings(value, urls);
1552
+ for (const im of Array.isArray(r?.images) ? r.images : [])
1553
+ if (typeof im?.url === "string" && im.url !== "")
1554
+ urls.push(im.url);
1555
+ for (const k of ["image", "video", "audio", "audio_file"]) {
1556
+ const u = r?.[k]?.url;
1557
+ if (typeof u === "string" && u !== "")
1558
+ urls.push(u);
1559
+ }
462
1560
  if (urls.length === 0)
463
- throw new Error(`${verb}: no result at ${spec.resultPath}`);
464
- result.files = urls.map((url) => ({ type: KIND_FILE_TYPE[spec.kind], mimeType: mimeFor(url, spec.kind), url }));
1561
+ throw new MediaError(`${verb}: no result in response`);
1562
+ return { files: await c.gather.all(urls.map((url) => ({ url }))), jobId, status: "completed" };
1563
+ });
1564
+ }
1565
+ }
1566
+ const CLI_PATH_RE = /\/[^\s"'()[\]]+\.(png|jpe?g|webp|gif|mp4|mov|webm|mp3|wav)/gi;
1567
+ function isFile(p) {
1568
+ try {
1569
+ return fs.statSync(p).isFile();
1570
+ }
1571
+ catch {
1572
+ return false;
1573
+ }
1574
+ }
1575
+ /** Runs a local subscription CLI once, headless, and collects the files it made (§7G.10). */
1576
+ export class CliMediaClient {
1577
+ name;
1578
+ config;
1579
+ spec;
1580
+ /** Never start the CLI; write a fixed file instead (§7G CLI mock presets). */
1581
+ mock;
1582
+ constructor(spec, config = {}, opts = {}) {
1583
+ this.spec = spec;
1584
+ this.name = spec.name;
1585
+ this.config = config;
1586
+ this.mock = opts.mock === true;
1587
+ }
1588
+ supports(verb) {
1589
+ return Object.prototype.hasOwnProperty.call(this.spec.templates, verb);
1590
+ }
1591
+ args(verb) {
1592
+ if (!this.supports(verb))
1593
+ return [];
1594
+ return verb === "video"
1595
+ ? [req("prompt"), opt("seconds"), opt("aspectRatio")]
1596
+ : [req("prompt"), opt("width"), opt("height"), opt("aspectRatio")];
1597
+ }
1598
+ image(args, ctx = {}) { return this.run("image", args, ctx); }
1599
+ video(args, ctx = {}) { return this.run("video", args, ctx); }
1600
+ async run(verb, args, ctx) {
1601
+ const template = this.spec.templates[verb];
1602
+ if (template === undefined)
1603
+ throw new MediaError(`media: no client supports "${verb}"`);
1604
+ const name = this.name;
1605
+ const cmd = Array.isArray(this.config.command) ? this.config.command : [this.config.command ?? this.spec.command];
1606
+ const timeoutMs = positive(this.config.loop?.timeoutMs) ?? 600000;
1607
+ const env = callEnv(name, verb, this.config, ctx, timeoutMs);
1608
+ if (ctx.signal?.aborted)
1609
+ throw abortError(ctx.signal);
1610
+ const prompt = template.split("{prompt}").join(foldMediaPrompt(verb, args));
1611
+ if (this.mock)
1612
+ return this.mockRun(verb, prompt);
1613
+ const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "toolnexus-cli-")));
1614
+ try {
1615
+ const child = spawn(cmd[0], [...cmd.slice(1), ...this.spec.argv(prompt, cwd, this.config.model)], {
1616
+ cwd,
1617
+ env: { ...process.env, ...(this.spec.env ?? {}) },
1618
+ stdio: ["ignore", "pipe", "ignore"],
1619
+ detached: process.platform !== "win32",
1620
+ });
1621
+ const started = await new Promise((resolve) => {
1622
+ child.once("spawn", () => resolve(true));
1623
+ child.once("error", () => resolve(false));
1624
+ });
1625
+ if (!started)
1626
+ throw new MediaUnavailableError(`${name}: cannot start "${cmd[0]}"`);
1627
+ let session;
1628
+ let usage;
1629
+ let text = "";
1630
+ const paths = [];
1631
+ let lastError;
1632
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
1633
+ rl.on("line", (line) => {
1634
+ let obj;
1635
+ try {
1636
+ obj = JSON.parse(line);
1637
+ }
1638
+ catch {
1639
+ return;
1640
+ }
1641
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj))
1642
+ return;
1643
+ const ev = this.spec.parse(obj, line);
1644
+ if (ev.session !== undefined)
1645
+ session = ev.session;
1646
+ if (ev.usage !== undefined)
1647
+ usage = ev.usage;
1648
+ if (ev.text !== undefined)
1649
+ text += ev.text;
1650
+ if (ev.paths)
1651
+ paths.push(...ev.paths);
1652
+ if (ev.error !== undefined)
1653
+ lastError = ev.error;
1654
+ if (ev.status !== undefined)
1655
+ env.report(ev.status, session);
1656
+ });
1657
+ let why;
1658
+ const kill = () => {
1659
+ try {
1660
+ if (child.pid !== undefined && process.platform !== "win32")
1661
+ process.kill(-child.pid, "SIGKILL");
1662
+ else
1663
+ child.kill("SIGKILL");
1664
+ }
1665
+ catch {
1666
+ child.kill("SIGKILL");
1667
+ }
1668
+ };
1669
+ const timer = setTimeout(() => { why ??= "timeout"; kill(); }, Math.max(0, env.deadline - Date.now()));
1670
+ const onAbort = () => { why ??= "cancelled"; kill(); };
1671
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
1672
+ const rlClosed = new Promise((r) => rl.once("close", () => r()));
1673
+ let exitCode;
1674
+ try {
1675
+ exitCode = await new Promise((resolve) => child.once("close", (code) => resolve(code ?? 0)));
1676
+ await rlClosed;
1677
+ }
1678
+ finally {
1679
+ clearTimeout(timer);
1680
+ ctx.signal?.removeEventListener("abort", onAbort);
1681
+ }
1682
+ if (why === "cancelled")
1683
+ throw abortError(ctx.signal);
1684
+ if (why === "timeout")
1685
+ throw new MediaError(`${name}: timed out after ${timeoutMs}ms`, "timeout", session);
1686
+ const seen = new Set();
1687
+ const candidates = [];
1688
+ const add = (p) => {
1689
+ if (!seen.has(p)) {
1690
+ seen.add(p);
1691
+ candidates.push(p);
1692
+ }
1693
+ };
1694
+ paths.forEach(add);
1695
+ for (const m of text.matchAll(CLI_PATH_RE))
1696
+ add(m[0]);
1697
+ if (this.spec.folder && session !== undefined) {
1698
+ const dir = this.spec.folder(session);
1699
+ if (dir) {
1700
+ let entries = [];
1701
+ try {
1702
+ entries = fs.readdirSync(dir).sort();
1703
+ }
1704
+ catch {
1705
+ entries = [];
1706
+ }
1707
+ for (const e of entries)
1708
+ add(path.join(dir, e));
1709
+ }
1710
+ }
1711
+ const prefix = verb === "image" ? "image/" : "video/";
1712
+ const kept = candidates.filter((p) => isFile(p) && (mimeFromExt(p) ?? "").startsWith(prefix));
1713
+ if (kept.length > 0) {
1714
+ const g = new Gatherer(gatherCfg(this.config, "download"), name, verb, VERB_KIND[verb]);
1715
+ const result = { files: await g.all(kept.map((p) => ({ path: p }))), status: "completed" };
1716
+ if (text !== "")
1717
+ result.text = text;
1718
+ if (session !== undefined)
1719
+ result.jobId = session;
1720
+ if (usage !== undefined)
1721
+ result.usage = usage;
1722
+ return result;
1723
+ }
1724
+ if (lastError !== undefined) {
1725
+ const un = this.spec.unavailable?.(lastError, verb);
1726
+ if (un !== undefined)
1727
+ throw new MediaUnavailableError(un, session);
1728
+ throw new MediaError(`${name}: no ${verb}: ${cap(lastError, 400)}`, "failed", session);
1729
+ }
1730
+ throw new MediaError(`${name}: no ${verb} file in the run (exit code ${exitCode})${text !== "" ? ": " + cap(text, 300) : ""}`, "failed", session);
1731
+ }
1732
+ finally {
1733
+ fs.rmSync(cwd, { recursive: true, force: true });
465
1734
  }
466
- if (jobId !== undefined)
467
- result.jobId = jobId;
468
- return result;
469
1735
  }
470
- typed(verb, input) {
471
- const { providerOptions, signal, ...args } = input;
472
- return this.generate(verb, args, { providerOptions, signal });
1736
+ /**
1737
+ * The CLI mock (§7G CLI mock presets): no process. Writes `<gather.dir>/<client>-mock-<verb>.<png|mp4>`
1738
+ * (gather.dir default `<tmp>/toolnexus-media`) holding `toolnexus mock <verb>` and gathers it like a
1739
+ * real CLI file; text = the exact prompt the CLI would have received; jobId "mock".
1740
+ */
1741
+ async mockRun(verb, prompt) {
1742
+ const dir = this.config.gather?.dir || path.join(os.tmpdir(), "toolnexus-media");
1743
+ let file;
1744
+ try {
1745
+ fs.mkdirSync(dir, { recursive: true });
1746
+ file = path.resolve(dir, `${this.name}-mock-${verb}${verb === "video" ? ".mp4" : ".png"}`);
1747
+ fs.writeFileSync(file, `toolnexus mock ${verb}`);
1748
+ }
1749
+ catch (e) {
1750
+ throw new MediaError(`${this.name}: ${e.message}`);
1751
+ }
1752
+ const g = new Gatherer(gatherCfg(this.config, "download"), this.name, verb, VERB_KIND[verb]);
1753
+ return { files: await g.all([{ path: file }]), text: prompt, jobId: "mock", status: "completed" };
1754
+ }
1755
+ }
1756
+ const CLI_HINTS = {
1757
+ width: ["width ", " px"],
1758
+ height: ["height ", " px"],
1759
+ seconds: ["about ", " seconds long"],
1760
+ aspectRatio: ["aspect ratio ", ""],
1761
+ };
1762
+ /**
1763
+ * The prompt a CLI style receives (§7G CLI hint folding): `prompt`, then the present size hints in
1764
+ * canonical verb order as ` (h1, h2)`; nothing appended when none is present.
1765
+ */
1766
+ export function foldMediaPrompt(verb, args) {
1767
+ const prompt = args.prompt === undefined || args.prompt === null ? "" : renderScalar(args.prompt);
1768
+ const hints = [];
1769
+ for (const a of VERB_SPEC.get(verb)?.args ?? []) {
1770
+ const h = CLI_HINTS[a.name];
1771
+ const v = args[a.name];
1772
+ if (h && v !== undefined && v !== null)
1773
+ hints.push(h[0] + renderScalar(v) + h[1]);
1774
+ }
1775
+ return hints.length === 0 ? prompt : `${prompt} (${hints.join(", ")})`;
1776
+ }
1777
+ const str = (v) => (typeof v === "string" ? v : undefined);
1778
+ const GROK_SPEC = {
1779
+ name: "grok",
1780
+ command: "grok",
1781
+ templates: {
1782
+ image: "Generate exactly one image with your image generation tool: {prompt}. Do not write code. Reply only with the file path.",
1783
+ video: "Generate exactly one video with your video generation tool: {prompt}. Do not write code. Reply only with the file path.",
1784
+ },
1785
+ argv: (prompt, cwd, model) => [
1786
+ "-p", prompt, "--cwd", cwd, "--output-format", "streaming-json", "--yolo", "--no-auto-update",
1787
+ ...(model ? ["-m", model] : []),
1788
+ ],
1789
+ env: { GROK_CONFIG: '{"features":{"image_gen":true,"video_gen":true}}' },
1790
+ parse(o) {
1791
+ switch (o.type) {
1792
+ case "text":
1793
+ return { text: str(o.data) ?? "" };
1794
+ case "tool_call":
1795
+ return { status: `tool ${str(o.toolName) ?? ""}` };
1796
+ case "tool_call_update": {
1797
+ const s = str(o.status);
1798
+ if (!s)
1799
+ return {};
1800
+ const ev = { status: `tool ${s}` };
1801
+ const raw = o.rawOutput;
1802
+ if (s === "completed") {
1803
+ const p = raw?.path;
1804
+ if (typeof p === "string" && p !== "")
1805
+ ev.paths = [p];
1806
+ }
1807
+ else if (s === "failed") {
1808
+ ev.error = typeof raw?.message === "string" ? raw.message : JSON.stringify(sortKeys(raw ?? null));
1809
+ }
1810
+ return ev;
1811
+ }
1812
+ case "end": {
1813
+ const ev = { session: str(o.sessionId), status: `end ${str(o.stopReason) ?? ""}` };
1814
+ // D21: tokens + total_cost_usd map to canonical fields; total_cost_usd_ticks is kept raw, never converted.
1815
+ const u = o.usage;
1816
+ const usage = reportedUsage({ inputTokens: u?.input_tokens, outputTokens: u?.output_tokens, costUsd: o.total_cost_usd }) ?? {};
1817
+ if (typeof o.total_cost_usd_ticks === "number" && Number.isFinite(o.total_cost_usd_ticks))
1818
+ usage.raw = { total_cost_usd_ticks: o.total_cost_usd_ticks };
1819
+ if (Object.keys(usage).length)
1820
+ ev.usage = usage;
1821
+ return ev;
1822
+ }
1823
+ case "error":
1824
+ return { error: str(o.message) ?? JSON.stringify(o) };
1825
+ default:
1826
+ return {};
1827
+ }
1828
+ },
1829
+ unavailable(error, verb) {
1830
+ const l = error.toLowerCase();
1831
+ if (l.includes("zero data retention")) {
1832
+ return `grok: ${verb} blocked by Zero Data Retention — run /privacy and opt in, or configure ZDR video storage (docs/media#grok-video-zdr)`;
1833
+ }
1834
+ if (l.includes("usage limit"))
1835
+ return `grok: ${cap(error, 400)}`;
1836
+ return undefined;
1837
+ },
1838
+ };
1839
+ /** Grok CLI (§7G.10): image, video via `grok -p … --output-format streaming-json`. */
1840
+ export class GrokMediaClient extends CliMediaClient {
1841
+ constructor(config = {}) {
1842
+ super(GROK_SPEC, config);
1843
+ }
1844
+ }
1845
+ const CODEX_SPEC = {
1846
+ name: "codex",
1847
+ command: "codex",
1848
+ templates: {
1849
+ image: "Generate exactly one image with your built-in image generation tool: {prompt}. Do not write code or use scripts. Reply only with the absolute path where the image was saved.",
1850
+ },
1851
+ argv: (prompt, _cwd, model) => ["exec", "--json", "--skip-git-repo-check", "-s", "workspace-write", ...(model ? ["-m", model] : []), prompt],
1852
+ parse(o, line) {
1853
+ const ev = {};
1854
+ if (typeof o.type === "string")
1855
+ ev.status = o.type;
1856
+ if (o.type === "thread.started")
1857
+ ev.session = str(o.thread_id);
1858
+ else if (o.type === "item.completed") {
1859
+ const item = o.item;
1860
+ if (item?.type === "agent_message" && typeof item.text === "string")
1861
+ ev.text = item.text;
1862
+ }
1863
+ else if (o.type === "error" || o.type === "turn.failed") {
1864
+ ev.error = str(o.message) ?? str(o.error?.message) ?? line;
1865
+ }
1866
+ return ev;
1867
+ },
1868
+ folder(session) {
1869
+ const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
1870
+ return path.join(home, "generated_images", session);
1871
+ },
1872
+ unavailable(error) {
1873
+ const l = error.toLowerCase();
1874
+ return l.includes("usage limit") || l.includes("quota") ? `codex: ${cap(error, 400)}` : undefined;
1875
+ },
1876
+ };
1877
+ /** Codex CLI (§7G.10): image via `codex exec --json` (ChatGPT plan). */
1878
+ export class CodexMediaClient extends CliMediaClient {
1879
+ constructor(config = {}) {
1880
+ super(CODEX_SPEC, config);
1881
+ }
1882
+ }
1883
+ const AGY_SPEC = {
1884
+ name: "agy",
1885
+ command: "agy",
1886
+ templates: {
1887
+ image: "Generate exactly one image with your image generation tool: {prompt}. Do not write code. Reply only with the absolute path where the image was saved.",
1888
+ },
1889
+ argv: (prompt, _cwd, model) => ["-p", prompt, "--output-format", "stream-json", "--dangerously-skip-permissions", ...(model ? ["--model", model] : [])],
1890
+ parse(o) {
1891
+ switch (o.event) {
1892
+ case "init":
1893
+ return { session: str(o.conversation_id), status: "init" };
1894
+ case "step_update": {
1895
+ const su = o.step_update;
1896
+ const t = str(su?.tool_name);
1897
+ const st = str(su?.state);
1898
+ return t && st ? { status: `tool ${t} ${st}` } : {};
1899
+ }
1900
+ case "result": {
1901
+ const r = o.result;
1902
+ const ev = { session: str(r?.conversation_id), text: str(r?.response), status: `result ${str(r?.status) ?? ""}` };
1903
+ const low = (ev.text ?? "").toLowerCase();
1904
+ if (r?.status === "SUCCESS" && (low.includes("quota") || low.includes("resource_exhausted"))) {
1905
+ // Live: agy reports an exhausted image quota in a SUCCESS result's text.
1906
+ ev.error = ev.text;
1907
+ }
1908
+ if (r?.status !== "SUCCESS") {
1909
+ ev.error = (str(r?.error) || undefined) ?? (str(r?.error?.message) || undefined) ?? (ev.text || undefined)
1910
+ ?? JSON.stringify(sortKeys(r ?? null));
1911
+ }
1912
+ const usage = reportedUsage({ inputTokens: r?.usage?.input_tokens, outputTokens: r?.usage?.output_tokens });
1913
+ if (usage)
1914
+ ev.usage = usage;
1915
+ return ev;
1916
+ }
1917
+ case "error":
1918
+ return { error: str(o.message) };
1919
+ default:
1920
+ return {};
1921
+ }
1922
+ },
1923
+ folder: (session) => path.join(os.homedir(), ".gemini", "antigravity-cli", "brain", session),
1924
+ unavailable(error) {
1925
+ const l = error.toLowerCase();
1926
+ return l.includes("quota") || l.includes("usage limit") ? `agy: ${cap(error, 400)}` : undefined;
1927
+ },
1928
+ };
1929
+ function acceptedArgs(client, verb) {
1930
+ if (typeof client.args === "function")
1931
+ return client.args(verb);
1932
+ return (VERB_SPEC.get(verb)?.args ?? []).map((a) => ({ name: a.name, required: a.required === true }));
1933
+ }
1934
+ const isUnavailable = (e) => e instanceof MediaUnavailableError || (e instanceof MediaError && e.status === "unavailable");
1935
+ /** Code-point order (plain `<` compares UTF-16 units, which misorders astral characters). */
1936
+ function byCodePoint(a, b) {
1937
+ const x = Array.from(a), y = Array.from(b);
1938
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
1939
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
1940
+ if (d)
1941
+ return d;
1942
+ }
1943
+ return x.length - y.length;
1944
+ }
1945
+ /** D15: object keys sorted by code point at every depth; array order kept. */
1946
+ function sortKeys(v) {
1947
+ if (Array.isArray(v))
1948
+ return v.map(sortKeys);
1949
+ if (v && typeof v === "object") {
1950
+ const keys = Object.keys(v).sort(byCodePoint);
1951
+ return Object.fromEntries(keys.map((k) => [k, sortKeys(v[k])]));
473
1952
  }
474
- image(input) {
475
- return this.typed("image", input);
1953
+ return v;
1954
+ }
1955
+ /** The seven verbs over an ordered list of clients; falls through only on `unavailable`. */
1956
+ export class Media {
1957
+ clients;
1958
+ constructor(clients) {
1959
+ const list = Array.isArray(clients) ? clients : [clients];
1960
+ if (list.length === 0)
1961
+ throw new MediaError("media: no clients");
1962
+ this.clients = list;
1963
+ }
1964
+ supports(verb) {
1965
+ return this.clients.some((c) => c.supports(verb));
476
1966
  }
477
- video(input) {
478
- return this.typed("video", input);
1967
+ /** Every verb some source supports, in canonical verb order. */
1968
+ verbs() {
1969
+ return MEDIA_VERBS.filter((v) => this.supports(v.verb)).map((v) => v.verb);
479
1970
  }
480
- speech(input) {
481
- return this.typed("speech", input);
1971
+ /** "style" when every source is a loaded style, else "client". */
1972
+ noun() {
1973
+ return this.clients.every((c) => c instanceof LoadedMediaStyle) ? "style" : "client";
482
1974
  }
483
- transcribe(input) {
484
- return this.typed("transcribe", input);
1975
+ /** Generic entry point: route `verb` with canonical `args`. */
1976
+ async generate(verb, args = {}, ctx = {}) {
1977
+ return (await this.route(verb, args, ctx)).result;
485
1978
  }
486
- cloneVoice(input) {
487
- return this.typed("cloneVoice", input);
1979
+ async route(verb, rawArgs, ctx) {
1980
+ const candidates = this.clients.filter((c) => c.supports(verb));
1981
+ if (candidates.length === 0)
1982
+ throw new MediaError(`media: no ${this.noun()} supports "${verb}"`);
1983
+ const args = present(rawArgs);
1984
+ const canon = (VERB_SPEC.get(verb)?.args ?? []).map((a) => a.name);
1985
+ // D7/D19: a key no client can ever take fails the call at once.
1986
+ for (const key of Object.keys(args).sort(byCodePoint)) {
1987
+ if (!canon.includes(key))
1988
+ throw new MediaError(`${verb}: argument "${key}" not supported`);
1989
+ }
1990
+ const messages = [];
1991
+ for (const c of candidates) {
1992
+ // D19: a candidate that cannot take these args is skipped like `unavailable` — first included.
1993
+ const acc = acceptedArgs(c, verb);
1994
+ const ok = new Set(acc.map((a) => a.name));
1995
+ const bad = Object.keys(args).sort(byCodePoint).find((k) => !ok.has(k));
1996
+ if (bad !== undefined) {
1997
+ messages.push(`${c.name}: argument "${bad}" not supported`);
1998
+ continue;
1999
+ }
2000
+ const requiredNames = acc.filter((a) => a.required).map((a) => a.name);
2001
+ const order = [...canon.filter((n) => requiredNames.includes(n)), ...requiredNames.filter((n) => !canon.includes(n))];
2002
+ const miss = order.find((key) => !(key in args));
2003
+ if (miss !== undefined) {
2004
+ messages.push(`${c.name}: missing required argument "${miss}"`);
2005
+ continue;
2006
+ }
2007
+ const callArgs = args;
2008
+ const fn = c[verb];
2009
+ if (typeof fn !== "function")
2010
+ throw new MediaError(`media: client "${c.name}" has no ${verb} method`);
2011
+ try {
2012
+ const result = await fn.call(c, callArgs, ctx);
2013
+ if (result.style === undefined)
2014
+ result.style = c instanceof LoadedMediaStyle ? c.id : c.name;
2015
+ if (result.model === undefined && c instanceof LoadedMediaStyle) {
2016
+ const model = c.config.models?.[verb] || c.config.model;
2017
+ if (model)
2018
+ result.model = model;
2019
+ }
2020
+ return { result, client: c.name };
2021
+ }
2022
+ catch (e) {
2023
+ if (!isUnavailable(e))
2024
+ throw e;
2025
+ messages.push(e.message);
2026
+ }
2027
+ }
2028
+ throw new MediaUnavailableError(`media: no client available for "${verb}": ${messages.join("; ")}`);
488
2029
  }
489
- talking(input) {
490
- return this.typed("talking", input);
2030
+ typed(verb, input) {
2031
+ const { providerOptions, signal, deadline, progress, ...args } = input;
2032
+ const ctx = {};
2033
+ if (providerOptions !== undefined)
2034
+ ctx.providerOptions = providerOptions;
2035
+ if (signal !== undefined)
2036
+ ctx.signal = signal;
2037
+ if (deadline !== undefined)
2038
+ ctx.deadline = deadline;
2039
+ if (progress !== undefined)
2040
+ ctx.progress = progress;
2041
+ return this.generate(verb, args, ctx);
491
2042
  }
492
- /** One Tool (source "media") per supported verb, in canonical order (§7G tool face). */
2043
+ image(input) { return this.typed("image", input); }
2044
+ video(input) { return this.typed("video", input); }
2045
+ speech(input) { return this.typed("speech", input); }
2046
+ music(input) { return this.typed("music", input); }
2047
+ transcribe(input) { return this.typed("transcribe", input); }
2048
+ cloneVoice(input) { return this.typed("cloneVoice", input); }
2049
+ talk(input) { return this.typed("talk", input); }
2050
+ /** One Tool (source "media") per verb any client supports, in `verbs.json` order (§7G.6). */
493
2051
  tools() {
494
2052
  const out = [];
495
2053
  for (const v of MEDIA_VERBS) {
496
- const resolved = this.resolve(v.verb);
497
- if (!resolved)
2054
+ const first = this.clients.find((c) => c.supports(v.verb));
2055
+ if (!first)
498
2056
  continue;
499
- const mapped = v.args.filter((a) => Object.prototype.hasOwnProperty.call(resolved.spec.args, a.name));
2057
+ const acc = new Map(acceptedArgs(first, v.verb).map((a) => [a.name, a]));
2058
+ const mapped = v.args.filter((a) => acc.has(a.name));
500
2059
  const properties = {};
501
2060
  for (const a of mapped) {
502
2061
  properties[a.name] = a.type === "array"
503
2062
  ? { type: "array", items: { type: a.items }, description: a.description }
504
2063
  : { type: a.type, description: a.description };
505
2064
  }
506
- const inputSchema = { type: "object", properties, required: mapped.filter((a) => a.required).map((a) => a.name) };
507
- const kind = resolved.spec.kind;
508
- const mode = resolved.spec.mode;
2065
+ const inputSchema = { type: "object", properties, required: mapped.filter((a) => acc.get(a.name).required).map((a) => a.name) };
2066
+ const verb = v.verb;
509
2067
  out.push({
510
2068
  name: v.tool,
511
2069
  description: v.description,
512
2070
  inputSchema,
513
2071
  source: "media",
514
- // Each call is an independent remote job: always concurrency-safe (§7G, §8 Tool scheduling).
515
2072
  concurrencySafe: () => true,
516
2073
  execute: async (args, ctx) => {
517
2074
  try {
518
- const r = await this.generate(v.verb, args ?? {}, { signal: ctx?.signal });
519
- const metadata = { source: "media", verb: v.verb };
520
- if (mode === "async" && r.jobId !== undefined)
2075
+ const { result: r, client } = await this.route(verb, args ?? {}, { signal: ctx?.signal });
2076
+ const metadata = { source: "media", verb, client };
2077
+ if (r.jobId !== undefined)
521
2078
  metadata.jobId = r.jobId;
522
2079
  metadata.status = r.status;
523
- if (kind === "text")
2080
+ if (r.usage !== undefined)
2081
+ metadata.usage = r.usage;
2082
+ if (VERB_KIND[verb] === "text")
524
2083
  return { isError: false, output: r.text ?? "", metadata };
525
- const parts = r.files.map((f) => ({ type: f.type, mimeType: f.mimeType, url: f.url }));
526
- return { isError: false, output: `${kind} ready: ` + r.files.map((f) => f.url).join("\n"), parts, metadata };
2084
+ return { isError: false, output: toolOutput(verb, r.files), parts: r.files.map(filePart), metadata };
527
2085
  }
528
2086
  catch (e) {
529
2087
  return { isError: true, output: e instanceof Error ? e.message : String(e) };
@@ -534,7 +2092,38 @@ export class Media {
534
2092
  return out;
535
2093
  }
536
2094
  }
537
- /** Build a media engine over one style or a list (first style declaring a verb wins). */
538
- export function createMedia(styles, opts = {}) {
539
- return new Media(styles, opts);
2095
+ const WORD = { image: "image", file: "video", audio: "audio" };
2096
+ function toolOutput(verb, files) {
2097
+ const word = files.length ? WORD[files[0].type] : VERB_KIND[verb] === "audio" ? "audio" : VERB_KIND[verb];
2098
+ const lines = files.map((f) => f.path ?? f.url ?? `inline ${f.mimeType} ${f.data ? Buffer.from(f.data, "base64").byteLength : 0} bytes`);
2099
+ return `${word} ready: ` + lines.join("\n");
2100
+ }
2101
+ /** §7G.6: a part never holds a path — local files are read now as data. */
2102
+ function filePart(f) {
2103
+ const part = partOf(f);
2104
+ if (f.path !== undefined)
2105
+ PART_PATH.set(part, f.path);
2106
+ return part;
2107
+ }
2108
+ function partOf(f) {
2109
+ if (f.data !== undefined)
2110
+ return { type: f.type, mimeType: f.mimeType, data: f.data };
2111
+ if (f.url !== undefined && isHttp(f.url))
2112
+ return { type: f.type, mimeType: f.mimeType, url: f.url };
2113
+ const local = f.path ?? (f.url?.startsWith("file://") ? f.url.slice("file://".length) : undefined);
2114
+ return { type: f.type, mimeType: f.mimeType, data: local !== undefined ? fs.readFileSync(local).toString("base64") : "" };
2115
+ }
2116
+ /** §7G.12 D20: the local path behind a media tool's part, kept off the part itself (§1B). */
2117
+ const PART_PATH = new WeakMap();
2118
+ /** The saved file's absolute path for a part a media tool produced, if it has one (D20). */
2119
+ export function mediaPartPath(part) {
2120
+ return PART_PATH.get(part);
2121
+ }
2122
+ /** Route the seven verbs over one client or an ordered list (§7G.3). */
2123
+ export function createMedia(clients) {
2124
+ return new Media(clients);
2125
+ }
2126
+ /** One-shot sugar: `createMedia(loadMediaStyle(style, config)).generate(verb, args)`. */
2127
+ export function generateMedia(style, verb, args = {}, config = {}) {
2128
+ return createMedia(loadMediaStyle(style, config)).generate(verb, args);
540
2129
  }