privateer-agent 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/fresh-supervisor.d.mts +5 -1
- package/bin/fresh-supervisor.mjs +47 -10
- package/bin/privateer-launch.mjs +13 -1
- package/bin/privateer-subagent.mjs +5 -2
- package/bin/session-recovery.d.mts +53 -0
- package/bin/session-recovery.mjs +240 -0
- package/bin/session-seal-preload.mjs +17 -0
- package/bin/session-seal.d.mts +25 -0
- package/bin/session-seal.mjs +242 -0
- package/extensions/privateer-gate.ts +75 -3
- package/extensions/privateer-recovery.ts +175 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +89 -0
- package/src/cli/chat.ts +7 -1
- package/src/config/moatManifest.json +1 -0
- package/src/config/whatsNew.json +4 -4
- package/src/permissions/classify.ts +11 -4
- package/src/remote/attentionPush.ts +40 -0
- package/src/remote/liveTaskSession.ts +7 -1
- package/src/remote/relayClient.ts +38 -4
- package/src/remote/remoteBridge.ts +17 -0
- package/src/remote/transcriptSnapshot.ts +104 -0
- package/src/tools/media.ts +291 -49
package/src/tools/media.ts
CHANGED
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
// stitch → score → send.
|
|
39
39
|
|
|
40
40
|
import { Type } from "typebox";
|
|
41
|
-
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync } from "node:fs";
|
|
41
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
42
|
+
import { createDecipheriv, randomBytes } from "node:crypto";
|
|
42
43
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
43
44
|
import { tmpdir } from "node:os";
|
|
45
|
+
import { Agent } from "undici";
|
|
44
46
|
import { apiRequest } from "../auth/privateer.ts";
|
|
45
47
|
import { effectiveMediaModel } from "../config/mediaModels.ts";
|
|
48
|
+
import { globalDir } from "../config/paths.ts";
|
|
46
49
|
|
|
47
50
|
/** Tool names these definitions register, for allow-list construction. */
|
|
48
51
|
export const MEDIA_TOOL_NAMES = [
|
|
@@ -167,6 +170,51 @@ interface AccountFailure {
|
|
|
167
170
|
status?: number;
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
// Pi installs a process-wide undici dispatcher whose headersTimeout/bodyTimeout is
|
|
174
|
+
// its HTTP idle timeout — 5 minutes by default. That is right for a model stream and
|
|
175
|
+
// wrong here: an image, speech or music POST answers only once the provider has
|
|
176
|
+
// finished, and a slow model (gpt-image with input images, a long track) can take
|
|
177
|
+
// longer than that to send its first byte. undici then aborts with a HeadersTimeout
|
|
178
|
+
// that fetch flattens to the bare "fetch failed", and an agent driving Privateer
|
|
179
|
+
// (Claude Code, Hermes, OpenClaw…) saw a 5-6 minute hang followed by a message that
|
|
180
|
+
// gave it nothing to act on — while the generation it had paid for finished anyway.
|
|
181
|
+
// Media calls get their own dispatcher with a ceiling sized to the slowest
|
|
182
|
+
// synchronous generation; the tool's AbortSignal still cancels it sooner.
|
|
183
|
+
export const MEDIA_HTTP_TIMEOUT_MS = 20 * 60_000;
|
|
184
|
+
let _mediaDispatcher: Agent | null = null;
|
|
185
|
+
function mediaDispatcher(): Agent {
|
|
186
|
+
return (_mediaDispatcher ??= new Agent({
|
|
187
|
+
headersTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
188
|
+
bodyTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
189
|
+
connect: { autoSelectFamilyAttemptTimeout: 2_000 },
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Say WHY a request got no HTTP response. fetch reports every transport failure as
|
|
195
|
+
* "fetch failed" and keeps the real reason on `cause` (an undici error with a `code`,
|
|
196
|
+
* or a Node errno); printing only the message threw that away, so a caller could not
|
|
197
|
+
* tell a timeout from a dead network from DNS. The timeout case also warns about
|
|
198
|
+
* billing: the server keeps working after we stop listening, so blindly re-sending
|
|
199
|
+
* the same prompt can pay for the same generation twice.
|
|
200
|
+
*/
|
|
201
|
+
export function describeTransportFailure(e: unknown): string {
|
|
202
|
+
if (e instanceof Error && e.name === "AbortError") return "the request was cancelled";
|
|
203
|
+
const top = e instanceof Error ? e.message : String(e);
|
|
204
|
+
const cause = (e as { cause?: unknown })?.cause;
|
|
205
|
+
const code = String((cause as { code?: unknown })?.code ?? (e as { code?: unknown })?.code ?? "");
|
|
206
|
+
const causeMsg = cause instanceof Error ? cause.message : "";
|
|
207
|
+
if (/^UND_ERR_(HEADERS|BODY)_TIMEOUT$/.test(code)) {
|
|
208
|
+
return (
|
|
209
|
+
`Privateer did not answer within ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes (${code}). ` +
|
|
210
|
+
"The generation may still have finished and been billed on the server, so check the output path " +
|
|
211
|
+
"before retrying, and retry once rather than in a loop"
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const detail = [code, causeMsg].filter(Boolean).join(": ");
|
|
215
|
+
return `could not reach Privateer: ${top}${detail && !top.includes(detail) ? ` (${detail})` : ""}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
170
218
|
/**
|
|
171
219
|
* Call the account API and return the parsed payload, or a message written for the
|
|
172
220
|
* model to read. Errors are surfaced rather than swallowed — an agent that quietly
|
|
@@ -175,11 +223,14 @@ interface AccountFailure {
|
|
|
175
223
|
*/
|
|
176
224
|
async function callAccount<T>(
|
|
177
225
|
path: string,
|
|
178
|
-
init: { method: "GET" | "POST"; body?: unknown; signal?: AbortSignal },
|
|
226
|
+
init: { method: "GET" | "POST" | "DELETE"; body?: unknown; signal?: AbortSignal },
|
|
179
227
|
): Promise<({ ok: true } & { data: T }) | AccountFailure> {
|
|
180
228
|
let res: Response;
|
|
181
229
|
try {
|
|
182
230
|
res = await apiRequest(path, {
|
|
231
|
+
// npm undici's Agent vs the undici-types Dispatcher in RequestInit: same runtime
|
|
232
|
+
// contract (verified against both Node's bundled fetch and Pi's undici 8).
|
|
233
|
+
...({ dispatcher: mediaDispatcher() } as object),
|
|
183
234
|
method: init.method,
|
|
184
235
|
...(init.body === undefined
|
|
185
236
|
? {}
|
|
@@ -187,9 +238,18 @@ async function callAccount<T>(
|
|
|
187
238
|
...(init.signal ? { signal: init.signal } : {}),
|
|
188
239
|
});
|
|
189
240
|
} catch (e) {
|
|
190
|
-
return { ok: false, message:
|
|
241
|
+
return { ok: false, message: describeTransportFailure(e) };
|
|
191
242
|
}
|
|
243
|
+
return interpretAccountResponse<T>(res);
|
|
244
|
+
}
|
|
192
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Turn an account-API response into the payload or a message for the model. Split
|
|
248
|
+
* out of callAccount because a deferred job's result arrives SEALED inside a poll,
|
|
249
|
+
* and once opened it must be judged exactly as the same response would have been
|
|
250
|
+
* had it come back directly — same ZDR wording, same 402, same 503 advice.
|
|
251
|
+
*/
|
|
252
|
+
async function interpretAccountResponse<T>(res: Response): Promise<({ ok: true } & { data: T }) | AccountFailure> {
|
|
193
253
|
if (res.ok || res.status === 202) {
|
|
194
254
|
try {
|
|
195
255
|
return { ok: true, data: (await res.json()) as T };
|
|
@@ -261,6 +321,191 @@ async function callAccount<T>(
|
|
|
261
321
|
return { ok: false, status: res.status, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
|
|
262
322
|
}
|
|
263
323
|
|
|
324
|
+
// ── Deferred jobs (image, speech, music, sfx) ────────────────────────────────
|
|
325
|
+
//
|
|
326
|
+
// These four used to be ONE blocking POST that answered only when the provider had
|
|
327
|
+
// finished. A slow generation outlived the idle timeouts between us and the server,
|
|
328
|
+
// the connection died, and the result — already billed — was gone. Now each is
|
|
329
|
+
// submit → job id → poll, like video: POST /api/agent/media/jobs runs the original
|
|
330
|
+
// route server-side and parks the response SEALED under a key only this machine
|
|
331
|
+
// holds (`sealKey`, sent with the submit and dropped by the server). The key is
|
|
332
|
+
// kept on disk beside the job id, so a later process — the same agent after a
|
|
333
|
+
// timeout, or another session entirely — can collect it with `resumeJobId`.
|
|
334
|
+
//
|
|
335
|
+
// A server older than the jobs route answers 404, and the call falls back to the
|
|
336
|
+
// old blocking request, so an agent released before the server deploys still works.
|
|
337
|
+
|
|
338
|
+
export type DeferredKind = "image" | "speech" | "music" | "sfx";
|
|
339
|
+
const DEFERRED_SYNC_PATH: Record<DeferredKind, string> = {
|
|
340
|
+
image: "/api/agent/media/images",
|
|
341
|
+
speech: "/api/audio/speech",
|
|
342
|
+
music: "/api/audio/music",
|
|
343
|
+
sfx: "/api/audio/sfx",
|
|
344
|
+
};
|
|
345
|
+
const DEFERRED_TOOL: Record<DeferredKind, string> = {
|
|
346
|
+
image: "generate_image",
|
|
347
|
+
speech: "generate_speech",
|
|
348
|
+
music: "generate_music",
|
|
349
|
+
sfx: "generate_sfx",
|
|
350
|
+
};
|
|
351
|
+
const DEFERRED_POLL_MS = 3_000;
|
|
352
|
+
// The server holds a result for an hour; a key older than that opens nothing.
|
|
353
|
+
const DEFERRED_KEY_TTL_MS = 2 * 60 * 60_000;
|
|
354
|
+
|
|
355
|
+
function deferredKeyDir(): string {
|
|
356
|
+
return join(globalDir(), "media-jobs");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function saveDeferredKey(jobId: string, kind: DeferredKind, key: Buffer): void {
|
|
360
|
+
const dir = deferredKeyDir();
|
|
361
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
362
|
+
// Prune expired keys while we are here — nothing else ever reads them.
|
|
363
|
+
try {
|
|
364
|
+
for (const f of readdirSync(dir)) {
|
|
365
|
+
const p = join(dir, f);
|
|
366
|
+
try {
|
|
367
|
+
const { createdAt } = JSON.parse(readFileSync(p, "utf8")) as { createdAt?: number };
|
|
368
|
+
if (!createdAt || Date.now() - createdAt > DEFERRED_KEY_TTL_MS) unlinkSync(p);
|
|
369
|
+
} catch { /* unreadable — leave it */ }
|
|
370
|
+
}
|
|
371
|
+
} catch { /* best effort */ }
|
|
372
|
+
writeFileSync(join(dir, `${jobId}.json`), JSON.stringify({ kind, key: key.toString("base64"), createdAt: Date.now() }), { mode: 0o600 });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function loadDeferredKey(jobId: string): { kind: DeferredKind; key: Buffer } | null {
|
|
376
|
+
if (!/^amj_[0-9a-f]+$/.test(jobId)) return null;
|
|
377
|
+
try {
|
|
378
|
+
const raw = JSON.parse(readFileSync(join(deferredKeyDir(), `${jobId}.json`), "utf8")) as { kind: DeferredKind; key: string };
|
|
379
|
+
return { kind: raw.kind, key: Buffer.from(raw.key, "base64") };
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function forgetDeferredKey(jobId: string): void {
|
|
386
|
+
try { unlinkSync(join(deferredKeyDir(), `${jobId}.json`)); } catch { /* already gone */ }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Open the server's iv(12) | tag(16) | ciphertext. Throws on a wrong key or tampering. */
|
|
390
|
+
export function openSealedResult(key: Buffer, sealedB64: string): { status: number; body: string } {
|
|
391
|
+
const buf = Buffer.from(sealedB64, "base64");
|
|
392
|
+
const d = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
|
|
393
|
+
d.setAuthTag(buf.subarray(12, 28));
|
|
394
|
+
return JSON.parse(Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString("utf8"));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function deferredResumeHint(kind: DeferredKind, jobId: string): string {
|
|
398
|
+
return `Collect it with ${DEFERRED_TOOL[kind]} { resumeJobId: "${jobId}", path: <same path> } — that waits on this same job and bills nothing further.`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface DeferredStatus {
|
|
402
|
+
status?: string;
|
|
403
|
+
kind?: string;
|
|
404
|
+
httpStatus?: number;
|
|
405
|
+
sealed?: string;
|
|
406
|
+
message?: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type DeferredResult<T> = ({ ok: true; data: T }) | AccountFailure | { ok: "started"; jobId: string };
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Run a generation as a deferred job and return what the blocking call would have.
|
|
413
|
+
* `resumeJobId` skips the submit and only waits; `wait: false` returns as soon as
|
|
414
|
+
* the job is accepted, so a caller can start several and collect them later.
|
|
415
|
+
*/
|
|
416
|
+
async function callDeferred<T>(
|
|
417
|
+
kind: DeferredKind,
|
|
418
|
+
body: unknown,
|
|
419
|
+
opts: { signal?: AbortSignal; resumeJobId?: string; wait?: boolean },
|
|
420
|
+
): Promise<DeferredResult<T>> {
|
|
421
|
+
let jobId = String(opts.resumeJobId ?? "").trim();
|
|
422
|
+
let key: Buffer;
|
|
423
|
+
if (jobId) {
|
|
424
|
+
const held = loadDeferredKey(jobId);
|
|
425
|
+
if (!held) {
|
|
426
|
+
return { ok: false, message: `no key for job ${jobId} on this machine — it was started elsewhere, already collected, or is over two hours old. Its result cannot be opened here.` };
|
|
427
|
+
}
|
|
428
|
+
if (held.kind !== kind) {
|
|
429
|
+
return { ok: false, message: `job ${jobId} is a ${held.kind} job; collect it with ${DEFERRED_TOOL[held.kind]}.` };
|
|
430
|
+
}
|
|
431
|
+
key = held.key;
|
|
432
|
+
} else {
|
|
433
|
+
key = randomBytes(32);
|
|
434
|
+
const submitted = await callAccount<{ jobId?: string }>("/api/agent/media/jobs", {
|
|
435
|
+
method: "POST",
|
|
436
|
+
signal: opts.signal,
|
|
437
|
+
body: { kind, sealKey: key.toString("base64"), request: body },
|
|
438
|
+
});
|
|
439
|
+
if (!submitted.ok) {
|
|
440
|
+
// A server from before the jobs route: do it the old, blocking way.
|
|
441
|
+
if (submitted.status === 404 || submitted.status === 405) {
|
|
442
|
+
return callAccount<T>(DEFERRED_SYNC_PATH[kind], { method: "POST", body, signal: opts.signal });
|
|
443
|
+
}
|
|
444
|
+
return submitted;
|
|
445
|
+
}
|
|
446
|
+
jobId = String(submitted.data.jobId ?? "");
|
|
447
|
+
if (!jobId) return { ok: false, message: "Privateer accepted the job but returned no job id" };
|
|
448
|
+
saveDeferredKey(jobId, kind, key);
|
|
449
|
+
if (opts.wait === false) return { ok: "started", jobId };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const hint = deferredResumeHint(kind, jobId);
|
|
453
|
+
const deadline = Date.now() + MEDIA_HTTP_TIMEOUT_MS;
|
|
454
|
+
let failures = 0;
|
|
455
|
+
while (Date.now() < deadline) {
|
|
456
|
+
if (opts.signal?.aborted) {
|
|
457
|
+
return { ok: false, message: `stopped waiting — job ${jobId} keeps running on the server. ${hint}` };
|
|
458
|
+
}
|
|
459
|
+
const poll = await callAccount<DeferredStatus>(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "GET", signal: opts.signal });
|
|
460
|
+
if (!poll.ok) {
|
|
461
|
+
if (spritePollWorthRetrying(poll.status) && ++failures < SPRITE_POLL_FAILURE_BUDGET) {
|
|
462
|
+
await sleep(DEFERRED_POLL_MS * 2, opts.signal);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (poll.status === 404) forgetDeferredKey(jobId);
|
|
466
|
+
return { ok: false, status: poll.status, message: `job ${jobId} could not be collected: ${poll.message}${poll.status === 404 ? "" : `. ${hint}`}` };
|
|
467
|
+
}
|
|
468
|
+
failures = 0;
|
|
469
|
+
const status = String(poll.data.status ?? "");
|
|
470
|
+
if (status === "failed") {
|
|
471
|
+
forgetDeferredKey(jobId);
|
|
472
|
+
return { ok: false, message: poll.data.message ?? `job ${jobId} failed on the server` };
|
|
473
|
+
}
|
|
474
|
+
if (status === "done" && poll.data.sealed) {
|
|
475
|
+
let opened: { status: number; body: string };
|
|
476
|
+
try {
|
|
477
|
+
opened = openSealedResult(key, poll.data.sealed);
|
|
478
|
+
} catch {
|
|
479
|
+
return { ok: false, message: `job ${jobId}'s result could not be opened with this machine's key` };
|
|
480
|
+
}
|
|
481
|
+
forgetDeferredKey(jobId);
|
|
482
|
+
// Ack so the server drops the ciphertext now rather than at the TTL.
|
|
483
|
+
void callAccount(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE" }).catch(() => {});
|
|
484
|
+
return interpretAccountResponse<T>(new Response(opened.body, { status: opened.status, headers: { "Content-Type": "application/json" } }));
|
|
485
|
+
}
|
|
486
|
+
await sleep(DEFERRED_POLL_MS, opts.signal);
|
|
487
|
+
}
|
|
488
|
+
return { ok: false, message: `job ${jobId} is still running after ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes. ${hint}` };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Shared parameters every deferred tool takes. */
|
|
492
|
+
const deferredParams = {
|
|
493
|
+
resumeJobId: Type.Optional(Type.String({
|
|
494
|
+
description:
|
|
495
|
+
"Collect a job started earlier (with wait:false, or one that timed out) instead of starting a new one. " +
|
|
496
|
+
"Nothing is generated and nothing is billed: it only waits and saves. Other generation fields are ignored; `path` still says where to save.",
|
|
497
|
+
})),
|
|
498
|
+
wait: Type.Optional(Type.Boolean({
|
|
499
|
+
description:
|
|
500
|
+
"Default true. Set false to start the generation and return its job id immediately, so you can start " +
|
|
501
|
+
"several at once and collect each later with resumeJobId. The job runs on Privateer's servers either way.",
|
|
502
|
+
})),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
function startedText(kind: DeferredKind, jobId: string): string {
|
|
506
|
+
return `Started ${kind} job ${jobId}; it is generating on Privateer's servers (results are held for up to an hour, sealed to this machine). ${deferredResumeHint(kind, jobId)}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
264
509
|
// ── Images ───────────────────────────────────────────────────────────────────
|
|
265
510
|
|
|
266
511
|
interface ImageResponse {
|
|
@@ -276,7 +521,7 @@ export const generateImageToolDefinition = {
|
|
|
276
521
|
"images already on disk — to EDIT or COMPOSE instead: one input means 'change this picture per the " +
|
|
277
522
|
"prompt', several means 'combine these into one'. Billed to the user's Privateer account and " +
|
|
278
523
|
"subject to its privacy settings; the prompt and any input images pass through Privateer's servers " +
|
|
279
|
-
"in plaintext,
|
|
524
|
+
"in plaintext. The finished result is held on the server for up to an hour, encrypted to a key only this machine holds, until it is collected — the only readable copy is the file you name. Use the resulting " +
|
|
280
525
|
"path as the first frame of generate_video, or as slideshow material for video_compose.",
|
|
281
526
|
parameters: Type.Object({
|
|
282
527
|
prompt: Type.String({ description: "What the image should show. Be specific about subject, style, lighting and framing." }),
|
|
@@ -292,40 +537,39 @@ export const generateImageToolDefinition = {
|
|
|
292
537
|
aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
|
|
293
538
|
size: Type.Optional(Type.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
|
|
294
539
|
model: Type.Optional(Type.String({ description: "Override the image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the one the user chose with /image-model, or the account default." })),
|
|
540
|
+
...deferredParams,
|
|
295
541
|
}),
|
|
296
542
|
async execute(
|
|
297
543
|
_toolCallId: string,
|
|
298
|
-
params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string },
|
|
544
|
+
params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
299
545
|
signal?: AbortSignal,
|
|
300
546
|
_onUpdate?: unknown,
|
|
301
547
|
ctx?: { cwd?: string },
|
|
302
548
|
) {
|
|
303
549
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
304
550
|
const prompt = String(params.prompt ?? "").trim();
|
|
305
|
-
|
|
551
|
+
const resuming = Boolean(String(params.resumeJobId ?? "").trim());
|
|
552
|
+
if (!prompt && !resuming) return text("Error: prompt is required.");
|
|
306
553
|
if (!params.path) return text("Error: path is required — say where to save the image.");
|
|
307
554
|
|
|
308
555
|
let inputs: { data: string; mimeType: string }[];
|
|
309
556
|
try {
|
|
310
|
-
inputs = (params.images ?? []).map((p) => readInputImage(cwd, p));
|
|
557
|
+
inputs = resuming ? [] : (params.images ?? []).map((p) => readInputImage(cwd, p));
|
|
311
558
|
} catch (e) {
|
|
312
559
|
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
313
560
|
}
|
|
314
561
|
|
|
315
562
|
const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
|
|
316
563
|
const model = effectiveMediaModel("image", params.model);
|
|
317
|
-
const r = await
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
...(model ? { model } : {}),
|
|
327
|
-
},
|
|
328
|
-
});
|
|
564
|
+
const r = await callDeferred<ImageResponse>("image", {
|
|
565
|
+
prompt,
|
|
566
|
+
n: count,
|
|
567
|
+
...(inputs.length ? { images: inputs } : {}),
|
|
568
|
+
...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
|
|
569
|
+
...(params.size ? { imageSize: params.size } : {}),
|
|
570
|
+
...(model ? { model } : {}),
|
|
571
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
572
|
+
if (r.ok === "started") return text(startedText("image", r.jobId));
|
|
329
573
|
if (!r.ok) return text(`Image generation failed: ${r.message}`);
|
|
330
574
|
|
|
331
575
|
const images = r.data.images ?? [];
|
|
@@ -344,7 +588,7 @@ export const generateImageToolDefinition = {
|
|
|
344
588
|
}
|
|
345
589
|
if (written.length === 0) return text("Image generation returned no usable image data.");
|
|
346
590
|
|
|
347
|
-
const verb = inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
|
|
591
|
+
const verb = resuming ? "Collected" : inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
|
|
348
592
|
return text(`${verb} ${written.length} image${written.length === 1 ? "" : "s"} with ${r.data.model ?? "the account image model"}:\n${written.map((w) => ` ${w}`).join("\n")}`);
|
|
349
593
|
},
|
|
350
594
|
};
|
|
@@ -782,28 +1026,26 @@ export const generateSpeechToolDefinition = {
|
|
|
782
1026
|
"refused with VOICE_UNSUPPORTED rather than silently served on a different voice.",
|
|
783
1027
|
})),
|
|
784
1028
|
model: Type.Optional(Type.String({ description: "Override the account's text-to-speech model." })),
|
|
1029
|
+
...deferredParams,
|
|
785
1030
|
}),
|
|
786
1031
|
async execute(
|
|
787
1032
|
_toolCallId: string,
|
|
788
|
-
params: { text: string; path: string; voice?: string; model?: string },
|
|
1033
|
+
params: { text: string; path: string; voice?: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
789
1034
|
signal?: AbortSignal,
|
|
790
1035
|
_onUpdate?: unknown,
|
|
791
1036
|
ctx?: { cwd?: string },
|
|
792
1037
|
) {
|
|
793
1038
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
794
1039
|
const body = String(params.text ?? "").trim();
|
|
795
|
-
if (!body) return text("Error: text is required.");
|
|
1040
|
+
if (!body && !String(params.resumeJobId ?? "").trim()) return text("Error: text is required.");
|
|
796
1041
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
797
1042
|
|
|
798
|
-
const r = await
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
...(params.model ? { ttsModelId: params.model } : {}),
|
|
805
|
-
},
|
|
806
|
-
});
|
|
1043
|
+
const r = await callDeferred<AudioResponse>("speech", {
|
|
1044
|
+
text: body,
|
|
1045
|
+
...(params.voice ? { voice: params.voice } : {}),
|
|
1046
|
+
...(params.model ? { ttsModelId: params.model } : {}),
|
|
1047
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
1048
|
+
if (r.ok === "started") return text(startedText("speech", r.jobId));
|
|
807
1049
|
if (!r.ok) return text(`Speech generation failed: ${r.message}`);
|
|
808
1050
|
if (!r.data.audioBase64) return text("Speech generation returned no audio.");
|
|
809
1051
|
|
|
@@ -834,24 +1076,26 @@ export const generateMusicToolDefinition = {
|
|
|
834
1076
|
prompt: Type.String({ description: "The music to generate: genre, mood, instrumentation, tempo. Keep it about the music, not about the user." }),
|
|
835
1077
|
path: Type.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/score.mp3')." }),
|
|
836
1078
|
model: Type.Optional(Type.String({ description: "Override the account's music model." })),
|
|
1079
|
+
...deferredParams,
|
|
837
1080
|
}),
|
|
838
1081
|
async execute(
|
|
839
1082
|
_toolCallId: string,
|
|
840
|
-
params: { prompt: string; path: string; model?: string },
|
|
1083
|
+
params: { prompt: string; path: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
841
1084
|
signal?: AbortSignal,
|
|
842
1085
|
_onUpdate?: unknown,
|
|
843
1086
|
ctx?: { cwd?: string },
|
|
844
1087
|
) {
|
|
845
1088
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
846
1089
|
const prompt = String(params.prompt ?? "").trim();
|
|
847
|
-
if (!prompt) return text("Error: prompt is required.");
|
|
1090
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
848
1091
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
849
1092
|
|
|
850
|
-
const r = await
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1093
|
+
const r = await callDeferred<AudioResponse>(
|
|
1094
|
+
"music",
|
|
1095
|
+
{ prompt, ...(params.model ? { musicModelId: params.model } : {}) },
|
|
1096
|
+
{ signal, resumeJobId: params.resumeJobId, wait: params.wait },
|
|
1097
|
+
);
|
|
1098
|
+
if (r.ok === "started") return text(startedText("music", r.jobId));
|
|
855
1099
|
if (!r.ok) return text(`Music generation failed: ${r.message}`);
|
|
856
1100
|
if (!r.data.audioBase64) return text("Music generation returned no audio.");
|
|
857
1101
|
|
|
@@ -907,17 +1151,18 @@ export const generateSfxToolDefinition = {
|
|
|
907
1151
|
"foley in the catalog; the default is a cheaper model tuned for single sounds.",
|
|
908
1152
|
}),
|
|
909
1153
|
),
|
|
1154
|
+
...deferredParams,
|
|
910
1155
|
}),
|
|
911
1156
|
async execute(
|
|
912
1157
|
_toolCallId: string,
|
|
913
|
-
params: { prompt: string; path: string; seconds?: number; model?: string },
|
|
1158
|
+
params: { prompt: string; path: string; seconds?: number; model?: string; resumeJobId?: string; wait?: boolean },
|
|
914
1159
|
signal?: AbortSignal,
|
|
915
1160
|
_onUpdate?: unknown,
|
|
916
1161
|
ctx?: { cwd?: string },
|
|
917
1162
|
) {
|
|
918
1163
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
919
1164
|
const prompt = String(params.prompt ?? "").trim();
|
|
920
|
-
if (!prompt) return text("Error: prompt is required.");
|
|
1165
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
921
1166
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
922
1167
|
|
|
923
1168
|
let seconds: number | undefined;
|
|
@@ -935,15 +1180,12 @@ export const generateSfxToolDefinition = {
|
|
|
935
1180
|
seconds = Math.round(seconds);
|
|
936
1181
|
}
|
|
937
1182
|
|
|
938
|
-
const r = await
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
...(params.model ? { sfxModelId: params.model } : {}),
|
|
945
|
-
},
|
|
946
|
-
});
|
|
1183
|
+
const r = await callDeferred<AudioResponse>("sfx", {
|
|
1184
|
+
prompt,
|
|
1185
|
+
...(seconds != null ? { duration: seconds } : {}),
|
|
1186
|
+
...(params.model ? { sfxModelId: params.model } : {}),
|
|
1187
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
1188
|
+
if (r.ok === "started") return text(startedText("sfx", r.jobId));
|
|
947
1189
|
if (!r.ok) return text(`Sound-effect generation failed: ${r.message}`);
|
|
948
1190
|
if (!r.data.audioBase64) return text("Sound-effect generation returned no audio.");
|
|
949
1191
|
|