@olares/lares-core 0.15.6 → 0.15.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +100 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/mcp/approval.js +152 -0
  43. package/mcp/client.js +38 -0
  44. package/mcp/config.js +139 -0
  45. package/media/catalog-row.js +244 -0
  46. package/media/generation.js +707 -0
  47. package/media/tool.js +92 -0
  48. package/olares/entrance.js +67 -15
  49. package/olares/identity.js +13 -136
  50. package/olares/session-identity.js +12 -12
  51. package/olares/trusted-host.js +0 -29
  52. package/package.json +48 -4
  53. package/router/catalog-cache.d.ts +1 -1
  54. package/router/catalog-cache.js +25 -14
  55. package/router/catalog-sync.js +8 -3
  56. package/router/catalog.d.ts +6 -1
  57. package/router/catalog.js +18 -2
  58. package/router/gateway.d.ts +9 -1
  59. package/router/gateway.js +40 -4
  60. package/router/gemini-schema.js +125 -0
  61. package/router/generation-guard.js +82 -0
  62. package/router/models.d.ts +6 -0
  63. package/router/models.js +22 -3
  64. package/router/search.js +10 -2
  65. package/router/session-model.js +53 -0
  66. package/router/shim.js +104 -3
  67. package/router/sref.js +126 -0
  68. package/router/stt.js +43 -12
  69. package/router/tts.js +118 -0
  70. package/search/client.js +5 -3
  71. package/search/config.js +53 -39
  72. package/search/menu.js +45 -10
  73. package/skills/client.js +42 -0
  74. package/skills/packs.js +40 -0
  75. package/skills/state.d.ts +22 -0
  76. package/skills/state.js +142 -0
  77. package/tools/crypto-uuid.js +3 -6
  78. package/tools/head-script.js +15 -0
  79. package/voice/agent.js +50 -0
  80. package/voice/capture.js +250 -0
  81. package/voice/client.js +126 -24
  82. package/voice/config.js +11 -1
  83. package/voice/duplex.js +44 -0
  84. package/voice/endpoint.js +57 -0
  85. package/voice/host/bluetooth-ready.py +1196 -0
  86. package/voice/host/install-host.sh +102 -0
  87. package/voice/input.js +4 -0
  88. package/voice/install.js +146 -0
  89. package/voice/languages.js +11 -1
  90. package/voice/link.js +86 -0
  91. package/voice/pcm.js +100 -0
  92. package/voice/playback.js +7 -0
  93. package/voice/problem.js +46 -0
  94. package/voice/recorder.js +79 -4
  95. package/voice/script.js +35 -0
  96. package/voice/service.js +253 -10
  97. package/voice/session.js +128 -0
  98. package/voice/speech.js +93 -0
  99. package/files/preview-cache.js +0 -100
