privateer-agent 0.12.13 → 0.12.14
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/privateer-subagent.mjs +10 -2
- package/extensions/privateer-gate.ts +7 -0
- package/extensions/privateer-media.ts +17 -4
- package/extensions/privateer-privacy.ts +13 -33
- package/extensions/privateer-tools.ts +8 -3
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
- package/src/acp/run.ts +29 -2
- package/src/channels/run.ts +9 -0
- package/src/cli/chat.ts +47 -0
- package/src/config/moat.ts +6 -26
- package/src/config/moatManifest.ts +5 -0
- package/src/config/privacyPolicy.ts +97 -0
- package/src/engine/errors.ts +97 -1
- package/src/ext/permissionGate.ts +6 -0
- package/src/harbor/index.ts +65 -11
- package/src/outbox/cloudOutbox.ts +72 -4
- package/src/permissions/childSpend.ts +105 -0
- package/src/permissions/classify.ts +61 -3
- package/src/permissions/modeGate.ts +39 -0
- package/src/providers/account.ts +8 -1
- package/src/providers/phala/measurements.ts +170 -0
- package/src/providers/phala/pin.ts +98 -0
- package/src/providers/phalaSeal.ts +131 -9
- package/src/providers/sealedShim.ts +6 -1
- package/src/remote/liveTaskSession.ts +8 -1
- package/src/remote/relayClient.ts +21 -0
- package/src/remote/remoteBridge.ts +9 -0
- package/src/routines/store.ts +11 -0
- package/src/tools/media.ts +516 -11
- package/src/tools/routineResult.ts +146 -0
- package/src/tools/videoCompose.ts +825 -7
package/src/tools/media.ts
CHANGED
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
// because a model choosing between "narrate this" and "score this" should know the
|
|
23
23
|
// difference in posture before it picks.
|
|
24
24
|
//
|
|
25
|
+
// SOUND EFFECTS ARE NOT THE SAME CASE, and the distinction is easy to get backwards.
|
|
26
|
+
// Every effect model is on fal, which is simply a non-ZDR provider — that is the exact
|
|
27
|
+
// situation the ZDR gate exists for, so `/api/audio/sfx` gates like image and video do
|
|
28
|
+
// and a default (ZDR-on) account is REFUSED until its owner enables non-ZDR media. Music
|
|
29
|
+
// skips the gate because gating it would leave an empty picker; sfx has no such excuse.
|
|
30
|
+
// So: never tell a user that sfx is exempt the way music is, and never suggest music as a
|
|
31
|
+
// ZDR-friendly substitute for an effect — it is the one with no gate at all.
|
|
32
|
+
//
|
|
25
33
|
// SHAPE. Every tool takes an explicit output `path` and returns that path. That is not
|
|
26
34
|
// bookkeeping: it makes the permission gate meaningful (a media call classifies as a
|
|
27
35
|
// write against a named file, see permissions/classify.ts), and it gives the NEXT step
|
|
@@ -38,8 +46,10 @@ import { apiRequest } from "../auth/privateer.ts";
|
|
|
38
46
|
export const MEDIA_TOOL_NAMES = [
|
|
39
47
|
"generate_image",
|
|
40
48
|
"generate_video",
|
|
49
|
+
"generate_model",
|
|
41
50
|
"generate_speech",
|
|
42
51
|
"generate_music",
|
|
52
|
+
"generate_sfx",
|
|
43
53
|
"media_capabilities",
|
|
44
54
|
] as const;
|
|
45
55
|
|
|
@@ -48,6 +58,18 @@ export const MEDIA_TOOL_NAMES = [
|
|
|
48
58
|
// caller can resume the poll rather than pay for another generation.
|
|
49
59
|
const VIDEO_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_VIDEO_TIMEOUT_MS) || 12 * 60_000;
|
|
50
60
|
const VIDEO_POLL_INTERVAL_MS = 5_000;
|
|
61
|
+
// A mesh job runs about a minute at the provider's stated typical time, and
|
|
62
|
+
// several for a large face count. Same bounded-wait contract as video: the job
|
|
63
|
+
// id is reported on timeout so the caller can resume the poll rather than pay
|
|
64
|
+
// for a second generation.
|
|
65
|
+
const MESH_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_MESH_TIMEOUT_MS) || 10 * 60_000;
|
|
66
|
+
const MESH_POLL_INTERVAL_MS = 5_000;
|
|
67
|
+
// Four reference views at 8 MB each would be ~43 MB of base64 — past the
|
|
68
|
+
// server's own body limit, so the request would be refused by a JSON parser with
|
|
69
|
+
// a message about payload size rather than about pictures. Caught here first,
|
|
70
|
+
// where the message can name the files.
|
|
71
|
+
const MAX_MESH_VIEWS = 4;
|
|
72
|
+
const MAX_MESH_INPUT_TOTAL_BYTES = 12 * 1024 * 1024;
|
|
51
73
|
// Bound what we'll upload as an input frame/reference. The server enforces its own
|
|
52
74
|
// ceiling; failing here first turns a 413 into a clear, local message.
|
|
53
75
|
const MAX_INPUT_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -179,6 +201,16 @@ async function callAccount<T>(
|
|
|
179
201
|
if (res.status === 400 || res.status === 413) {
|
|
180
202
|
return { ok: false, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
|
|
181
203
|
}
|
|
204
|
+
// 503/504 are OUR outage or a provider timing out, not a bad request: an unset
|
|
205
|
+
// provider key, our own balance with that provider, or a slow job. Retrying the same
|
|
206
|
+
// call later is the right move, and saying so stops a model from rewriting a perfectly
|
|
207
|
+
// good prompt in the belief it caused this.
|
|
208
|
+
if (res.status === 503 || res.status === 504) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
message: `${serverMessage || "that media service is temporarily unavailable"} — this is on Privateer's side, not the prompt's; try again in a few minutes`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
182
214
|
return { ok: false, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
|
|
183
215
|
}
|
|
184
216
|
|
|
@@ -397,12 +429,237 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
397
429
|
});
|
|
398
430
|
}
|
|
399
431
|
|
|
432
|
+
// ── 3D ───────────────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
interface ModelSubmitResponse {
|
|
435
|
+
jobId?: string;
|
|
436
|
+
status?: string;
|
|
437
|
+
model?: string;
|
|
438
|
+
format?: string;
|
|
439
|
+
estimatedUsd?: number;
|
|
440
|
+
}
|
|
441
|
+
interface ModelStatusResponse {
|
|
442
|
+
status?: string;
|
|
443
|
+
message?: string;
|
|
444
|
+
format?: string;
|
|
445
|
+
mimeType?: string;
|
|
446
|
+
data?: string;
|
|
447
|
+
delivered?: boolean;
|
|
448
|
+
model?: string;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export const generateModelToolDefinition = {
|
|
452
|
+
name: "generate_model",
|
|
453
|
+
label: "Generate 3D Model",
|
|
454
|
+
description:
|
|
455
|
+
"Generate a 3D MESH — a .glb (or .obj) file for a game engine or DCC tool — from one or more " +
|
|
456
|
+
"reference images. (This makes 3D geometry; it has nothing to do with language models.) There is no " +
|
|
457
|
+
"text-to-mesh here: generate the concept art with generate_image first, look at it, and pass that " +
|
|
458
|
+
"path as `images` — which also means the shape is generated from a look someone approved rather " +
|
|
459
|
+
"than from a sentence. Supply up to four views (front, back, left, right) of the SAME object to stop " +
|
|
460
|
+
"the model inventing the sides it cannot see; on some models that costs more and it is usually " +
|
|
461
|
+
"worth it. Generation takes a minute or more and this tool waits for it. EXPENSIVE and billed to " +
|
|
462
|
+
"the user's Privateer account: $0.14 to $2.41 a mesh depending on WHICH model and which options, " +
|
|
463
|
+
"so plan the asset before calling and tell the user the total before batching dozens of them.\n" +
|
|
464
|
+
"There are ten models from five vendors and they are not interchangeable — the dearest costs " +
|
|
465
|
+
"seventeen times the cheapest, and each takes DIFFERENT options. Call media_capabilities (with " +
|
|
466
|
+
"`model` set) to see a model's own options and price before choosing; pass what you picked as " +
|
|
467
|
+
"`axes`. Blocking out a shape is a job for the cheap end; a hero asset is not. PRIVACY: 3D " +
|
|
468
|
+
"generation runs on a provider with no zero-retention option, so it is gated — a ZDR account must " +
|
|
469
|
+
"have enabled non-ZDR media. Call media_capabilities first if unsure.",
|
|
470
|
+
parameters: Type.Object({
|
|
471
|
+
images: Type.Array(Type.String(), {
|
|
472
|
+
description:
|
|
473
|
+
"Paths to reference images of one object, best view first. 1-4 of them, read as front, back, left, right. " +
|
|
474
|
+
"How many are actually USED is per-model — media_capabilities reports `maxViews`, and several " +
|
|
475
|
+
"models take a single view and will quietly ignore the rest, so check before generating four " +
|
|
476
|
+
"images to feed one. Where extra views are used they give the model the sides it would " +
|
|
477
|
+
"otherwise invent; on the Hunyuan models they add $0.15 in total (not each) and are free elsewhere.",
|
|
478
|
+
}),
|
|
479
|
+
path: Type.String({
|
|
480
|
+
description:
|
|
481
|
+
"Where to write the mesh, relative to cwd or absolute (e.g. 'assets/props/crate.glb'). " +
|
|
482
|
+
"The extension is corrected to whatever container is actually delivered.",
|
|
483
|
+
}),
|
|
484
|
+
format: Type.Optional(
|
|
485
|
+
Type.String({
|
|
486
|
+
description:
|
|
487
|
+
"Container: 'glb' (default) or 'obj'. Prefer glb — it is the only one that carries the " +
|
|
488
|
+
"materials, so an 'obj' of a textured mesh arrives as bare geometry, and 'obj' is not produced " +
|
|
489
|
+
"at all for generateType 'Geometry'. Falls back to glb whenever the provider doesn't render it.",
|
|
490
|
+
}),
|
|
491
|
+
),
|
|
492
|
+
// `additionalProperties`, not Type.Record: TypeBox compiles a Record to
|
|
493
|
+
// `patternProperties`, which several providers' tool-schema validators
|
|
494
|
+
// reject outright — and a tool whose schema is refused fails at the provider
|
|
495
|
+
// with a message about JSON Schema rather than about 3D.
|
|
496
|
+
axes: Type.Optional(
|
|
497
|
+
Type.Object({}, {
|
|
498
|
+
additionalProperties: Type.Union([Type.String(), Type.Number(), Type.Boolean()]),
|
|
499
|
+
description:
|
|
500
|
+
"The chosen model's own options, as {name: value} — the ONLY way to reach the options on any " +
|
|
501
|
+
"model except the Hunyuan ones, since no two endpoints share their levers (one takes " +
|
|
502
|
+
"`resolution`, another a `texture` ladder, another a `highPack` flag). Get the exact names and " +
|
|
503
|
+
"legal values from media_capabilities with the same `model`; an option this model does not " +
|
|
504
|
+
"have is REFUSED, not ignored, so do not guess. Omit an option to take the model's own " +
|
|
505
|
+
"default, which for a numeric budget means letting the provider choose — on some models " +
|
|
506
|
+
"setting it at all is what costs extra.",
|
|
507
|
+
}),
|
|
508
|
+
),
|
|
509
|
+
generateType: Type.Optional(
|
|
510
|
+
Type.String({
|
|
511
|
+
description:
|
|
512
|
+
"Hunyuan models only (prefer `axes` elsewhere). 'Normal' ($0.375, default) — textured mesh. " +
|
|
513
|
+
"'LowPoly' ($0.45) — retopologised, best for a game asset that will deform or needs clean " +
|
|
514
|
+
"edges; dearer because it is more work, not less. 'Geometry' ($0.225) — untextured geometry " +
|
|
515
|
+
"only, for blockouts and greyboxing.",
|
|
516
|
+
}),
|
|
517
|
+
),
|
|
518
|
+
polygonType: Type.Optional(
|
|
519
|
+
Type.String({
|
|
520
|
+
description:
|
|
521
|
+
"Hunyuan models only (prefer `axes` elsewhere). 'triangle' (default) or 'quadrilateral'. Quads " +
|
|
522
|
+
"deform far better under animation, so choose them for anything that will be rigged; triangles " +
|
|
523
|
+
"are fine for static props.",
|
|
524
|
+
}),
|
|
525
|
+
),
|
|
526
|
+
faceCount: Type.Optional(
|
|
527
|
+
Type.Number({
|
|
528
|
+
description:
|
|
529
|
+
"Target face budget, honoured exactly; the legal range is per-model (media_capabilities " +
|
|
530
|
+
"reports it). On the Hunyuan models it ADDS $0.15 because the provider charges for a custom " +
|
|
531
|
+
"count, and leaving it unset uses their 500k default — measured at 8.6 MB untextured and 64 MB " +
|
|
532
|
+
"textured, against 15 MB for a textured 60k mesh. Free on most other models. Set it for " +
|
|
533
|
+
"anything going into a game.",
|
|
534
|
+
}),
|
|
535
|
+
),
|
|
536
|
+
pbr: Type.Optional(
|
|
537
|
+
Type.Boolean({
|
|
538
|
+
description:
|
|
539
|
+
"Generate PBR materials (base colour, normal, roughness/metallic) instead of a flat texture. " +
|
|
540
|
+
"Essential for anything lit by a modern engine. ADDS $0.15 on the Hunyuan models and is free " +
|
|
541
|
+
"on Tripo and Meshy — which is why the price comes from media_capabilities and not from here. " +
|
|
542
|
+
"Refused on any model generating untextured geometry.",
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
model: Type.Optional(
|
|
546
|
+
Type.String({
|
|
547
|
+
description:
|
|
548
|
+
"Which 3D model to use, e.g. 'fal-ai/hyper3d/rodin/v2.5/fast' for a cheap blockout or " +
|
|
549
|
+
"'meshy/v7/image-to-3d' for a game-ready hero asset. media_capabilities lists every id with " +
|
|
550
|
+
"its price. Leave unset to use the account default.",
|
|
551
|
+
}),
|
|
552
|
+
),
|
|
553
|
+
}),
|
|
554
|
+
async execute(
|
|
555
|
+
_toolCallId: string,
|
|
556
|
+
params: {
|
|
557
|
+
images: string[]; path: string; format?: string; generateType?: string;
|
|
558
|
+
polygonType?: string; faceCount?: number; pbr?: boolean; model?: string;
|
|
559
|
+
axes?: Record<string, string | number | boolean>;
|
|
560
|
+
},
|
|
561
|
+
signal?: AbortSignal,
|
|
562
|
+
_onUpdate?: unknown,
|
|
563
|
+
ctx?: { cwd?: string },
|
|
564
|
+
) {
|
|
565
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
566
|
+
const paths = params.images ?? [];
|
|
567
|
+
if (paths.length === 0) return text("Error: at least one reference image is required — generate one with generate_image first.");
|
|
568
|
+
if (paths.length > MAX_MESH_VIEWS) {
|
|
569
|
+
return text(`Error: at most ${MAX_MESH_VIEWS} reference views (front, back, left, right); got ${paths.length}.`);
|
|
570
|
+
}
|
|
571
|
+
if (!params.path) return text("Error: path is required — say where to save the mesh.");
|
|
572
|
+
|
|
573
|
+
let images: { data: string; mimeType: string }[];
|
|
574
|
+
try {
|
|
575
|
+
images = paths.map((p) => readInputImage(cwd, p));
|
|
576
|
+
} catch (e) {
|
|
577
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
578
|
+
}
|
|
579
|
+
// base64 is 4 bytes per 3, so compare decoded sizes against the decoded cap.
|
|
580
|
+
const totalBytes = images.reduce((sum, img) => sum + Math.floor((img.data.length * 3) / 4), 0);
|
|
581
|
+
if (totalBytes > MAX_MESH_INPUT_TOTAL_BYTES) {
|
|
582
|
+
return text(
|
|
583
|
+
`Error: the reference views total ${(totalBytes / 1048576).toFixed(1)} MB; the limit for one request is ` +
|
|
584
|
+
`${MAX_MESH_INPUT_TOTAL_BYTES / 1048576} MB. Use fewer views, or downscale them first.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const submitted = await callAccount<ModelSubmitResponse>("/api/agent/media/models", {
|
|
589
|
+
method: "POST",
|
|
590
|
+
signal,
|
|
591
|
+
body: {
|
|
592
|
+
images,
|
|
593
|
+
...(params.format ? { format: params.format } : {}),
|
|
594
|
+
// `axes` wins over the named fields server-side, so a model passing both
|
|
595
|
+
// gets what it spelled out per-axis. The named four are sent alongside
|
|
596
|
+
// rather than folded in here: which of them the chosen model actually
|
|
597
|
+
// has is the server's business, and duplicating that judgement in the
|
|
598
|
+
// tool is how the two get to disagree.
|
|
599
|
+
...(params.axes && typeof params.axes === "object" ? { axes: params.axes } : {}),
|
|
600
|
+
...(params.generateType ? { generateType: params.generateType } : {}),
|
|
601
|
+
...(params.polygonType ? { polygonType: params.polygonType } : {}),
|
|
602
|
+
...(params.faceCount != null ? { faceCount: params.faceCount } : {}),
|
|
603
|
+
...(params.pbr ? { pbr: true } : {}),
|
|
604
|
+
...(params.model ? { model: params.model } : {}),
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
if (!submitted.ok) return text(`3D generation failed: ${submitted.message}`);
|
|
608
|
+
const jobId = submitted.data.jobId;
|
|
609
|
+
if (!jobId) return text("3D generation failed: Privateer did not return a job id.");
|
|
610
|
+
|
|
611
|
+
// Poll to completion. The account is charged when the provider delivers, so an
|
|
612
|
+
// abandoned poll still costs money — hence the timeout message names the job id.
|
|
613
|
+
const deadline = Date.now() + MESH_POLL_TIMEOUT_MS;
|
|
614
|
+
const cancelled = () =>
|
|
615
|
+
text(`3D job ${jobId} was submitted but the wait was cancelled. It is still running and will still be billed.`);
|
|
616
|
+
for (;;) {
|
|
617
|
+
if (signal?.aborted) return cancelled();
|
|
618
|
+
await sleep(MESH_POLL_INTERVAL_MS, signal);
|
|
619
|
+
if (signal?.aborted) return cancelled();
|
|
620
|
+
const poll = await callAccount<ModelStatusResponse>(`/api/agent/media/models/${encodeURIComponent(jobId)}`, {
|
|
621
|
+
method: "GET",
|
|
622
|
+
signal,
|
|
623
|
+
});
|
|
624
|
+
if (!poll.ok) return text(`3D job ${jobId} could not be polled: ${poll.message}`);
|
|
625
|
+
|
|
626
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
627
|
+
if (status === "failed") return text(`3D generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
|
|
628
|
+
if (status === "completed") {
|
|
629
|
+
if (!poll.data.data) {
|
|
630
|
+
return text(`3D job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
|
|
631
|
+
}
|
|
632
|
+
// The delivered container wins over the requested one. Writing GLB bytes
|
|
633
|
+
// into a path someone named `.fbx` produces a file that opens nowhere and
|
|
634
|
+
// a bug report about the importer.
|
|
635
|
+
const delivered = String(poll.data.format || "glb").toLowerCase();
|
|
636
|
+
const target = abs(cwd, params.path);
|
|
637
|
+
const asked = extname(target).replace(/^\./, "").toLowerCase();
|
|
638
|
+
const out = `${target.slice(0, target.length - extname(target).length)}.${delivered}`;
|
|
639
|
+
const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
|
|
640
|
+
const note = asked && asked !== delivered
|
|
641
|
+
? `\n(Asked for .${asked}; the provider returned ${delivered.toUpperCase()}, so the file was saved with that extension.)`
|
|
642
|
+
: "";
|
|
643
|
+
return text(`Generated 3D model with ${poll.data.model ?? submitted.data.model ?? "the account 3D model"}: ${summary}${note}`);
|
|
644
|
+
}
|
|
645
|
+
if (Date.now() > deadline) {
|
|
646
|
+
return text(
|
|
647
|
+
`3D job ${jobId} is still ${status || "running"} after ${Math.round(MESH_POLL_TIMEOUT_MS / 60000)} minutes. ` +
|
|
648
|
+
"It will still complete and still be billed; nothing was saved here.",
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
|
|
400
655
|
// ── Audio ────────────────────────────────────────────────────────────────────
|
|
401
656
|
|
|
402
657
|
interface AudioResponse {
|
|
403
658
|
audioBase64?: string;
|
|
404
659
|
mimeType?: string;
|
|
405
660
|
model?: string;
|
|
661
|
+
/** Present only for the models that take a length — an sfx model always does. */
|
|
662
|
+
durationSeconds?: number;
|
|
406
663
|
}
|
|
407
664
|
|
|
408
665
|
export const generateSpeechToolDefinition = {
|
|
@@ -495,14 +752,234 @@ export const generateMusicToolDefinition = {
|
|
|
495
752
|
},
|
|
496
753
|
};
|
|
497
754
|
|
|
755
|
+
// ── Sound effects ────────────────────────────────────────────────────────────
|
|
756
|
+
//
|
|
757
|
+
// The server clamps a length it doesn't like (falClampDuration) rather than refusing it,
|
|
758
|
+
// which is the right behaviour for a UI slider and the wrong one for an agent: a model
|
|
759
|
+
// that asked for 45 seconds of rain and silently got 30 will cut the sequence to a length
|
|
760
|
+
// that doesn't exist. So the bounds are enforced HERE, by name, before the call.
|
|
761
|
+
const SFX_MIN_SECONDS = 1;
|
|
762
|
+
const SFX_MAX_SECONDS = 30;
|
|
763
|
+
|
|
764
|
+
export const generateSfxToolDefinition = {
|
|
765
|
+
name: "generate_sfx",
|
|
766
|
+
label: "Generate Sound Effect",
|
|
767
|
+
description:
|
|
768
|
+
"Generate a single sound effect from a text prompt and save it to disk — an impact, a whoosh, a UI " +
|
|
769
|
+
"click, a room ambience. Cheap (about $0.01-$0.02 a call) and quick. This is for ONE sound, not for " +
|
|
770
|
+
"a score: use generate_music for a bed and generate_speech for narration. Place the result in time " +
|
|
771
|
+
"with video_compose's mix_audio, which is what makes an effect land on the frame it belongs to.\n" +
|
|
772
|
+
"PRIVACY: every effect model is a non-zero-retention provider, so this is gated exactly like image " +
|
|
773
|
+
"and video generation — an account with Require ZDR on is REFUSED until its owner enables non-ZDR " +
|
|
774
|
+
"media. That is a setting only they can change, so do not retry a refusal. (Do not reach for " +
|
|
775
|
+
"generate_music instead on privacy grounds: music has no ZDR gate at all, which is looser, not " +
|
|
776
|
+
"safer.) Check media_capabilities if you need to know whether this account can use it before you plan " +
|
|
777
|
+
"a sequence around a dozen effects.",
|
|
778
|
+
parameters: Type.Object({
|
|
779
|
+
prompt: Type.String({
|
|
780
|
+
description:
|
|
781
|
+
"The sound itself, described as a sound: 'heavy wooden door slamming shut, close mic, reverberant hall'. " +
|
|
782
|
+
"Effect models are tuned for one event, so keep it to one — a scene comes out muddy.",
|
|
783
|
+
}),
|
|
784
|
+
path: Type.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/sfx/door-slam.mp3')." }),
|
|
785
|
+
seconds: Type.Optional(
|
|
786
|
+
Type.Number({
|
|
787
|
+
description: `How long the effect should be, ${SFX_MIN_SECONDS}-${SFX_MAX_SECONDS} whole seconds. Defaults to 5. One of the models is billed by the second, so ask for what you need.`,
|
|
788
|
+
}),
|
|
789
|
+
),
|
|
790
|
+
model: Type.Optional(
|
|
791
|
+
Type.String({
|
|
792
|
+
description:
|
|
793
|
+
"Override the account's effect model. 'fal-ai/elevenlabs/sound-effects/v2' is the most convincing " +
|
|
794
|
+
"foley in the catalog; the default is a cheaper model tuned for single sounds.",
|
|
795
|
+
}),
|
|
796
|
+
),
|
|
797
|
+
}),
|
|
798
|
+
async execute(
|
|
799
|
+
_toolCallId: string,
|
|
800
|
+
params: { prompt: string; path: string; seconds?: number; model?: string },
|
|
801
|
+
signal?: AbortSignal,
|
|
802
|
+
_onUpdate?: unknown,
|
|
803
|
+
ctx?: { cwd?: string },
|
|
804
|
+
) {
|
|
805
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
806
|
+
const prompt = String(params.prompt ?? "").trim();
|
|
807
|
+
if (!prompt) return text("Error: prompt is required.");
|
|
808
|
+
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
809
|
+
|
|
810
|
+
let seconds: number | undefined;
|
|
811
|
+
if (params.seconds != null) {
|
|
812
|
+
seconds = Number(params.seconds);
|
|
813
|
+
if (!Number.isFinite(seconds) || seconds < SFX_MIN_SECONDS || seconds > SFX_MAX_SECONDS) {
|
|
814
|
+
return text(
|
|
815
|
+
`Error: seconds must be between ${SFX_MIN_SECONDS} and ${SFX_MAX_SECONDS} (got ${params.seconds}). ` +
|
|
816
|
+
"For anything longer, generate a few effects and place them with video_compose mix_audio, or use generate_music for a bed.",
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
// Whole seconds for every effect model in the catalog, including the two whose
|
|
820
|
+
// schema would take a float: an effect is aimed at by feel, and 4.6s is a number
|
|
821
|
+
// nobody chose. Rounding here keeps the reported length and the file in agreement.
|
|
822
|
+
seconds = Math.round(seconds);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const r = await callAccount<AudioResponse>("/api/audio/sfx", {
|
|
826
|
+
method: "POST",
|
|
827
|
+
signal,
|
|
828
|
+
body: {
|
|
829
|
+
prompt,
|
|
830
|
+
...(seconds != null ? { duration: seconds } : {}),
|
|
831
|
+
...(params.model ? { sfxModelId: params.model } : {}),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
if (!r.ok) return text(`Sound-effect generation failed: ${r.message}`);
|
|
835
|
+
if (!r.data.audioBase64) return text("Sound-effect generation returned no audio.");
|
|
836
|
+
|
|
837
|
+
const target = abs(cwd, params.path);
|
|
838
|
+
const ext = extname(target) || extForMime(r.data.mimeType ?? "", ".mp3");
|
|
839
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
840
|
+
const length = r.data.durationSeconds ? `${r.data.durationSeconds}s ` : "";
|
|
841
|
+
return text(
|
|
842
|
+
`Generated a ${length}sound effect with ${r.data.model ?? "the account effect model"}: ` +
|
|
843
|
+
writeOut(out, Buffer.from(r.data.audioBase64, "base64")),
|
|
844
|
+
);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
|
|
498
848
|
// ── Capabilities ─────────────────────────────────────────────────────────────
|
|
499
849
|
|
|
500
850
|
interface CapabilitiesResponse {
|
|
501
851
|
image?: { model?: string; blockedByZdr?: boolean; maxPerCall?: number };
|
|
502
852
|
video?: { model?: string; blockedByZdr?: boolean; durations?: number[] | null; aspectRatios?: string[] | null };
|
|
853
|
+
model3d?: {
|
|
854
|
+
model?: string;
|
|
855
|
+
configured?: boolean;
|
|
856
|
+
blockedByZdr?: boolean;
|
|
857
|
+
formats?: string[];
|
|
858
|
+
/** The options THIS model takes. They differ per endpoint — see MeshAxis. */
|
|
859
|
+
axes?: MeshAxis[];
|
|
860
|
+
conflicts?: { whenAxis: string; is: string | number | boolean; forbids: string }[];
|
|
861
|
+
/** Every 3D model the account can ask for. The agent has no picker, so this
|
|
862
|
+
* is the only place model ids other than the default can be discovered. */
|
|
863
|
+
catalog?: { id: string; name?: string; priceUsd?: { min?: number; max?: number } | null }[];
|
|
864
|
+
generateTypes?: string[];
|
|
865
|
+
polygonTypes?: string[];
|
|
866
|
+
faceCount?: { min?: number; max?: number } | null;
|
|
867
|
+
maxViews?: number;
|
|
868
|
+
priceUsd?: { min?: number; max?: number } | null;
|
|
869
|
+
};
|
|
870
|
+
/** Sound effects. `configured` is our deployment missing a fal key; `blockedByZdr` is
|
|
871
|
+
* the account's own privacy setting. They are different refusals with different
|
|
872
|
+
* remedies, and only one of them is the user's to fix. */
|
|
873
|
+
sfx?: { model?: string; configured?: boolean; blockedByZdr?: boolean; maxDurationSeconds?: number };
|
|
503
874
|
privacy?: { requireZdr?: boolean; allowNonZdrMedia?: boolean };
|
|
504
875
|
}
|
|
505
876
|
|
|
877
|
+
/**
|
|
878
|
+
* One option a 3D model takes.
|
|
879
|
+
*
|
|
880
|
+
* The five vendors behind the catalog do not share their levers: Trellis prices
|
|
881
|
+
* on `resolution`, Tripo on a texture ladder, Rodin on an addon flag, Hunyuan on
|
|
882
|
+
* a generate type plus three surcharges. So the model is told what the endpoint
|
|
883
|
+
* it picked actually offers rather than being given four fixed parameters that
|
|
884
|
+
* are right for one row and meaningless for the other nine.
|
|
885
|
+
*/
|
|
886
|
+
interface MeshAxis {
|
|
887
|
+
name: string;
|
|
888
|
+
kind: "enum" | "bool" | "int" | "views";
|
|
889
|
+
/** True where this choice moves the price. */
|
|
890
|
+
priced?: boolean;
|
|
891
|
+
default?: string | number | boolean | null;
|
|
892
|
+
values?: string[] | null;
|
|
893
|
+
min?: number | null;
|
|
894
|
+
max?: number | null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The 3D section of the capability report.
|
|
899
|
+
*
|
|
900
|
+
* Exported and pure so it can be tested against a response shape without an
|
|
901
|
+
* account: the whole point of these lines is that a model reads them and then
|
|
902
|
+
* spends the user's money, and the failure mode — printing options that belong
|
|
903
|
+
* to a different endpoint than the one it will call — costs real money and looks
|
|
904
|
+
* like nothing at all.
|
|
905
|
+
*/
|
|
906
|
+
export function describeModel3d(model3d: CapabilitiesResponse["model3d"]): string[] {
|
|
907
|
+
// 3D has two independent refusals — our deployment missing a provider key, and
|
|
908
|
+
// the user's own privacy setting — and telling someone to change a preference
|
|
909
|
+
// that isn't the problem wastes a turn each way.
|
|
910
|
+
if (model3d?.configured === false) {
|
|
911
|
+
return ["3D model generation: NOT AVAILABLE on this deployment (no provider key). Do not call generate_model."];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const lines = [
|
|
915
|
+
`3D model: ${model3d?.model ?? "unknown"}${model3d?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
916
|
+
` formats: ${model3d?.formats?.length ? model3d.formats.join(", ") : "glb"}` +
|
|
917
|
+
`; up to ${model3d?.maxViews ?? 4} reference view(s)`,
|
|
918
|
+
` cost: $${(model3d?.priceUsd?.min ?? 0).toFixed(2)}-$${(model3d?.priceUsd?.max ?? 0).toFixed(2)} charged per mesh, ` +
|
|
919
|
+
"depending on the options below",
|
|
920
|
+
];
|
|
921
|
+
|
|
922
|
+
// The options of the model being DESCRIBED, not a fixed four. Passed to
|
|
923
|
+
// generate_model as `axes`, spelled exactly as printed here — an axis name this
|
|
924
|
+
// model does not have is refused rather than ignored.
|
|
925
|
+
if (model3d?.axes?.length) {
|
|
926
|
+
lines.push(` options (pass as generate_model's \`axes\`, e.g. {"${model3d.axes[0].name}": ...}):`);
|
|
927
|
+
for (const a of model3d.axes) lines.push(describeAxis(a));
|
|
928
|
+
}
|
|
929
|
+
for (const c of model3d?.conflicts ?? []) {
|
|
930
|
+
lines.push(` NOTE: ${c.forbids} cannot be used when ${c.whenAxis} is ${JSON.stringify(c.is)}.`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Without this the agent knows one id — the account default — and the other
|
|
934
|
+
// nine endpoints may as well not exist. The spread is a factor of seventeen, so
|
|
935
|
+
// which one is picked matters more than any single option on it.
|
|
936
|
+
if (model3d?.catalog?.length) {
|
|
937
|
+
lines.push(" every 3D model available (pass `model` to generate_model, or to this tool to see its options):");
|
|
938
|
+
for (const m of model3d.catalog) {
|
|
939
|
+
const p = m.priceUsd ? ` $${(m.priceUsd.min ?? 0).toFixed(2)}-$${(m.priceUsd.max ?? 0).toFixed(2)}` : "";
|
|
940
|
+
lines.push(` ${m.id}${p}${m.id === model3d.model ? " [described above]" : ""}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return lines;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** One axis as a line the model can read: name, legal values, and whether it costs. */
|
|
947
|
+
function describeAxis(a: MeshAxis): string {
|
|
948
|
+
const cost = a.priced ? " [affects price]" : "";
|
|
949
|
+
if (a.kind === "int") return ` ${a.name}: number ${a.min ?? "?"}-${a.max ?? "?"}, omit to let the provider choose${cost}`;
|
|
950
|
+
if (a.kind === "views") return ` ${a.name}: driven by how many images you pass${cost}`;
|
|
951
|
+
if (a.kind === "bool") return ` ${a.name}: true | false (default ${a.default === true})${cost}`;
|
|
952
|
+
return ` ${a.name}: ${(a.values ?? []).join(" | ")} (default ${String(a.default)})${cost}`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* The sound-effect section of the capability report.
|
|
957
|
+
*
|
|
958
|
+
* Exported and pure for the same reason describeModel3d is: this is where a model
|
|
959
|
+
* finds out whether an effect is worth planning for, and the two ways it can be
|
|
960
|
+
* unavailable have different remedies — one is our deployment's missing key (nobody
|
|
961
|
+
* on this server can generate an effect) and one is the account owner's privacy
|
|
962
|
+
* setting (only they can change it). Collapsing them into "unavailable" sends the
|
|
963
|
+
* user to a preference screen that isn't the problem.
|
|
964
|
+
*/
|
|
965
|
+
export function describeSfx(sfx: CapabilitiesResponse["sfx"]): string[] {
|
|
966
|
+
if (sfx?.configured === false) {
|
|
967
|
+
return ["Sound effects: NOT AVAILABLE on this deployment (no provider key). Do not call generate_sfx."];
|
|
968
|
+
}
|
|
969
|
+
if (sfx?.blockedByZdr) {
|
|
970
|
+
return [
|
|
971
|
+
`Sound effects: ${sfx.model ?? "unknown"} [BLOCKED by this account's ZDR setting]`,
|
|
972
|
+
" Every effect model is non-ZDR, so this account cannot generate one until its owner enables " +
|
|
973
|
+
"non-ZDR media (Settings → Privacy). Do not substitute generate_music for an effect — it is " +
|
|
974
|
+
"ungated, not more private.",
|
|
975
|
+
];
|
|
976
|
+
}
|
|
977
|
+
return [
|
|
978
|
+
`Sound effects: ${sfx?.model ?? "unknown"}`,
|
|
979
|
+
` up to ${sfx?.maxDurationSeconds ?? SFX_MAX_SECONDS}s per effect, one sound per call, ~$0.01-$0.02 each`,
|
|
980
|
+
];
|
|
981
|
+
}
|
|
982
|
+
|
|
506
983
|
export const mediaCapabilitiesToolDefinition = {
|
|
507
984
|
name: "media_capabilities",
|
|
508
985
|
label: "Media Capabilities",
|
|
@@ -511,44 +988,72 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
511
988
|
"resolves to, the clip lengths and aspect ratios that video model accepts, and whether the " +
|
|
512
989
|
"account's privacy settings currently block media generation. Free and instant. Call it before " +
|
|
513
990
|
"planning a multi-clip video so you pick a legal clip length instead of discovering it through a " +
|
|
514
|
-
"rejected — or worse, billed — call
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
991
|
+
"rejected — or worse, billed — call.\n" +
|
|
992
|
+
"It is also the ONLY way to find out which 3D models exist and what options each one takes: they " +
|
|
993
|
+
"range from $0.14 to $2.41 a mesh and no two take the same options, so call this with `model` set " +
|
|
994
|
+
"to the id you are considering BEFORE generate_model, or you will pay the default model's price " +
|
|
995
|
+
"for a job a cheaper one could have done.",
|
|
996
|
+
parameters: Type.Object({
|
|
997
|
+
model: Type.Optional(
|
|
998
|
+
Type.String({
|
|
999
|
+
description:
|
|
1000
|
+
"A 3D model id to describe instead of the account default (e.g. 'fal-ai/trellis-2'). " +
|
|
1001
|
+
"The response's 3D catalog lists every legal id; every field reported — the options, " +
|
|
1002
|
+
"their legal values and the price — is per-model.",
|
|
1003
|
+
}),
|
|
1004
|
+
),
|
|
1005
|
+
}),
|
|
1006
|
+
async execute(_toolCallId: string, params: { model?: string }, signal?: AbortSignal) {
|
|
1007
|
+
const query = params?.model ? `?model=${encodeURIComponent(params.model)}` : "";
|
|
1008
|
+
const r = await callAccount<CapabilitiesResponse>(`/api/agent/media/capabilities${query}`, { method: "GET", signal });
|
|
518
1009
|
if (!r.ok) return text(`Could not read media capabilities: ${r.message}`);
|
|
519
1010
|
|
|
520
|
-
const { image, video, privacy } = r.data;
|
|
1011
|
+
const { image, video, model3d, sfx, privacy } = r.data;
|
|
521
1012
|
const lines = [
|
|
522
1013
|
`Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
523
1014
|
` up to ${image?.maxPerCall ?? 1} image(s) per call`,
|
|
524
1015
|
`Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
525
1016
|
` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
|
|
526
1017
|
` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`,
|
|
527
|
-
`Privacy: requireZdr=${privacy?.requireZdr ?? "?"}, allowNonZdrMedia=${privacy?.allowNonZdrMedia ?? "?"}`,
|
|
528
1018
|
];
|
|
529
|
-
|
|
1019
|
+
|
|
1020
|
+
lines.push(...describeModel3d(model3d));
|
|
1021
|
+
lines.push(...describeSfx(sfx));
|
|
1022
|
+
|
|
1023
|
+
lines.push(`Privacy: requireZdr=${privacy?.requireZdr ?? "?"}, allowNonZdrMedia=${privacy?.allowNonZdrMedia ?? "?"}`);
|
|
1024
|
+
if (image?.blockedByZdr || video?.blockedByZdr || model3d?.blockedByZdr || sfx?.blockedByZdr) {
|
|
530
1025
|
lines.push(
|
|
531
1026
|
"A [BLOCKED] model means the account requires Zero Data Retention and that model has no ZDR endpoint. " +
|
|
532
1027
|
"Only the account owner can change it (Settings → Privacy); do not keep retrying.",
|
|
533
1028
|
);
|
|
534
1029
|
}
|
|
535
|
-
lines.push(
|
|
1030
|
+
lines.push(
|
|
1031
|
+
"Speech and music are always available; sound effects are not (see above). Speech runs confidentially, " +
|
|
1032
|
+
"music has no ZDR gate at all, and effects are gated like image and video — so a blocked effect must " +
|
|
1033
|
+
"never be answered with music.",
|
|
1034
|
+
);
|
|
536
1035
|
return text(lines.join("\n"));
|
|
537
1036
|
},
|
|
538
1037
|
};
|
|
539
1038
|
|
|
540
1039
|
/**
|
|
541
|
-
* Extension factory registering
|
|
542
|
-
* their session from an explicit `extensionFactories` list (harbor, channels,
|
|
543
|
-
* REPL); the interactive TUI picks the same definitions up through
|
|
1040
|
+
* Extension factory registering every account-backed media tool. Used by the surfaces
|
|
1041
|
+
* that build their session from an explicit `extensionFactories` list (harbor, channels,
|
|
1042
|
+
* ACP, the REPL); the interactive TUI picks the same definitions up through
|
|
544
1043
|
* `extensions/privateer-media.ts`, which the launcher passes it as an `-e` argument.
|
|
1044
|
+
*
|
|
1045
|
+
* Keep this in step with MEDIA_TOOL_NAMES — tests/media.test.ts asserts the two agree,
|
|
1046
|
+
* because the allow-lists in harbor/channels/acp are built from the names and a tool
|
|
1047
|
+
* registered but unlisted would be silently ungrantable to an unattended run.
|
|
545
1048
|
*/
|
|
546
1049
|
export function makeMediaTools() {
|
|
547
1050
|
return (pi: { registerTool?: (def: unknown) => void }): void => {
|
|
548
1051
|
pi.registerTool?.(generateImageToolDefinition);
|
|
549
1052
|
pi.registerTool?.(generateVideoToolDefinition);
|
|
1053
|
+
pi.registerTool?.(generateModelToolDefinition);
|
|
550
1054
|
pi.registerTool?.(generateSpeechToolDefinition);
|
|
551
1055
|
pi.registerTool?.(generateMusicToolDefinition);
|
|
1056
|
+
pi.registerTool?.(generateSfxToolDefinition);
|
|
552
1057
|
pi.registerTool?.(mediaCapabilitiesToolDefinition);
|
|
553
1058
|
};
|
|
554
1059
|
}
|