@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.
- package/README.md +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +100 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- 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
|
+
}
|