@@ -0,0 +1,707 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { basename, extname, join } from "node:path";
4
+ import { attachFlowstudioFilesPaths } from "../drive/flowstudio-files.js";
5
+ import { routerEndUser, routerGatewayUrl, routerHeaders } from "../router/gateway.js";
6
+ import { withSref } from "../router/sref.js";
7
+ import {
8
+ prepareWorkspaceTarget,
9
+ resolveExistingWorkspacePath,
10
+ resolveWorkspaceRoot,
11
+ workspaceCandidate,
12
+ } from "../workspace/path.js";
13
+ import { carriesWebpImage, transcodeWebpImages } from "./router-images.js";
14
+ import {
15
+ MEDIA_GENERATION_ROUTES,
16
+ bindParams,
17
+ fetchCatalogRow,
18
+ mediaSlots,
19
+ planGeneration,
20
+ promptParameterKey,
21
+ rowContract,
22
+ } from "./catalog-row.js";
23
+
24
+ export { MEDIA_GENERATION_ROUTES };
25
+
26
+ /** A long video on a busy local GPU has been measured at nine minutes. */
27
+ export const MEDIA_GENERATION_TIMEOUT_MS = 60 * 60 * 1000;
28
+ export const MEDIA_POLL_INTERVAL_MS = 5_000;
29
+ const MAX_REFERENCE_IMAGES = 4;
30
+ const MAX_REFERENCE_BYTES = 10 * 1024 * 1024;
31
+ const MAX_TRANSIENT_FAILURES = 12;
32
+
33
+ export const MEDIA_SUBMITTED_EVENT = "media/generation-submitted";
34
+ export const MEDIA_SETTLED_EVENT = "media/generation-settled";
35
+
36
+ /**
37
+ * The session events the media ledger writes. dsh refuses to read a log that
38
+ * holds an event type it does not know and that is not marked `ignorable`, and
39
+ * `Session.append` cannot mark one — so every session that ran media_generate
40
+ * failed to reload with "unknown to this harness". The dsh host that loads
41
+ * Lares registers these as known (see registerMediaSessionEvents); nothing in
42
+ * their data changes how the rest of the log is read.
43
+ */
44
+ export const MEDIA_SESSION_EVENT_TYPES = Object.freeze([MEDIA_SUBMITTED_EVENT, MEDIA_SETTLED_EVENT]);
45
+
46
+ /** @param {{ add(type: string): unknown }} known dsh's KNOWN_SESSION_EVENT_TYPES */
47
+ export function registerMediaSessionEvents(known) {
48
+ if (!known || typeof known.add !== "function") {
49
+ throw new TypeError("KNOWN_SESSION_EVENT_TYPES is not a mutable set; media sessions would not reload");
50
+ }
51
+ for (const type of MEDIA_SESSION_EVENT_TYPES) known.add(type);
52
+ return known;
53
+ }
54
+
55
+ const IMAGE_TYPES = Object.freeze({
56
+ ".png": "image/png",
57
+ ".jpg": "image/jpeg",
58
+ ".jpeg": "image/jpeg",
59
+ ".webp": "image/webp",
60
+ ".gif": "image/gif",
61
+ });
62
+
63
+ const TERMINAL = new Set(["completed", "failed", "canceled", "cancelled"]);
64
+
65
+ /**
66
+ * A 1×1 PNG for Router's pass-check fields. Router refuses an edit row without
67
+ * inputs.images before it forwards; FlowStudio ignores these once the media are
68
+ * named by slot in flowstudio.params. It never reaches a workflow.
69
+ */
70
+ export const PASS_CHECK_IMAGE =
71
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
72
+
73
+ /** Home/FlowStudio: the Files folder Lares and FlowStudio share (mounted from userData). */
74
+ export const FLOWSTUDIO_UPLOADS_FILES_PATH = "drive/Home/FlowStudio/uploads";
75
+
76
+ /**
77
+ * A slot value as FlowStudio reads it: a file in the user's Home/FlowStudio.
78
+ * A `drive/…` address is passed as given (an earlier output under
79
+ * Home/FlowStudio/outputs, or an upload already there). A workspace file — an
80
+ * upload into this chat, a file the agent made — is copied into
81
+ * Home/FlowStudio/uploads first, named by its content so a second run reuses it.
82
+ */
83
+ export async function slotFilesPath(workspaceRoot, value, env = process.env, deps = {}) {
84
+ const raw = String(value ?? "").trim();
85
+ if (!raw || raw.startsWith("drive/")) return raw;
86
+ const shared = String(env.LARES_FLOWSTUDIO_FILES_DIR ?? "").trim();
87
+ if (!shared) {
88
+ throw new MediaGenerationError("Home/FlowStudio is not mounted in Lares; cannot hand this file to FlowStudio");
89
+ }
90
+ const root = await resolveWorkspaceRoot(workspaceRoot);
91
+ const absolute = await resolveExistingWorkspacePath(root, workspaceCandidate(root, raw));
92
+ const bytes = await readFile(absolute);
93
+ const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
94
+ const name = `${digest}_${basename(absolute).replace(/[^\w.\-\u4e00-\u9fff]+/g, "_")}`;
95
+ const target = join(shared, "uploads", name);
96
+ await (deps.mkdir ?? mkdir)(join(shared, "uploads"), { recursive: true });
97
+ await (deps.writeFile ?? writeFile)(target, bytes, { flag: "wx" }).catch((error) => {
98
+ if (error?.code !== "EEXIST") throw error;
99
+ });
100
+ return `${FLOWSTUDIO_UPLOADS_FILES_PATH}/${name}`;
101
+ }
102
+
103
+ async function slotParams(contract, params, workspaceRoot, env, deps = {}) {
104
+ if (!params || typeof params !== "object" || Array.isArray(params)) return params;
105
+ const out = { ...params };
106
+ for (const slot of mediaSlots(contract)) {
107
+ if (!(slot.key in out)) continue;
108
+ const value = out[slot.key];
109
+ if (value && typeof value === "object" && !Array.isArray(value)) {
110
+ const mapped = {};
111
+ for (const [name, path] of Object.entries(value)) mapped[name] = await slotFilesPath(workspaceRoot, path, env, deps);
112
+ out[slot.key] = mapped;
113
+ } else {
114
+ out[slot.key] = await slotFilesPath(workspaceRoot, value, env, deps);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ export class MediaGenerationError extends Error {
121
+ /**
122
+ * @param {string} message
123
+ * @param {{ code?: string, status?: number, retryable?: boolean, retryAfterSeconds?: number, id?: string }} [facts]
124
+ */
125
+ constructor(message, facts = {}) {
126
+ super(message);
127
+ this.name = "MediaGenerationError";
128
+ this.code = facts.code ?? "";
129
+ this.status = facts.status ?? 0;
130
+ this.retryable = facts.retryable === true;
131
+ this.retryAfterSeconds = facts.retryAfterSeconds ?? 0;
132
+ this.id = facts.id ?? "";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Input images are read from the session workspace and sent as data URLs: the
138
+ * upstream cannot read a path on this pod. The request builder chooses the
139
+ * released `reference_images` spelling or canonical `inputs.images` spelling.
140
+ */
141
+ export async function referenceImageDataUrl(workspaceRoot, path) {
142
+ const type = IMAGE_TYPES[extname(String(path)).toLowerCase()];
143
+ if (!type) throw new MediaGenerationError(`${path} is not a png, jpeg, webp, or gif image`);
144
+ const root = await resolveWorkspaceRoot(workspaceRoot);
145
+ const absolute = await resolveExistingWorkspacePath(root, workspaceCandidate(root, String(path)));
146
+ const bytes = await readFile(absolute);
147
+ if (bytes.length === 0 || bytes.length > MAX_REFERENCE_BYTES) {
148
+ throw new MediaGenerationError(`${path} must be a non-empty image of at most 10 MiB`);
149
+ }
150
+ return `data:${type};base64,${bytes.toString("base64")}`;
151
+ }
152
+
153
+ /**
154
+ * Build the Router request for one catalog row.
155
+ *
156
+ * Nothing about the workflow is decided here: the row's contract (see
157
+ * catalog-row.js) supplies the route, the operation, where images go, and the
158
+ * parameters `params` may name. Every tunable travels as `flowstudio.params`.
159
+ *
160
+ * @param {ReturnType<typeof rowContract>} contract
161
+ * @returns {{ mode: string, route: string, body: Record<string, unknown> }}
162
+ */
163
+ export function mediaGenerationRequest(contract, args, referenceImages = []) {
164
+ const model = String(args?.model ?? "").trim();
165
+ if (!model.includes("/")) throw new MediaGenerationError("model must be <provider>/<model> as the catalog lists it");
166
+ if (args?.options !== undefined) {
167
+ throw new MediaGenerationError("options is not a media_generate field; put the row's parameters in params");
168
+ }
169
+ if (referenceImages.length > MAX_REFERENCE_IMAGES) {
170
+ throw new MediaGenerationError(`at most ${MAX_REFERENCE_IMAGES} reference images`);
171
+ }
172
+ const mask = String(args?.mask_image_data_url ?? "").trim();
173
+ const params = bindParams(contract, args?.params ?? {});
174
+ const slotMedia = mediaSlots(contract).some((slot) => slot.key in params);
175
+ const plan = planGeneration(contract, { images: referenceImages.length, mask: Boolean(mask), slotMedia });
176
+ // A FlowStudio workflow reads its prompt from params like any other
177
+ // parameter. Router still wants a top-level prompt before it forwards; that
178
+ // copy is only its pass-check and FlowStudio never reads it.
179
+ const promptKey = promptParameterKey(contract);
180
+ const fromParams = promptKey && typeof params[promptKey] === "string" ? params[promptKey] : "";
181
+ const prompt = String(args?.prompt ?? "") || fromParams;
182
+ const body = { model };
183
+ if (prompt) body.prompt = prompt;
184
+ if (plan.operation) body.operation = plan.operation;
185
+ if (Object.keys(params).length > 0) body.flowstudio = { params };
186
+ if (plan.canonical) {
187
+ if (referenceImages.length > 0 || mask) {
188
+ body.inputs = {};
189
+ if (referenceImages.length > 0) body.inputs.images = referenceImages;
190
+ if (mask) body.inputs.mask = mask;
191
+ }
192
+ } else {
193
+ if (referenceImages.length > 0) body.reference_images = referenceImages;
194
+ if (mask) body.maskImage = mask;
195
+ }
196
+ if (plan.passCheck.images || plan.passCheck.mask) {
197
+ const placeholder = plan.canonical ? (body.inputs ??= {}) : body;
198
+ if (plan.passCheck.images) placeholder[plan.canonical ? "images" : "reference_images"] = [PASS_CHECK_IMAGE];
199
+ if (plan.passCheck.mask) {
200
+ if (plan.canonical) placeholder.mask = PASS_CHECK_IMAGE;
201
+ else body.maskImage = PASS_CHECK_IMAGE;
202
+ }
203
+ }
204
+ return { mode: contract.mode, route: plan.route, body };
205
+ }
206
+
207
+ function retryAfterSeconds(response, payload) {
208
+ const header = Number.parseInt(response?.headers?.get?.("retry-after") ?? "", 10);
209
+ if (Number.isFinite(header) && header > 0) return header;
210
+ const fromBody = Number(payload?.retry_after_seconds ?? 0);
211
+ return Number.isFinite(fromBody) && fromBody > 0 ? fromBody : 0;
212
+ }
213
+
214
+ async function readJson(response) {
215
+ const text = await response.text();
216
+ if (!text) return {};
217
+ try {
218
+ return JSON.parse(text);
219
+ } catch {
220
+ return { error: { message: text.slice(0, 500) } };
221
+ }
222
+ }
223
+
224
+ const MAX_ERROR_TEXT = 500;
225
+
226
+ function clip(text) {
227
+ const value = String(text ?? "").trim();
228
+ return value.length > MAX_ERROR_TEXT ? `${value.slice(0, MAX_ERROR_TEXT)}…` : value;
229
+ }
230
+
231
+ /**
232
+ * Readable text for an error field of any shape Router or an upstream sends:
233
+ * a string, `{ message }` (possibly nested, e.g. an upstream body relayed as
234
+ * the message), FastAPI's `{ detail }` / validation `[{ loc, msg }]`, or
235
+ * anything else as compact JSON. Never "[object Object]".
236
+ */
237
+ export function errorText(value, depth = 0) {
238
+ if (value === undefined || value === null) return "";
239
+ if (typeof value === "string") return clip(value);
240
+ if (typeof value !== "object") return clip(String(value));
241
+ if (depth > 4) return clip(safeJson(value));
242
+ if (Array.isArray(value)) {
243
+ return clip(value.map((item) => {
244
+ if (item && typeof item === "object" && ("msg" in item || "loc" in item)) {
245
+ const loc = Array.isArray(item.loc) ? item.loc.join(".") : String(item.loc ?? "");
246
+ const msg = errorText(item.msg, depth + 1);
247
+ return loc && msg ? `${loc}: ${msg}` : loc || msg;
248
+ }
249
+ return errorText(item, depth + 1);
250
+ }).filter(Boolean).join("; "));
251
+ }
252
+ for (const key of ["message", "error", "detail", "msg", "reason", "description"]) {
253
+ const text = errorText(value[key], depth + 1);
254
+ if (text) return text;
255
+ }
256
+ // `code` / `type` are reported separately; JSON only what else is there.
257
+ const rest = Object.fromEntries(
258
+ Object.entries(value).filter(([key]) => !["code", "type", "status"].includes(key)),
259
+ );
260
+ return clip(safeJson(rest));
261
+ }
262
+
263
+ function safeJson(value) {
264
+ try {
265
+ const text = JSON.stringify(value);
266
+ return text === "{}" || text === "[]" ? "" : text;
267
+ } catch {
268
+ return "";
269
+ }
270
+ }
271
+
272
+ function errorCode(error) {
273
+ if (!error || typeof error !== "object" || Array.isArray(error)) return "";
274
+ const code = error.code ?? error.type ?? "";
275
+ return typeof code === "string" || typeof code === "number" ? String(code).trim() : "";
276
+ }
277
+
278
+ function errorFromResponse(response, payload, id) {
279
+ const error = payload?.error && typeof payload.error === "object" ? payload.error : {};
280
+ const code = errorCode(error) || errorCode(payload);
281
+ const text = errorText(payload?.error) || errorText(payload?.detail) || errorText(payload?.message);
282
+ const fallback = `Router answered HTTP ${response.status}`;
283
+ const message = text && text !== code ? text : fallback;
284
+ const after = retryAfterSeconds(response, payload);
285
+ return new MediaGenerationError(code ? `${code}: ${message}` : message, {
286
+ code,
287
+ status: response.status,
288
+ retryable: response.status === 429 || response.status === 503 || after > 0,
289
+ retryAfterSeconds: after,
290
+ id,
291
+ });
292
+ }
293
+
294
+ /** Failed generation → error naming the code and whether waiting helps. */
295
+ export function generationFailure(payload) {
296
+ const code = String(payload?.error_code ?? "").trim() || errorCode(payload?.error);
297
+ const raw = errorText(payload?.error);
298
+ const text = raw === code ? "" : raw;
299
+ const status = String(payload?.status ?? "failed");
300
+ const detail = [code, text].filter(Boolean).join(": ") || `generation ${status}`;
301
+ const retryable = payload?.retryable === true;
302
+ const after = Number(payload?.retry_after_seconds ?? 0) || 0;
303
+ const hint = retryable ? ` (retryable after ${after || 30}s)` : "";
304
+ return new MediaGenerationError(`${detail}${hint}`, {
305
+ code,
306
+ retryable,
307
+ retryAfterSeconds: after,
308
+ id: String(payload?.id ?? ""),
309
+ });
310
+ }
311
+
312
+ /** An abort reason as an Error: dsh aborts with whatever its caller gave it. */
313
+ function abortError(reason) {
314
+ if (reason instanceof Error) return reason;
315
+ const text = errorText(reason);
316
+ return new MediaGenerationError(text ? `the call was aborted: ${text}` : "the call was aborted", {});
317
+ }
318
+
319
+ /**
320
+ * Any thrown value as an Error whose message says what happened. The tool
321
+ * harness shows `Error: ${message}`, and a thrown object or an Error carrying
322
+ * an object as its message reads as "[object Object]" — which tells neither
323
+ * the model nor the user anything. `stage` names where the call was.
324
+ */
325
+ export function describedMediaError(error, stage = "") {
326
+ const prefix = stage ? `media_generate ${stage}: ` : "";
327
+ if (error instanceof Error) {
328
+ const message = typeof error.message === "string" ? error.message : "";
329
+ if (message && !message.includes("[object Object]")) {
330
+ if (error instanceof MediaGenerationError || !prefix) return error;
331
+ // A bare runtime error ("fetch failed") says nothing about where it came from.
332
+ const located = new MediaGenerationError(`${prefix}${message}`, error);
333
+ located.cause = error;
334
+ return located;
335
+ }
336
+ const text = errorText(error.cause) || errorText({ ...error, name: undefined, stack: undefined });
337
+ const described = new MediaGenerationError(`${prefix}${text || error.name || "failed without a message"}`, error);
338
+ described.cause = error;
339
+ return described;
340
+ }
341
+ const described = new MediaGenerationError(`${prefix}${errorText(error) || String(error)}`, {});
342
+ described.cause = error;
343
+ return described;
344
+ }
345
+
346
+ function defaultSleep(ms, signal) {
347
+ return new Promise((resolve, reject) => {
348
+ if (signal?.aborted) {
349
+ reject(abortError(signal.reason));
350
+ return;
351
+ }
352
+ const timer = setTimeout(() => {
353
+ signal?.removeEventListener("abort", onAbort);
354
+ resolve();
355
+ }, ms);
356
+ const onAbort = () => {
357
+ clearTimeout(timer);
358
+ reject(abortError(signal.reason));
359
+ };
360
+ signal?.addEventListener("abort", onAbort, { once: true });
361
+ });
362
+ }
363
+
364
+ function transport(deps = {}) {
365
+ const env = deps.env ?? process.env;
366
+ return {
367
+ env,
368
+ base: routerGatewayUrl(env),
369
+ headers: { ...routerHeaders(env), ...(deps.headers ?? {}) },
370
+ fetch: deps.fetch ?? globalThis.fetch,
371
+ sleep: deps.sleep ?? defaultSleep,
372
+ now: deps.now ?? Date.now,
373
+ intervalMs: deps.intervalMs ?? MEDIA_POLL_INTERVAL_MS,
374
+ };
375
+ }
376
+
377
+ function mediaIdempotencyKey(callId) {
378
+ const stable = String(callId ?? "").trim() || randomUUID();
379
+ return `lares-${createHash("sha256").update(stable).digest("hex")}`;
380
+ }
381
+
382
+ /** @returns {Promise<Record<string, any>>} the created generation, carrying its id. */
383
+ export async function submitMediaGeneration(request, deps = {}) {
384
+ const t = transport(deps);
385
+ const stamped = withSref(request.body, routerEndUser(t.env), t.env);
386
+ let body = Buffer.from(JSON.stringify(stamped), "utf8");
387
+ if (carriesWebpImage(body)) body = await transcodeWebpImages(body);
388
+ const response = await t.fetch(`${t.base}/${request.route}`, {
389
+ method: "POST",
390
+ headers: {
391
+ ...t.headers,
392
+ "content-type": "application/json",
393
+ prefer: "respond-async",
394
+ ...(deps.idempotencyKey ? {
395
+ "idempotency-key": deps.idempotencyKey,
396
+ "x-olares-idempotency-key": deps.idempotencyKey,
397
+ } : {}),
398
+ },
399
+ body,
400
+ signal: deps.signal,
401
+ });
402
+ const payload = await readJson(response);
403
+ if (!response.ok) throw errorFromResponse(response, payload, "");
404
+ const id = String(payload?.id ?? "").trim();
405
+ if (!id) throw new MediaGenerationError("Router accepted the generation without an id");
406
+ return payload;
407
+ }
408
+
409
+ /**
410
+ * Poll one generation until it settles.
411
+ *
412
+ * A dropped connection or a busy Router is not the generation failing: it keeps
413
+ * running upstream and has already been paid for, so transient failures wait
414
+ * and retry rather than end the call. Only a settled status, a 404, or a
415
+ * refusal ends it.
416
+ */
417
+ export async function awaitMediaGeneration(id, deps = {}) {
418
+ const t = transport(deps);
419
+ const deadline = t.now() + (deps.timeoutMs ?? MEDIA_GENERATION_TIMEOUT_MS);
420
+ let transient = 0;
421
+ for (;;) {
422
+ let wait = t.intervalMs;
423
+ try {
424
+ const response = await t.fetch(`${t.base}/generations/${encodeURIComponent(id)}`, {
425
+ headers: t.headers,
426
+ signal: deps.signal,
427
+ });
428
+ const payload = await readJson(response);
429
+ if (response.ok) {
430
+ transient = 0;
431
+ const status = String(payload?.status ?? "").toLowerCase();
432
+ if (TERMINAL.has(status)) return payload;
433
+ deps.onProgress?.(payload);
434
+ } else {
435
+ const error = errorFromResponse(response, payload, id);
436
+ if (!error.retryable && response.status < 500) throw error;
437
+ transient += 1;
438
+ if (transient > MAX_TRANSIENT_FAILURES) throw error;
439
+ wait = Math.max(wait, error.retryAfterSeconds * 1000);
440
+ }
441
+ } catch (error) {
442
+ if (deps.signal?.aborted || error instanceof MediaGenerationError) throw error;
443
+ transient += 1;
444
+ if (transient > MAX_TRANSIENT_FAILURES) throw error;
445
+ }
446
+ if (t.now() + wait > deadline) {
447
+ throw new MediaGenerationError(`generation ${id} did not settle in time; it may still finish`, { id });
448
+ }
449
+ await t.sleep(wait, deps.signal);
450
+ }
451
+ }
452
+
453
+ /** One poll, for a generation this process is not already waiting on. */
454
+ export async function peekMediaGeneration(id, deps = {}) {
455
+ const t = transport(deps);
456
+ const response = await t.fetch(`${t.base}/generations/${encodeURIComponent(id)}`, {
457
+ headers: t.headers,
458
+ signal: deps.signal,
459
+ });
460
+ const payload = await readJson(response);
461
+ if (!response.ok) throw errorFromResponse(response, payload, id);
462
+ return payload;
463
+ }
464
+
465
+ const OUTPUT_EXTENSIONS = Object.freeze({
466
+ "image/png": ".png",
467
+ "image/jpeg": ".jpg",
468
+ "image/webp": ".webp",
469
+ "image/gif": ".gif",
470
+ "video/mp4": ".mp4",
471
+ "video/webm": ".webm",
472
+ "audio/mpeg": ".mp3",
473
+ "audio/wav": ".wav",
474
+ "audio/x-wav": ".wav",
475
+ "audio/flac": ".flac",
476
+ "model/gltf-binary": ".glb",
477
+ });
478
+
479
+ async function downloadOutput(id, output, index, workspaceRoot, deps) {
480
+ const t = transport(deps);
481
+ const query = output?.id ? `?outputId=${encodeURIComponent(output.id)}` : "";
482
+ const response = await t.fetch(`${t.base}/generations/${encodeURIComponent(id)}/content${query}`, {
483
+ headers: t.headers,
484
+ signal: deps.signal,
485
+ });
486
+ if (!response.ok) throw errorFromResponse(response, await readJson(response), id);
487
+ const type = String(response.headers.get("content-type") ?? output?.content_type ?? "")
488
+ .split(";", 1)[0].trim().toLowerCase();
489
+ const extension = OUTPUT_EXTENSIONS[type] ?? ".bin";
490
+ const destination = `outputs/${id}-${index + 1}${extension}`;
491
+ const root = await resolveWorkspaceRoot(workspaceRoot);
492
+ const absolute = await (deps.prepareTarget ?? prepareWorkspaceTarget)(root, destination, true);
493
+ await (deps.writeFile ?? writeFile)(absolute, Buffer.from(await response.arrayBuffer()));
494
+ return destination;
495
+ }
496
+
497
+ /**
498
+ * Where each output of a completed generation can be opened, in output order:
499
+ * its files address when the provider stored it on the user's drive, otherwise
500
+ * a copy under the session workspace's outputs/ fetched through Router.
501
+ */
502
+ export async function settledFiles(payload, workspaceRoot, deps = {}) {
503
+ const env = deps.env ?? process.env;
504
+ const enriched = await attachFlowstudioFilesPaths(payload, routerEndUser(env), {
505
+ signal: deps.signal,
506
+ cat: deps.cat,
507
+ });
508
+ const outputs = Array.isArray(enriched?.outputs) ? enriched.outputs : [];
509
+ const files = [];
510
+ for (const [index, output] of outputs.entries()) {
511
+ const address = String(output?.files_path ?? "").trim();
512
+ files.push(address || await downloadOutput(String(enriched.id ?? payload.id), output, index, workspaceRoot, deps));
513
+ }
514
+ return files;
515
+ }
516
+
517
+ /**
518
+ * Generations this session submitted and never recorded as settled. The ledger
519
+ * is what lets a result reach the user after the turn that asked for it ended
520
+ * early — a model request failing after a nine-minute render, a cancel, a
521
+ * restart — instead of only living in the tool call that was waiting on it.
522
+ */
523
+ export function pendingMediaGenerations(events) {
524
+ const pending = new Map();
525
+ for (const event of events ?? []) {
526
+ if (event?.type === MEDIA_SUBMITTED_EVENT && event.data?.id) {
527
+ pending.set(String(event.data.id), { ...event.data });
528
+ } else if (event?.type === MEDIA_SETTLED_EVENT && event.data?.id) {
529
+ pending.delete(String(event.data.id));
530
+ }
531
+ }
532
+ return [...pending.values()];
533
+ }
534
+
535
+ /** @returns {Record<string, unknown>} the settled ledger entry for a final payload or error. */
536
+ export function settledRecord(id, outcome) {
537
+ if (outcome instanceof Error) {
538
+ return {
539
+ id,
540
+ status: "failed",
541
+ error: outcome.message,
542
+ ...(outcome instanceof MediaGenerationError && outcome.code ? { code: outcome.code } : {}),
543
+ };
544
+ }
545
+ return { id, status: outcome.status, files: outcome.files ?? [] };
546
+ }
547
+
548
+ function appendPresented(session, turn, callId, files) {
549
+ if (files.length === 0 || turn === undefined || turn === null) return;
550
+ session.append("deliverables/presented", { turn, callId, files: files.map((path) => ({ path })) });
551
+ }
552
+
553
+ /**
554
+ * Settle what the ledger still lists as pending, presenting finished outputs
555
+ * in the current turn. A generation that cannot be checked right now stays
556
+ * pending and is left out of the result, so an unreachable Router does not
557
+ * turn into a note every turn.
558
+ */
559
+ export async function recoverMediaGenerations(session, { turn, workspaceRoot }, deps = {}) {
560
+ const recovered = [];
561
+ for (const entry of pendingMediaGenerations(session?.events)) {
562
+ let payload;
563
+ try {
564
+ payload = await peekMediaGeneration(entry.id, deps);
565
+ } catch (error) {
566
+ if (deps.signal?.aborted) throw error;
567
+ if (error instanceof MediaGenerationError && error.status === 404) {
568
+ const gone = new MediaGenerationError("the generation has expired or no longer exists", error);
569
+ session.append(MEDIA_SETTLED_EVENT, settledRecord(entry.id, gone));
570
+ recovered.push({ ...entry, status: "failed", error: gone.message });
571
+ }
572
+ continue;
573
+ }
574
+ const status = String(payload?.status ?? "").toLowerCase();
575
+ if (!TERMINAL.has(status)) {
576
+ recovered.push({ ...entry, pending: true });
577
+ continue;
578
+ }
579
+ if (status !== "completed") {
580
+ const failure = generationFailure(payload);
581
+ session.append(MEDIA_SETTLED_EVENT, settledRecord(entry.id, failure));
582
+ recovered.push({ ...entry, status, error: failure.message });
583
+ continue;
584
+ }
585
+ const files = await settledFiles(payload, workspaceRoot, deps);
586
+ session.append(MEDIA_SETTLED_EVENT, settledRecord(entry.id, { status, files }));
587
+ appendPresented(session, turn, `media-recovered-${entry.id}`, files);
588
+ recovered.push({ ...entry, status, files });
589
+ }
590
+ return recovered;
591
+ }
592
+
593
+ /**
594
+ * The skill passes images as `reference_images`. A FlowStudio row names those
595
+ * files as parameter slots, so an unfilled image slot takes the next path and
596
+ * the slot copy into Home/FlowStudio does the rest. Paths already in params
597
+ * stay put. Leftover paths stay `reference_images` for a row with no slot.
598
+ */
599
+ function placeReferenceImages(contract, args) {
600
+ const paths = Array.isArray(args?.reference_images)
601
+ ? args.reference_images.map((path) => String(path ?? "").trim()).filter(Boolean)
602
+ : [];
603
+ const params = args?.params && typeof args.params === "object" && !Array.isArray(args.params)
604
+ ? { ...args.params }
605
+ : {};
606
+ const open = mediaSlots(contract).filter((slot) => (
607
+ (slot.media === "image" || slot.type === "image") && (params[slot.key] == null || params[slot.key] === "")
608
+ ));
609
+ let used = 0;
610
+ for (const slot of open) {
611
+ if (used >= paths.length) break;
612
+ params[slot.key] = paths[used];
613
+ used += 1;
614
+ }
615
+ return { ...args, params, reference_images: paths.slice(used) };
616
+ }
617
+
618
+ /**
619
+ * Run one generation to completion inside a tool call: submit (or pick up an
620
+ * id an earlier turn submitted), record it in the session ledger before
621
+ * waiting, and settle the ledger with what came back. A wait that is aborted
622
+ * or times out leaves the entry pending for {@link recoverMediaGenerations}.
623
+ */
624
+ export async function runMediaGeneration(args, context, deps = {}) {
625
+ const stage = { name: "" };
626
+ try {
627
+ return await runMediaGenerationStages(args, context, deps, stage);
628
+ } catch (error) {
629
+ throw describedMediaError(error, stage.name);
630
+ }
631
+ }
632
+
633
+ async function runMediaGenerationStages(args, { session, callId, workspaceRoot, signal }, deps, stage) {
634
+ stage.name = "catalog";
635
+ const resumeId = String(args?.generation_id ?? "").trim();
636
+ let id = resumeId;
637
+ let model = String(args?.model ?? "").trim();
638
+ let mode = "";
639
+ if (!resumeId) {
640
+ if (!model.includes("/")) throw new MediaGenerationError("model must be <provider>/<model> as the catalog lists it");
641
+ const contract = rowContract(await fetchCatalogRow(model, { ...deps, signal }));
642
+ const placed = placeReferenceImages(contract, args);
643
+ const paths = Array.isArray(placed.reference_images) ? placed.reference_images : [];
644
+ const images = [];
645
+ for (const path of paths) images.push(await referenceImageDataUrl(workspaceRoot, path));
646
+ const maskPath = String(placed.mask_image ?? "").trim();
647
+ const maskImageDataUrl = maskPath
648
+ ? await referenceImageDataUrl(workspaceRoot, maskPath)
649
+ : "";
650
+ const params = await slotParams(contract, placed.params, workspaceRoot, deps.env ?? process.env, deps);
651
+ const request = mediaGenerationRequest(
652
+ contract,
653
+ { ...placed, ...(params === undefined ? {} : { params }), mask_image_data_url: maskImageDataUrl },
654
+ images,
655
+ );
656
+ stage.name = "submit";
657
+ const created = await submitMediaGeneration(request, {
658
+ ...deps,
659
+ signal,
660
+ idempotencyKey: mediaIdempotencyKey(callId),
661
+ });
662
+ id = String(created.id);
663
+ model = request.body.model;
664
+ mode = request.mode;
665
+ }
666
+ const known = pendingMediaGenerations(session?.events).some((entry) => entry.id === id);
667
+ if (!known) session?.append(MEDIA_SUBMITTED_EVENT, { id, model, mode, callId });
668
+
669
+ let final;
670
+ stage.name = `wait (${id})`;
671
+ try {
672
+ final = await awaitMediaGeneration(id, { ...deps, signal });
673
+ } catch (error) {
674
+ if (signal?.aborted || !(error instanceof MediaGenerationError) || error.status !== 404) throw error;
675
+ session?.append(MEDIA_SETTLED_EVENT, settledRecord(id, error));
676
+ throw error;
677
+ }
678
+ const status = String(final?.status ?? "").toLowerCase();
679
+ if (status !== "completed") {
680
+ const failure = generationFailure(final);
681
+ session?.append(MEDIA_SETTLED_EVENT, settledRecord(id, failure));
682
+ throw failure;
683
+ }
684
+ stage.name = `publish (${id})`;
685
+ const files = await settledFiles(final, workspaceRoot, { ...deps, signal });
686
+ session?.append(MEDIA_SETTLED_EVENT, settledRecord(id, { status, files }));
687
+ return { id, status, files };
688
+ }
689
+
690
+ /** Model-facing note about generations recovered at the start of a turn. */
691
+ export function recoveredGenerationsNote(recovered) {
692
+ if (recovered.length === 0) return "";
693
+ const lines = recovered.map((item) => {
694
+ if (item.files?.length) {
695
+ return `- ${item.id} (${item.model ?? "media"}) finished and is now presented: ${item.files.map((path) => `\`${path}\``).join(", ")}`;
696
+ }
697
+ if (item.pending) {
698
+ return `- ${item.id} (${item.model ?? "media"}) is still running; call media_generate with generation_id "${item.id}" to wait for it`;
699
+ }
700
+ return `- ${item.id} (${item.model ?? "media"}) ended without output: ${item.error ?? item.status}`;
701
+ });
702
+ return [
703
+ "Media generations started in an earlier turn were checked before this one:",
704
+ ...lines,
705
+ "Mention a finished one to the user if it is what they were waiting for; do not generate it again.",
706
+ ].join("\n");
707
+ }