privateer-agent 0.15.4 → 0.15.6
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 +11 -0
- package/bin/boot-bundle.d.mts +2 -0
- package/bin/boot-bundle.mjs +44 -0
- package/bin/fresh-supervisor.d.mts +5 -1
- package/bin/fresh-supervisor.mjs +47 -10
- package/bin/privateer-launch.mjs +27 -3
- package/bin/privateer-subagent.mjs +5 -2
- package/bin/privateer-verify.mjs +12 -0
- 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/dist-boot/boot.mjs +3 -0
- package/dist-boot/c-27JKSMWL.mjs +1460 -0
- package/dist-boot/c-2BMRFV24.mjs +269 -0
- package/dist-boot/c-2CTF6JA3.mjs +166 -0
- package/dist-boot/c-2G3BBHRJ.mjs +84 -0
- package/dist-boot/c-2IDLKXX6.mjs +250 -0
- package/dist-boot/c-2NJD5VYM.mjs +8 -0
- package/dist-boot/c-2S5EEQPW.mjs +1602 -0
- package/dist-boot/c-2WRDCUVI.mjs +25 -0
- package/dist-boot/c-2YN3M256.mjs +14 -0
- package/dist-boot/c-37GM3GMU.mjs +187 -0
- package/dist-boot/c-3STKRSVO.mjs +2480 -0
- package/dist-boot/c-3TPV6TQQ.mjs +2320 -0
- package/dist-boot/c-3UVHLQDJ.mjs +225 -0
- package/dist-boot/c-42PUUZYC.mjs +697 -0
- package/dist-boot/c-4D5UMWVW.mjs +683 -0
- package/dist-boot/c-4ETZRMTA.mjs +46 -0
- package/dist-boot/c-4QT25BD5.mjs +1482 -0
- package/dist-boot/c-4WVR32AP.mjs +2579 -0
- package/dist-boot/c-5EMM567G.mjs +152 -0
- package/dist-boot/c-5FA4CV63.mjs +145 -0
- package/dist-boot/c-5GV3X3RB.mjs +14 -0
- package/dist-boot/c-5JCEPRIY.mjs +52 -0
- package/dist-boot/c-5NDKIIWT.mjs +16 -0
- package/dist-boot/c-5QL3AR26.mjs +4665 -0
- package/dist-boot/c-5XY6MFKN.mjs +5142 -0
- package/dist-boot/c-65UZIZC7.mjs +2897 -0
- package/dist-boot/c-6FUXKHOK.mjs +1969 -0
- package/dist-boot/c-6JHWQE4V.mjs +18242 -0
- package/dist-boot/c-6LOBR4I6.mjs +5280 -0
- package/dist-boot/c-6QHOZXKQ.mjs +14394 -0
- package/dist-boot/c-6ZO2SADQ.mjs +178 -0
- package/dist-boot/c-6ZORVVZP.mjs +1449 -0
- package/dist-boot/c-76QH4UCP.mjs +2441 -0
- package/dist-boot/c-77GO5L5Y.mjs +20982 -0
- package/dist-boot/c-7M23XFTN.mjs +5052 -0
- package/dist-boot/c-7TC367E2.mjs +32 -0
- package/dist-boot/c-7UOG7POX.mjs +1524 -0
- package/dist-boot/c-7VHP3FLN.mjs +376 -0
- package/dist-boot/c-A73JHZP2.mjs +13207 -0
- package/dist-boot/c-ABXULAFL.mjs +71 -0
- package/dist-boot/c-ANDD2AQO.mjs +74 -0
- package/dist-boot/c-AOY77AZL.mjs +1430 -0
- package/dist-boot/c-AXEPZ22I.mjs +108 -0
- package/dist-boot/c-B2Y7VNHU.mjs +427 -0
- package/dist-boot/c-B7VJUH73.mjs +77 -0
- package/dist-boot/c-BFW6CDVL.mjs +24 -0
- package/dist-boot/c-BHNIF2AR.mjs +188 -0
- package/dist-boot/c-BKF2O6BL.mjs +23 -0
- package/dist-boot/c-BOZXFTZN.mjs +8 -0
- package/dist-boot/c-BRNYJWZL.mjs +28 -0
- package/dist-boot/c-C3N4Z2HO.mjs +93 -0
- package/dist-boot/c-C4IV7ZFG.mjs +717 -0
- package/dist-boot/c-C5NK6UYV.mjs +793 -0
- package/dist-boot/c-C67644MB.mjs +1659 -0
- package/dist-boot/c-C6S2YB53.mjs +620 -0
- package/dist-boot/c-CDBEJSBF.mjs +37 -0
- package/dist-boot/c-CG47HBKU.mjs +435 -0
- package/dist-boot/c-CKCTWCT5.mjs +51 -0
- package/dist-boot/c-CN77DCGF.mjs +60 -0
- package/dist-boot/c-CRBXNKFO.mjs +1795 -0
- package/dist-boot/c-CRIHASQ6.mjs +973 -0
- package/dist-boot/c-CU4NGAUO.mjs +3786 -0
- package/dist-boot/c-CWRFLGQU.mjs +1883 -0
- package/dist-boot/c-CYQB2USO.mjs +32 -0
- package/dist-boot/c-DEE67DJO.mjs +379 -0
- package/dist-boot/c-DIFDBYOP.mjs +1258 -0
- package/dist-boot/c-DOSGVXDZ.mjs +34879 -0
- package/dist-boot/c-DR7XQI32.mjs +381 -0
- package/dist-boot/c-DTGPSB46.mjs +14666 -0
- package/dist-boot/c-DTHDNA4P.mjs +320 -0
- package/dist-boot/c-E4Z3Y5B5.mjs +3864 -0
- package/dist-boot/c-E66Z2TI7.mjs +203 -0
- package/dist-boot/c-EBU6RLRV.mjs +1527 -0
- package/dist-boot/c-EBZZNGSF.mjs +432 -0
- package/dist-boot/c-EC3LJ2KF.mjs +1032 -0
- package/dist-boot/c-EQBKXWNX.mjs +15829 -0
- package/dist-boot/c-EUEPIW4S.mjs +574 -0
- package/dist-boot/c-EX4YVWAT.mjs +378 -0
- package/dist-boot/c-EYZGSAJU.mjs +98 -0
- package/dist-boot/c-F75VRKY4.mjs +172 -0
- package/dist-boot/c-FH4NXKHV.mjs +177 -0
- package/dist-boot/c-FHNUCTEM.mjs +140 -0
- package/dist-boot/c-FJ5T42DS.mjs +5349 -0
- package/dist-boot/c-FPCNRL7G.mjs +1728 -0
- package/dist-boot/c-G4DLN7Q4.mjs +8 -0
- package/dist-boot/c-G56GYQFZ.mjs +2507 -0
- package/dist-boot/c-GLIOERDQ.mjs +1200 -0
- package/dist-boot/c-GMJ5HUBM.mjs +14 -0
- package/dist-boot/c-H3HSGMAQ.mjs +461 -0
- package/dist-boot/c-HIKTO6P7.mjs +275 -0
- package/dist-boot/c-I2MCJ34O.mjs +464 -0
- package/dist-boot/c-I3C6LBW4.mjs +765 -0
- package/dist-boot/c-I54OKQHW.mjs +387 -0
- package/dist-boot/c-IFSP3R4D.mjs +80 -0
- package/dist-boot/c-IZK77VB7.mjs +171 -0
- package/dist-boot/c-J3Y25UGE.mjs +54 -0
- package/dist-boot/c-J5IESS7W.mjs +27750 -0
- package/dist-boot/c-JJWZBY7L.mjs +254 -0
- package/dist-boot/c-JKTFXTGF.mjs +463 -0
- package/dist-boot/c-JN57AXQ4.mjs +77 -0
- package/dist-boot/c-KFIO6LUW.mjs +1096 -0
- package/dist-boot/c-KIEMEFWW.mjs +478 -0
- package/dist-boot/c-KIVXWBYD.mjs +28 -0
- package/dist-boot/c-KP6CRIP4.mjs +580 -0
- package/dist-boot/c-L26VOJX4.mjs +11558 -0
- package/dist-boot/c-L5GHWG5G.mjs +5988 -0
- package/dist-boot/c-M7NSQAKK.mjs +353 -0
- package/dist-boot/c-MCEWOPBW.mjs +56 -0
- package/dist-boot/c-MGPIEG7I.mjs +20 -0
- package/dist-boot/c-NREXJV3M.mjs +393 -0
- package/dist-boot/c-O5PP6CTX.mjs +162 -0
- package/dist-boot/c-OBQDHSXM.mjs +1774 -0
- package/dist-boot/c-OLQHHDD4.mjs +21 -0
- package/dist-boot/c-OXW4JQQH.mjs +319 -0
- package/dist-boot/c-P3D7ZUWN.mjs +1288 -0
- package/dist-boot/c-P4PQZVV5.mjs +948 -0
- package/dist-boot/c-P63T3XL2.mjs +26373 -0
- package/dist-boot/c-P6N5DQGZ.mjs +194 -0
- package/dist-boot/c-P7MLKQFC.mjs +58 -0
- package/dist-boot/c-PB3PZJL5.mjs +1276 -0
- package/dist-boot/c-PTN4S6G2.mjs +1259 -0
- package/dist-boot/c-PYCE5J7U.mjs +1083 -0
- package/dist-boot/c-Q4HYCRLB.mjs +19410 -0
- package/dist-boot/c-QUXMETGG.mjs +68 -0
- package/dist-boot/c-RDMJZCOX.mjs +356 -0
- package/dist-boot/c-ROGJK76M.mjs +17 -0
- package/dist-boot/c-RQJJ26T4.mjs +708 -0
- package/dist-boot/c-RSKCUPJF.mjs +7559 -0
- package/dist-boot/c-RVJU4O6P.mjs +37 -0
- package/dist-boot/c-S2RVHR3M.mjs +165 -0
- package/dist-boot/c-SKMK3PEE.mjs +91120 -0
- package/dist-boot/c-TI7SM4EG.mjs +20 -0
- package/dist-boot/c-TILJ6GXD.mjs +1235 -0
- package/dist-boot/c-TT6BCUJD.mjs +114 -0
- package/dist-boot/c-U4ITOXAN.mjs +2107 -0
- package/dist-boot/c-UGERXVO2.mjs +7120 -0
- package/dist-boot/c-UPBH5AXY.mjs +1363 -0
- package/dist-boot/c-US2PUNZL.mjs +597 -0
- package/dist-boot/c-UU4WZPPE.mjs +20 -0
- package/dist-boot/c-UWVIYPQS.mjs +39 -0
- package/dist-boot/c-V4RHQBPQ.mjs +17683 -0
- package/dist-boot/c-V5MRVK47.mjs +351 -0
- package/dist-boot/c-V6CL266R.mjs +1755 -0
- package/dist-boot/c-VJTMFKYE.mjs +14975 -0
- package/dist-boot/c-VJTRUKIB.mjs +773 -0
- package/dist-boot/c-W4IFSHPW.mjs +43 -0
- package/dist-boot/c-WETGB2CF.mjs +113 -0
- package/dist-boot/c-WO3QSZUU.mjs +1337 -0
- package/dist-boot/c-WQLH73TY.mjs +242 -0
- package/dist-boot/c-XL735JW5.mjs +500 -0
- package/dist-boot/c-Y7HADL5B.mjs +1107 -0
- package/dist-boot/c-YGQHSVSK.mjs +41 -0
- package/dist-boot/c-YMP2AMEB.mjs +10350 -0
- package/dist-boot/c-YRDO2WEF.mjs +12012 -0
- package/dist-boot/c-YUSTRATJ.mjs +8 -0
- package/dist-boot/c-Z2262R22.mjs +2697 -0
- package/dist-boot/c-Z4ALQMJC.mjs +709 -0
- package/dist-boot/c-Z5ZUQVUY.mjs +62 -0
- package/dist-boot/c-ZDZUKXF7.mjs +874 -0
- package/dist-boot/c-ZJYLGIC3.mjs +32 -0
- package/dist-boot/c-ZNA5CDMI.mjs +77 -0
- package/dist-boot/c-ZSO6DI3M.mjs +204 -0
- package/dist-boot/c-ZWUVHKMK.mjs +100 -0
- package/dist-boot/c-ZY4MESEW.mjs +832 -0
- package/dist-boot/c-ZZ5REI4L.mjs +329 -0
- package/dist-boot/cli.mjs +51 -0
- package/dist-boot/ext-anthropic-attribution.mjs +207 -0
- package/dist-boot/ext-pi-background-tasks.mjs +7234 -0
- package/dist-boot/ext-pi-subagents.mjs +37 -0
- package/dist-boot/ext-privateer-account.mjs +4426 -0
- package/dist-boot/ext-privateer-brand.mjs +5274 -0
- package/dist-boot/ext-privateer-codemode.mjs +37 -0
- package/dist-boot/ext-privateer-computer.mjs +626 -0
- package/dist-boot/ext-privateer-connect.mjs +1862 -0
- package/dist-boot/ext-privateer-context.mjs +323 -0
- package/dist-boot/ext-privateer-desktop.mjs +92 -0
- package/dist-boot/ext-privateer-final-audit.mjs +48 -0
- package/dist-boot/ext-privateer-fresh.mjs +81 -0
- package/dist-boot/ext-privateer-gate.mjs +5959 -0
- package/dist-boot/ext-privateer-hints.mjs +236 -0
- package/dist-boot/ext-privateer-media.mjs +2626 -0
- package/dist-boot/ext-privateer-models.mjs +3397 -0
- package/dist-boot/ext-privateer-posture.mjs +3006 -0
- package/dist-boot/ext-privateer-privacy.mjs +4578 -0
- package/dist-boot/ext-privateer-recovery.mjs +287 -0
- package/dist-boot/ext-privateer-speak.mjs +4754 -0
- package/dist-boot/ext-privateer-terminals.mjs +725 -0
- package/dist-boot/ext-privateer-tools.mjs +528 -0
- package/dist-boot/ext-privateer-truncation.mjs +74 -0
- package/dist-boot/ext-privateer-update.mjs +210 -0
- package/dist-boot/ext-privateer-vision.mjs +148 -0
- package/dist-boot/ext-privateer-web.mjs +543 -0
- package/dist-boot/ext-rpiv-ask-user-question.mjs +710 -0
- package/dist-boot/manifest.json +9 -0
- package/extensions/privateer-brand.ts +3 -1
- package/extensions/privateer-context.ts +4 -2
- package/extensions/privateer-final-audit.ts +17 -0
- package/extensions/privateer-gate.ts +55 -9
- package/extensions/privateer-recovery.ts +175 -0
- package/extensions/privateer-web.ts +4 -4
- package/package.json +3 -1
- package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +199 -2
- package/patches/pi-background-tasks+2.6.10.patch +91 -7
- package/src/cli/chat.ts +7 -1
- package/src/config/moatManifest.json +2 -0
- package/src/config/whatsNew.json +4 -4
- package/src/context.ts +33 -5
- package/src/engine/finalAudit.ts +38 -0
- package/src/engine/stallWatchdog.ts +8 -1
- package/src/providers/account.ts +34 -3
- 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
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
import{createRequire as __pvCR}from"node:module";import{fileURLToPath as __pvF}from"node:url";import{dirname as __pvD}from"node:path";const require=__pvCR(import.meta.url);const __pvUrl=(r)=>new URL(r,import.meta.url).href;const __pvFile=(r)=>__pvF(new URL(r,import.meta.url));
|
|
2
|
+
import {
|
|
3
|
+
effectiveMediaModel
|
|
4
|
+
} from "./c-5JCEPRIY.mjs";
|
|
5
|
+
import "./c-RVJU4O6P.mjs";
|
|
6
|
+
import {
|
|
7
|
+
apiRequest
|
|
8
|
+
} from "./c-EC3LJ2KF.mjs";
|
|
9
|
+
import {
|
|
10
|
+
globalDir
|
|
11
|
+
} from "./c-YGQHSVSK.mjs";
|
|
12
|
+
import {
|
|
13
|
+
require_undici
|
|
14
|
+
} from "./c-P63T3XL2.mjs";
|
|
15
|
+
import "./c-4WVR32AP.mjs";
|
|
16
|
+
import {
|
|
17
|
+
typebox_exports
|
|
18
|
+
} from "./c-RQJJ26T4.mjs";
|
|
19
|
+
import "./c-L5GHWG5G.mjs";
|
|
20
|
+
import {
|
|
21
|
+
__name,
|
|
22
|
+
__toESM
|
|
23
|
+
} from "./c-CN77DCGF.mjs";
|
|
24
|
+
|
|
25
|
+
// src/tools/media.ts?ext=privateer-gate
|
|
26
|
+
var import_undici = __toESM(require_undici());
|
|
27
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
28
|
+
import { createDecipheriv, randomBytes } from "node:crypto";
|
|
29
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
var MEDIA_TOOL_NAMES = [
|
|
32
|
+
"generate_image",
|
|
33
|
+
"generate_video",
|
|
34
|
+
"generate_model",
|
|
35
|
+
"generate_sprite",
|
|
36
|
+
"generate_speech",
|
|
37
|
+
"generate_music",
|
|
38
|
+
"generate_sfx",
|
|
39
|
+
"media_capabilities"
|
|
40
|
+
];
|
|
41
|
+
var VIDEO_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_VIDEO_TIMEOUT_MS) || 25 * 6e4;
|
|
42
|
+
var VIDEO_POLL_INTERVAL_MS = 5e3;
|
|
43
|
+
var MESH_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_MESH_TIMEOUT_MS) || 10 * 6e4;
|
|
44
|
+
var SPRITE_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_SPRITE_TIMEOUT_MS) || 25 * 6e4;
|
|
45
|
+
var SPRITE_POLL_INTERVAL_MS = 6e3;
|
|
46
|
+
var MESH_POLL_INTERVAL_MS = 5e3;
|
|
47
|
+
var MAX_MESH_VIEWS = 4;
|
|
48
|
+
var MAX_MESH_INPUT_TOTAL_BYTES = 12 * 1024 * 1024;
|
|
49
|
+
var MAX_INPUT_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
50
|
+
function text(t) {
|
|
51
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
52
|
+
}
|
|
53
|
+
__name(text, "text");
|
|
54
|
+
var IMAGE_MIME = {
|
|
55
|
+
".png": "image/png",
|
|
56
|
+
".jpg": "image/jpeg",
|
|
57
|
+
".jpeg": "image/jpeg",
|
|
58
|
+
".webp": "image/webp",
|
|
59
|
+
".gif": "image/gif"
|
|
60
|
+
};
|
|
61
|
+
function mimeForImage(path) {
|
|
62
|
+
return IMAGE_MIME[extname(path).toLowerCase()] ?? "image/jpeg";
|
|
63
|
+
}
|
|
64
|
+
__name(mimeForImage, "mimeForImage");
|
|
65
|
+
function extForMime(mimeType, fallback) {
|
|
66
|
+
const m = (mimeType || "").toLowerCase();
|
|
67
|
+
if (m.includes("png")) return ".png";
|
|
68
|
+
if (m.includes("jpeg") || m.includes("jpg")) return ".jpg";
|
|
69
|
+
if (m.includes("webp")) return ".webp";
|
|
70
|
+
if (m.includes("mp4")) return ".mp4";
|
|
71
|
+
if (m.includes("webm")) return ".webm";
|
|
72
|
+
if (m.includes("mpeg") || m.includes("mp3")) return ".mp3";
|
|
73
|
+
if (m.includes("wav")) return ".wav";
|
|
74
|
+
if (m.includes("ogg")) return ".ogg";
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
__name(extForMime, "extForMime");
|
|
78
|
+
function abs(cwd, p) {
|
|
79
|
+
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
80
|
+
}
|
|
81
|
+
__name(abs, "abs");
|
|
82
|
+
function writeOut(target, bytes) {
|
|
83
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
84
|
+
writeFileSync(target, bytes);
|
|
85
|
+
const kb = bytes.length / 1024;
|
|
86
|
+
return `${target} (${kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${Math.round(kb)} KB`})`;
|
|
87
|
+
}
|
|
88
|
+
__name(writeOut, "writeOut");
|
|
89
|
+
function readInputImage(cwd, p) {
|
|
90
|
+
const target = abs(cwd, p);
|
|
91
|
+
if (!existsSync(target)) throw new Error(`input image not found: ${p}`);
|
|
92
|
+
const stat = statSync(target);
|
|
93
|
+
if (stat.isDirectory()) throw new Error(`${p} is a directory, not an image`);
|
|
94
|
+
if (stat.size === 0) throw new Error(`${p} is empty`);
|
|
95
|
+
if (stat.size > MAX_INPUT_IMAGE_BYTES) {
|
|
96
|
+
throw new Error(`${p} is ${(stat.size / 1048576).toFixed(1)} MB; the limit for an input image is ${MAX_INPUT_IMAGE_BYTES / 1048576} MB`);
|
|
97
|
+
}
|
|
98
|
+
return { data: readFileSync(target).toString("base64"), mimeType: mimeForImage(target) };
|
|
99
|
+
}
|
|
100
|
+
__name(readInputImage, "readInputImage");
|
|
101
|
+
var MEDIA_HTTP_TIMEOUT_MS = 20 * 6e4;
|
|
102
|
+
var _mediaDispatcher = null;
|
|
103
|
+
function mediaDispatcher() {
|
|
104
|
+
return _mediaDispatcher ??= new import_undici.Agent({
|
|
105
|
+
headersTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
106
|
+
bodyTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
107
|
+
connect: { autoSelectFamilyAttemptTimeout: 2e3 }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
__name(mediaDispatcher, "mediaDispatcher");
|
|
111
|
+
function describeTransportFailure(e) {
|
|
112
|
+
if (e instanceof Error && e.name === "AbortError") return "the request was cancelled";
|
|
113
|
+
const top = e instanceof Error ? e.message : String(e);
|
|
114
|
+
const cause = e?.cause;
|
|
115
|
+
const code = String(cause?.code ?? e?.code ?? "");
|
|
116
|
+
const causeMsg = cause instanceof Error ? cause.message : "";
|
|
117
|
+
if (/^UND_ERR_(HEADERS|BODY)_TIMEOUT$/.test(code)) {
|
|
118
|
+
return `Privateer did not answer within ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 6e4)} minutes (${code}). The generation may still have finished and been billed on the server, so check the output path before retrying, and retry once rather than in a loop`;
|
|
119
|
+
}
|
|
120
|
+
const detail = [code, causeMsg].filter(Boolean).join(": ");
|
|
121
|
+
return `could not reach Privateer: ${top}${detail && !top.includes(detail) ? ` (${detail})` : ""}`;
|
|
122
|
+
}
|
|
123
|
+
__name(describeTransportFailure, "describeTransportFailure");
|
|
124
|
+
async function callAccount(path, init) {
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await apiRequest(path, {
|
|
128
|
+
// npm undici's Agent vs the undici-types Dispatcher in RequestInit: same runtime
|
|
129
|
+
// contract (verified against both Node's bundled fetch and Pi's undici 8).
|
|
130
|
+
...{ dispatcher: mediaDispatcher() },
|
|
131
|
+
method: init.method,
|
|
132
|
+
...init.body === void 0 ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(init.body) },
|
|
133
|
+
...init.signal ? { signal: init.signal } : {}
|
|
134
|
+
});
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { ok: false, message: describeTransportFailure(e) };
|
|
137
|
+
}
|
|
138
|
+
return interpretAccountResponse(res);
|
|
139
|
+
}
|
|
140
|
+
__name(callAccount, "callAccount");
|
|
141
|
+
async function interpretAccountResponse(res) {
|
|
142
|
+
if (res.ok || res.status === 202) {
|
|
143
|
+
try {
|
|
144
|
+
return { ok: true, data: await res.json() };
|
|
145
|
+
} catch {
|
|
146
|
+
return { ok: false, message: "Privateer returned a malformed response" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let code = "";
|
|
150
|
+
let serverMessage = "";
|
|
151
|
+
try {
|
|
152
|
+
const err = await res.json();
|
|
153
|
+
code = String(err?.code ?? err?.error?.code ?? "");
|
|
154
|
+
serverMessage = String(err?.message ?? err?.error?.message ?? "");
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
if (code === "ZDR_MEDIA_BLOCKED") {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
status: res.status,
|
|
161
|
+
message: (serverMessage || "this account requires Zero Data Retention and the media model has no ZDR endpoint") + " \u2014 this is a privacy setting only the account owner can change (Settings \u2192 Privacy), so do not retry."
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (code === "ZDR_KEY_UNAVAILABLE") {
|
|
165
|
+
return { ok: false, status: res.status, message: serverMessage || "no zero-retention provider key is available right now \u2014 try again later" };
|
|
166
|
+
}
|
|
167
|
+
if (/DAILY_CAP|LIMIT_REACHED/i.test(code) || res.status === 429) {
|
|
168
|
+
return { ok: false, status: res.status, message: serverMessage || "the account's daily media allowance is used up \u2014 it resets tomorrow" };
|
|
169
|
+
}
|
|
170
|
+
if (res.status === 402 || /INSUFFICIENT|QUOTA|TOP_?UP/i.test(code)) {
|
|
171
|
+
return { ok: false, status: res.status, message: serverMessage || "the account is out of credit for media generation \u2014 top up or upgrade to continue" };
|
|
172
|
+
}
|
|
173
|
+
if (res.status === 401) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
status: res.status,
|
|
177
|
+
message: serverMessage || "Privateer refused this agent's session (401) \u2014 running the command again opens a fresh one; if it keeps failing, check `privateer auth status`"
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (res.status === 403) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
status: res.status,
|
|
184
|
+
message: serverMessage || "Privateer refused this media request (403) \u2014 the plan may not include it; this is not a sign-in problem"
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (res.status === 400 || res.status === 413) {
|
|
188
|
+
return { ok: false, status: res.status, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
|
|
189
|
+
}
|
|
190
|
+
if (res.status === 503 || res.status === 504) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
status: res.status,
|
|
194
|
+
message: `${serverMessage || "that media service is temporarily unavailable"} \u2014 this is on Privateer's side, not the prompt's; try again in a few minutes`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: false, status: res.status, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
|
|
198
|
+
}
|
|
199
|
+
__name(interpretAccountResponse, "interpretAccountResponse");
|
|
200
|
+
var DEFERRED_SYNC_PATH = {
|
|
201
|
+
image: "/api/agent/media/images",
|
|
202
|
+
speech: "/api/audio/speech",
|
|
203
|
+
music: "/api/audio/music",
|
|
204
|
+
sfx: "/api/audio/sfx"
|
|
205
|
+
};
|
|
206
|
+
var DEFERRED_TOOL = {
|
|
207
|
+
image: "generate_image",
|
|
208
|
+
speech: "generate_speech",
|
|
209
|
+
music: "generate_music",
|
|
210
|
+
sfx: "generate_sfx"
|
|
211
|
+
};
|
|
212
|
+
var DEFERRED_POLL_MS = 3e3;
|
|
213
|
+
var DEFERRED_KEY_TTL_MS = 2 * 60 * 6e4;
|
|
214
|
+
function deferredKeyDir() {
|
|
215
|
+
return join(globalDir(), "media-jobs");
|
|
216
|
+
}
|
|
217
|
+
__name(deferredKeyDir, "deferredKeyDir");
|
|
218
|
+
function saveDeferredKey(jobId, kind, key) {
|
|
219
|
+
const dir = deferredKeyDir();
|
|
220
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
221
|
+
try {
|
|
222
|
+
for (const f of readdirSync(dir)) {
|
|
223
|
+
const p = join(dir, f);
|
|
224
|
+
try {
|
|
225
|
+
const { createdAt } = JSON.parse(readFileSync(p, "utf8"));
|
|
226
|
+
if (!createdAt || Date.now() - createdAt > DEFERRED_KEY_TTL_MS) unlinkSync(p);
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
writeFileSync(join(dir, `${jobId}.json`), JSON.stringify({ kind, key: key.toString("base64"), createdAt: Date.now() }), { mode: 384 });
|
|
233
|
+
}
|
|
234
|
+
__name(saveDeferredKey, "saveDeferredKey");
|
|
235
|
+
function loadDeferredKey(jobId) {
|
|
236
|
+
if (!/^amj_[0-9a-f]+$/.test(jobId)) return null;
|
|
237
|
+
try {
|
|
238
|
+
const raw = JSON.parse(readFileSync(join(deferredKeyDir(), `${jobId}.json`), "utf8"));
|
|
239
|
+
return { kind: raw.kind, key: Buffer.from(raw.key, "base64") };
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
__name(loadDeferredKey, "loadDeferredKey");
|
|
245
|
+
function forgetDeferredKey(jobId) {
|
|
246
|
+
try {
|
|
247
|
+
unlinkSync(join(deferredKeyDir(), `${jobId}.json`));
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
__name(forgetDeferredKey, "forgetDeferredKey");
|
|
252
|
+
function openSealedResult(key, sealedB64) {
|
|
253
|
+
const buf = Buffer.from(sealedB64, "base64");
|
|
254
|
+
const d = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
|
|
255
|
+
d.setAuthTag(buf.subarray(12, 28));
|
|
256
|
+
return JSON.parse(Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString("utf8"));
|
|
257
|
+
}
|
|
258
|
+
__name(openSealedResult, "openSealedResult");
|
|
259
|
+
function deferredResumeHint(kind, jobId) {
|
|
260
|
+
return `Collect it with ${DEFERRED_TOOL[kind]} { resumeJobId: "${jobId}", path: <same path> } \u2014 that waits on this same job and bills nothing further.`;
|
|
261
|
+
}
|
|
262
|
+
__name(deferredResumeHint, "deferredResumeHint");
|
|
263
|
+
async function callDeferred(kind, body, opts) {
|
|
264
|
+
let jobId = String(opts.resumeJobId ?? "").trim();
|
|
265
|
+
let key;
|
|
266
|
+
if (jobId) {
|
|
267
|
+
const held = loadDeferredKey(jobId);
|
|
268
|
+
if (!held) {
|
|
269
|
+
return { ok: false, message: `no key for job ${jobId} on this machine \u2014 it was started elsewhere, already collected, or is over two hours old. Its result cannot be opened here.` };
|
|
270
|
+
}
|
|
271
|
+
if (held.kind !== kind) {
|
|
272
|
+
return { ok: false, message: `job ${jobId} is a ${held.kind} job; collect it with ${DEFERRED_TOOL[held.kind]}.` };
|
|
273
|
+
}
|
|
274
|
+
key = held.key;
|
|
275
|
+
} else {
|
|
276
|
+
key = randomBytes(32);
|
|
277
|
+
const submitted = await callAccount("/api/agent/media/jobs", {
|
|
278
|
+
method: "POST",
|
|
279
|
+
signal: opts.signal,
|
|
280
|
+
body: { kind, sealKey: key.toString("base64"), request: body }
|
|
281
|
+
});
|
|
282
|
+
if (!submitted.ok) {
|
|
283
|
+
if (submitted.status === 404 || submitted.status === 405) {
|
|
284
|
+
return callAccount(DEFERRED_SYNC_PATH[kind], { method: "POST", body, signal: opts.signal });
|
|
285
|
+
}
|
|
286
|
+
return submitted;
|
|
287
|
+
}
|
|
288
|
+
jobId = String(submitted.data.jobId ?? "");
|
|
289
|
+
if (!jobId) return { ok: false, message: "Privateer accepted the job but returned no job id" };
|
|
290
|
+
saveDeferredKey(jobId, kind, key);
|
|
291
|
+
if (opts.wait === false) return { ok: "started", jobId };
|
|
292
|
+
}
|
|
293
|
+
const hint = deferredResumeHint(kind, jobId);
|
|
294
|
+
const deadline = Date.now() + MEDIA_HTTP_TIMEOUT_MS;
|
|
295
|
+
let failures = 0;
|
|
296
|
+
while (Date.now() < deadline) {
|
|
297
|
+
if (opts.signal?.aborted) {
|
|
298
|
+
return { ok: false, message: `stopped waiting \u2014 job ${jobId} keeps running on the server. ${hint}` };
|
|
299
|
+
}
|
|
300
|
+
const poll = await callAccount(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "GET", signal: opts.signal });
|
|
301
|
+
if (!poll.ok) {
|
|
302
|
+
if (spritePollWorthRetrying(poll.status) && ++failures < SPRITE_POLL_FAILURE_BUDGET) {
|
|
303
|
+
await sleep(DEFERRED_POLL_MS * 2, opts.signal);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (poll.status === 404) forgetDeferredKey(jobId);
|
|
307
|
+
return { ok: false, status: poll.status, message: `job ${jobId} could not be collected: ${poll.message}${poll.status === 404 ? "" : `. ${hint}`}` };
|
|
308
|
+
}
|
|
309
|
+
failures = 0;
|
|
310
|
+
const status = String(poll.data.status ?? "");
|
|
311
|
+
if (status === "failed") {
|
|
312
|
+
forgetDeferredKey(jobId);
|
|
313
|
+
return { ok: false, message: poll.data.message ?? `job ${jobId} failed on the server` };
|
|
314
|
+
}
|
|
315
|
+
if (status === "done" && poll.data.sealed) {
|
|
316
|
+
let opened;
|
|
317
|
+
try {
|
|
318
|
+
opened = openSealedResult(key, poll.data.sealed);
|
|
319
|
+
} catch {
|
|
320
|
+
return { ok: false, message: `job ${jobId}'s result could not be opened with this machine's key` };
|
|
321
|
+
}
|
|
322
|
+
forgetDeferredKey(jobId);
|
|
323
|
+
void callAccount(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE" }).catch(() => {
|
|
324
|
+
});
|
|
325
|
+
return interpretAccountResponse(new Response(opened.body, { status: opened.status, headers: { "Content-Type": "application/json" } }));
|
|
326
|
+
}
|
|
327
|
+
await sleep(DEFERRED_POLL_MS, opts.signal);
|
|
328
|
+
}
|
|
329
|
+
return { ok: false, message: `job ${jobId} is still running after ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 6e4)} minutes. ${hint}` };
|
|
330
|
+
}
|
|
331
|
+
__name(callDeferred, "callDeferred");
|
|
332
|
+
var deferredParams = {
|
|
333
|
+
resumeJobId: typebox_exports.Optional(typebox_exports.String({
|
|
334
|
+
description: "Collect a job started earlier (with wait:false, or one that timed out) instead of starting a new one. Nothing is generated and nothing is billed: it only waits and saves. Other generation fields are ignored; `path` still says where to save."
|
|
335
|
+
})),
|
|
336
|
+
wait: typebox_exports.Optional(typebox_exports.Boolean({
|
|
337
|
+
description: "Default true. Set false to start the generation and return its job id immediately, so you can start several at once and collect each later with resumeJobId. The job runs on Privateer's servers either way."
|
|
338
|
+
}))
|
|
339
|
+
};
|
|
340
|
+
function startedText(kind, jobId) {
|
|
341
|
+
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)}`;
|
|
342
|
+
}
|
|
343
|
+
__name(startedText, "startedText");
|
|
344
|
+
var generateImageToolDefinition = {
|
|
345
|
+
name: "generate_image",
|
|
346
|
+
label: "Generate Image",
|
|
347
|
+
description: "Generate an image from a text prompt and save it to disk. Optionally pass `images` \u2014 paths to images already on disk \u2014 to EDIT or COMPOSE instead: one input means 'change this picture per the prompt', several means 'combine these into one'. Billed to the user's Privateer account and subject to its privacy settings; the prompt and any input images pass through Privateer's servers 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 \u2014 the only readable copy is the file you name. Use the resulting path as the first frame of generate_video, or as slideshow material for video_compose.",
|
|
348
|
+
parameters: typebox_exports.Object({
|
|
349
|
+
prompt: typebox_exports.String({ description: "What the image should show. Be specific about subject, style, lighting and framing." }),
|
|
350
|
+
path: typebox_exports.String({ description: "Where to write the image, relative to cwd or absolute (e.g. 'frames/opening.png')." }),
|
|
351
|
+
images: typebox_exports.Optional(
|
|
352
|
+
typebox_exports.Array(typebox_exports.String(), {
|
|
353
|
+
description: "Paths to existing images to edit or combine. One = edit that image; several = use the first as the base and incorporate the rest. Omit to generate from the prompt alone."
|
|
354
|
+
})
|
|
355
|
+
),
|
|
356
|
+
count: typebox_exports.Optional(typebox_exports.Number({ description: "How many variations to produce, 1-4. Above 1, files are suffixed -1, -2, \u2026 Defaults to 1." })),
|
|
357
|
+
aspectRatio: typebox_exports.Optional(typebox_exports.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
|
|
358
|
+
size: typebox_exports.Optional(typebox_exports.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
|
|
359
|
+
model: typebox_exports.Optional(typebox_exports.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." })),
|
|
360
|
+
...deferredParams
|
|
361
|
+
}),
|
|
362
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
363
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
364
|
+
const prompt = String(params.prompt ?? "").trim();
|
|
365
|
+
const resuming = Boolean(String(params.resumeJobId ?? "").trim());
|
|
366
|
+
if (!prompt && !resuming) return text("Error: prompt is required.");
|
|
367
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the image.");
|
|
368
|
+
let inputs;
|
|
369
|
+
try {
|
|
370
|
+
inputs = resuming ? [] : (params.images ?? []).map((p) => readInputImage(cwd, p));
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
373
|
+
}
|
|
374
|
+
const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
|
|
375
|
+
const model = effectiveMediaModel("image", params.model);
|
|
376
|
+
const r = await callDeferred("image", {
|
|
377
|
+
prompt,
|
|
378
|
+
n: count,
|
|
379
|
+
...inputs.length ? { images: inputs } : {},
|
|
380
|
+
...params.aspectRatio ? { aspectRatio: params.aspectRatio } : {},
|
|
381
|
+
...params.size ? { imageSize: params.size } : {},
|
|
382
|
+
...model ? { model } : {}
|
|
383
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
384
|
+
if (r.ok === "started") return text(startedText("image", r.jobId));
|
|
385
|
+
if (!r.ok) return text(`Image generation failed: ${r.message}`);
|
|
386
|
+
const images = r.data.images ?? [];
|
|
387
|
+
if (images.length === 0) return text("Image generation returned no images.");
|
|
388
|
+
const target = abs(cwd, params.path);
|
|
389
|
+
const ext = extname(target) || extForMime(images[0].mimeType ?? "", ".png");
|
|
390
|
+
const stem = target.slice(0, target.length - extname(target).length);
|
|
391
|
+
const written = [];
|
|
392
|
+
for (const [i, img] of images.entries()) {
|
|
393
|
+
if (!img.data) continue;
|
|
394
|
+
const out = images.length === 1 ? `${stem}${ext}` : `${stem}-${i + 1}${ext}`;
|
|
395
|
+
written.push(writeOut(out, Buffer.from(img.data, "base64")));
|
|
396
|
+
}
|
|
397
|
+
if (written.length === 0) return text("Image generation returned no usable image data.");
|
|
398
|
+
const verb = resuming ? "Collected" : inputs.length ? inputs.length === 1 ? "Edited" : "Composed" : "Generated";
|
|
399
|
+
return text(`${verb} ${written.length} image${written.length === 1 ? "" : "s"} with ${r.data.model ?? "the account image model"}:
|
|
400
|
+
${written.map((w) => ` ${w}`).join("\n")}`);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var generateVideoToolDefinition = {
|
|
404
|
+
name: "generate_video",
|
|
405
|
+
label: "Generate Video",
|
|
406
|
+
description: "Generate a video clip from a text prompt and save it to disk. Give `firstFrame` (a path to an image) to animate an existing picture, and `lastFrame` as well to interpolate between two stills \u2014 that pairing is how you keep several clips visually continuous: end one clip on a frame you extracted with video_compose, then start the next from it. Generation takes minutes and this tool waits for it. Expensive (roughly $0.10-$1 a clip) and billed to the user's Privateer account, so plan the shot before calling. Clip lengths and aspect ratios are model-specific \u2014 check media_capabilities first if unsure. Stitch the finished clips with video_compose.\nIf a call comes back saying the job is still running, DO NOT call this again with the same prompt \u2014 that bills a second generation. Call it with `resumeJobId` set to the job id it reported (and the same `path`) to keep waiting on the clip the account has already paid for.",
|
|
407
|
+
parameters: typebox_exports.Object({
|
|
408
|
+
prompt: typebox_exports.String({ description: "What happens in the shot: subject, action, camera move, style." }),
|
|
409
|
+
path: typebox_exports.String({ description: "Where to write the video, relative to cwd or absolute (e.g. 'clips/01-opening.mp4')." }),
|
|
410
|
+
resumeJobId: typebox_exports.Optional(typebox_exports.String({
|
|
411
|
+
description: "Resume waiting on a job already submitted (from a previous call that timed out). Nothing is generated and nothing is billed: it only polls and saves. `prompt` is ignored."
|
|
412
|
+
})),
|
|
413
|
+
firstFrame: typebox_exports.Optional(typebox_exports.String({ description: "Path to an image to use as the opening frame (image-to-video)." })),
|
|
414
|
+
lastFrame: typebox_exports.Optional(typebox_exports.String({ description: "Path to an image to use as the closing frame. Requires firstFrame." })),
|
|
415
|
+
seconds: typebox_exports.Optional(typebox_exports.Number({ description: "Clip length in seconds. Only certain values are legal per model \u2014 see media_capabilities." })),
|
|
416
|
+
aspectRatio: typebox_exports.Optional(typebox_exports.String({ description: "Aspect ratio, e.g. '16:9', '9:16'. Model-specific." })),
|
|
417
|
+
resolution: typebox_exports.Optional(typebox_exports.String({ description: "Resolution, e.g. '720p' or '1080p'." })),
|
|
418
|
+
audio: typebox_exports.Optional(typebox_exports.Boolean({ description: "Ask the model to generate a soundtrack too, where it supports one. Costs more. Defaults to false." })),
|
|
419
|
+
model: typebox_exports.Optional(typebox_exports.String({ description: "Override the video model (e.g. 'google/veo-3.1-lite'). Leave unset to use the one the user chose with /video-model, or the account default." }))
|
|
420
|
+
}),
|
|
421
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
422
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
423
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the video.");
|
|
424
|
+
const resumeJobId = String(params.resumeJobId ?? "").trim();
|
|
425
|
+
if (resumeJobId) {
|
|
426
|
+
return awaitVideoJob(resumeJobId, cwd, params.path, null, signal);
|
|
427
|
+
}
|
|
428
|
+
const prompt = String(params.prompt ?? "").trim();
|
|
429
|
+
if (!prompt) return text("Error: prompt is required.");
|
|
430
|
+
if (params.lastFrame && !params.firstFrame) return text("Error: lastFrame needs firstFrame alongside it.");
|
|
431
|
+
let firstFrame;
|
|
432
|
+
let lastFrame;
|
|
433
|
+
try {
|
|
434
|
+
if (params.firstFrame) firstFrame = readInputImage(cwd, params.firstFrame);
|
|
435
|
+
if (params.lastFrame) lastFrame = readInputImage(cwd, params.lastFrame);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
438
|
+
}
|
|
439
|
+
const model = effectiveMediaModel("video", params.model);
|
|
440
|
+
const submitted = await callAccount("/api/agent/media/videos", {
|
|
441
|
+
method: "POST",
|
|
442
|
+
signal,
|
|
443
|
+
body: {
|
|
444
|
+
prompt,
|
|
445
|
+
...params.seconds != null ? { seconds: params.seconds } : {},
|
|
446
|
+
...params.aspectRatio ? { aspectRatio: params.aspectRatio } : {},
|
|
447
|
+
...params.resolution ? { resolution: params.resolution } : {},
|
|
448
|
+
...params.audio ? { generateAudio: true } : {},
|
|
449
|
+
...model ? { model } : {},
|
|
450
|
+
...firstFrame ? { firstFrame } : {},
|
|
451
|
+
...lastFrame ? { lastFrame } : {}
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
if (!submitted.ok) return text(`Video generation failed: ${submitted.message}`);
|
|
455
|
+
const jobId = submitted.data.jobId;
|
|
456
|
+
if (!jobId) return text("Video generation failed: Privateer did not return a job id.");
|
|
457
|
+
return awaitVideoJob(jobId, cwd, params.path, submitted.data.model ?? null, signal);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
async function awaitVideoJob(jobId, cwd, path, submittedModel, signal) {
|
|
461
|
+
const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
|
|
462
|
+
const resumeHint = `Resume it with generate_video { resumeJobId: "${jobId}", path: "${path}" } \u2014 that waits on this same clip and bills nothing further.`;
|
|
463
|
+
const cancelled = /* @__PURE__ */ __name(() => text(`Video job ${jobId} is still running and will still be billed; the wait was cancelled. ${resumeHint}`), "cancelled");
|
|
464
|
+
for (; ; ) {
|
|
465
|
+
if (signal?.aborted) return cancelled();
|
|
466
|
+
await sleep(VIDEO_POLL_INTERVAL_MS, signal);
|
|
467
|
+
if (signal?.aborted) return cancelled();
|
|
468
|
+
const poll = await callAccount(`/api/agent/media/videos/${encodeURIComponent(jobId)}`, {
|
|
469
|
+
method: "GET",
|
|
470
|
+
signal
|
|
471
|
+
});
|
|
472
|
+
if (!poll.ok) return text(`Video job ${jobId} could not be polled: ${poll.message}. ${resumeHint}`);
|
|
473
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
474
|
+
if (status === "failed") return text(`Video generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
|
|
475
|
+
if (status === "completed") {
|
|
476
|
+
if (!poll.data.data) {
|
|
477
|
+
return text(`Video job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
|
|
478
|
+
}
|
|
479
|
+
const target = abs(cwd, path);
|
|
480
|
+
const ext = extname(target) || extForMime(poll.data.mimeType ?? "", ".mp4");
|
|
481
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
482
|
+
const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
|
|
483
|
+
return text(`Generated video with ${poll.data.model ?? submittedModel ?? "the account video model"}: ${summary}`);
|
|
484
|
+
}
|
|
485
|
+
if (Date.now() > deadline) {
|
|
486
|
+
return text(
|
|
487
|
+
`Video job ${jobId} is still ${status || "running"} after ${Math.round(VIDEO_POLL_TIMEOUT_MS / 6e4)} minutes. It will still complete and is already billed \u2014 do NOT generate it again. ${resumeHint}`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
__name(awaitVideoJob, "awaitVideoJob");
|
|
493
|
+
function sleep(ms, signal) {
|
|
494
|
+
return new Promise((resolve_) => {
|
|
495
|
+
const t = setTimeout(resolve_, ms);
|
|
496
|
+
signal?.addEventListener("abort", () => {
|
|
497
|
+
clearTimeout(t);
|
|
498
|
+
resolve_();
|
|
499
|
+
}, { once: true });
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
__name(sleep, "sleep");
|
|
503
|
+
var generateModelToolDefinition = {
|
|
504
|
+
name: "generate_model",
|
|
505
|
+
label: "Generate 3D Model",
|
|
506
|
+
description: "Generate a 3D MESH \u2014 a .glb (or .obj) file for a game engine or DCC tool \u2014 from one or more reference images. (This makes 3D geometry; it has nothing to do with language models.) There is no text-to-mesh here: generate the concept art with generate_image first, look at it, and pass that path as `images` \u2014 which also means the shape is generated from a look someone approved rather than from a sentence. Supply up to four views (front, back, left, right) of the SAME object to stop the model inventing the sides it cannot see; on some models that costs more and it is usually worth it. Generation takes a minute or more and this tool waits for it. EXPENSIVE and billed to the user's Privateer account: $0.14 to $2.41 a mesh depending on WHICH model and which options, so plan the asset before calling and tell the user the total before batching dozens of them.\nThere are ten models from five vendors and they are not interchangeable \u2014 the dearest costs seventeen times the cheapest, and each takes DIFFERENT options. Call media_capabilities (with `model` set) to see a model's own options and price before choosing; pass what you picked as `axes`. Blocking out a shape is a job for the cheap end; a hero asset is not. PRIVACY: 3D generation runs on a provider with no zero-retention option, so it is gated \u2014 a ZDR account must have enabled non-ZDR media. Call media_capabilities first if unsure.",
|
|
507
|
+
parameters: typebox_exports.Object({
|
|
508
|
+
images: typebox_exports.Array(typebox_exports.String(), {
|
|
509
|
+
description: "Paths to reference images of one object, best view first. 1-4 of them, read as front, back, left, right. How many are actually USED is per-model \u2014 media_capabilities reports `maxViews`, and several models take a single view and will quietly ignore the rest, so check before generating four images to feed one. Where extra views are used they give the model the sides it would otherwise invent; on the Hunyuan models they add $0.15 in total (not each) and are free elsewhere."
|
|
510
|
+
}),
|
|
511
|
+
path: typebox_exports.String({
|
|
512
|
+
description: "Where to write the mesh, relative to cwd or absolute (e.g. 'assets/props/crate.glb'). The extension is corrected to whatever container is actually delivered."
|
|
513
|
+
}),
|
|
514
|
+
format: typebox_exports.Optional(
|
|
515
|
+
typebox_exports.String({
|
|
516
|
+
description: "Container: 'glb' (default) or 'obj'. Prefer glb \u2014 it is the only one that carries the materials, so an 'obj' of a textured mesh arrives as bare geometry, and 'obj' is not produced at all for generateType 'Geometry'. Falls back to glb whenever the provider doesn't render it."
|
|
517
|
+
})
|
|
518
|
+
),
|
|
519
|
+
// `additionalProperties`, not Type.Record: TypeBox compiles a Record to
|
|
520
|
+
// `patternProperties`, which several providers' tool-schema validators
|
|
521
|
+
// reject outright — and a tool whose schema is refused fails at the provider
|
|
522
|
+
// with a message about JSON Schema rather than about 3D.
|
|
523
|
+
axes: typebox_exports.Optional(
|
|
524
|
+
typebox_exports.Object({}, {
|
|
525
|
+
additionalProperties: typebox_exports.Union([typebox_exports.String(), typebox_exports.Number(), typebox_exports.Boolean()]),
|
|
526
|
+
description: "The chosen model's own options, as {name: value} \u2014 the ONLY way to reach the options on any model except the Hunyuan ones, since no two endpoints share their levers (one takes `resolution`, another a `texture` ladder, another a `highPack` flag). Get the exact names and legal values from media_capabilities with the same `model`; an option this model does not have is REFUSED, not ignored, so do not guess. Omit an option to take the model's own default, which for a numeric budget means letting the provider choose \u2014 on some models setting it at all is what costs extra."
|
|
527
|
+
})
|
|
528
|
+
),
|
|
529
|
+
generateType: typebox_exports.Optional(
|
|
530
|
+
typebox_exports.String({
|
|
531
|
+
description: "Hunyuan models only (prefer `axes` elsewhere). 'Normal' ($0.375, default) \u2014 textured mesh. 'LowPoly' ($0.45) \u2014 retopologised, best for a game asset that will deform or needs clean edges; dearer because it is more work, not less. 'Geometry' ($0.225) \u2014 untextured geometry only, for blockouts and greyboxing."
|
|
532
|
+
})
|
|
533
|
+
),
|
|
534
|
+
polygonType: typebox_exports.Optional(
|
|
535
|
+
typebox_exports.String({
|
|
536
|
+
description: "Hunyuan models only (prefer `axes` elsewhere). 'triangle' (default) or 'quadrilateral'. Quads deform far better under animation, so choose them for anything that will be rigged; triangles are fine for static props."
|
|
537
|
+
})
|
|
538
|
+
),
|
|
539
|
+
faceCount: typebox_exports.Optional(
|
|
540
|
+
typebox_exports.Number({
|
|
541
|
+
description: "Target face budget, honoured exactly; the legal range is per-model (media_capabilities reports it). On the Hunyuan models it ADDS $0.15 because the provider charges for a custom count, and leaving it unset uses their 500k default \u2014 measured at 8.6 MB untextured and 64 MB textured, against 15 MB for a textured 60k mesh. Free on most other models. Set it for anything going into a game."
|
|
542
|
+
})
|
|
543
|
+
),
|
|
544
|
+
pbr: typebox_exports.Optional(
|
|
545
|
+
typebox_exports.Boolean({
|
|
546
|
+
description: "Generate PBR materials (base colour, normal, roughness/metallic) instead of a flat texture. Essential for anything lit by a modern engine. ADDS $0.15 on the Hunyuan models and is free on Tripo and Meshy \u2014 which is why the price comes from media_capabilities and not from here. Refused on any model generating untextured geometry."
|
|
547
|
+
})
|
|
548
|
+
),
|
|
549
|
+
model: typebox_exports.Optional(
|
|
550
|
+
typebox_exports.String({
|
|
551
|
+
description: "Which 3D model to use, e.g. 'fal-ai/hyper3d/rodin/v2.5/fast' for a cheap blockout or 'meshy/v7/image-to-3d' for a game-ready hero asset. media_capabilities lists every id with its price. Leave unset to use the account default."
|
|
552
|
+
})
|
|
553
|
+
)
|
|
554
|
+
}),
|
|
555
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
556
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
557
|
+
const paths = params.images ?? [];
|
|
558
|
+
if (paths.length === 0) return text("Error: at least one reference image is required \u2014 generate one with generate_image first.");
|
|
559
|
+
if (paths.length > MAX_MESH_VIEWS) {
|
|
560
|
+
return text(`Error: at most ${MAX_MESH_VIEWS} reference views (front, back, left, right); got ${paths.length}.`);
|
|
561
|
+
}
|
|
562
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the mesh.");
|
|
563
|
+
let images;
|
|
564
|
+
try {
|
|
565
|
+
images = paths.map((p) => readInputImage(cwd, p));
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
568
|
+
}
|
|
569
|
+
const totalBytes = images.reduce((sum, img) => sum + Math.floor(img.data.length * 3 / 4), 0);
|
|
570
|
+
if (totalBytes > MAX_MESH_INPUT_TOTAL_BYTES) {
|
|
571
|
+
return text(
|
|
572
|
+
`Error: the reference views total ${(totalBytes / 1048576).toFixed(1)} MB; the limit for one request is ${MAX_MESH_INPUT_TOTAL_BYTES / 1048576} MB. Use fewer views, or downscale them first.`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
const submitted = await callAccount("/api/agent/media/models", {
|
|
576
|
+
method: "POST",
|
|
577
|
+
signal,
|
|
578
|
+
body: {
|
|
579
|
+
images,
|
|
580
|
+
...params.format ? { format: params.format } : {},
|
|
581
|
+
// `axes` wins over the named fields server-side, so a model passing both
|
|
582
|
+
// gets what it spelled out per-axis. The named four are sent alongside
|
|
583
|
+
// rather than folded in here: which of them the chosen model actually
|
|
584
|
+
// has is the server's business, and duplicating that judgement in the
|
|
585
|
+
// tool is how the two get to disagree.
|
|
586
|
+
...params.axes && typeof params.axes === "object" ? { axes: params.axes } : {},
|
|
587
|
+
...params.generateType ? { generateType: params.generateType } : {},
|
|
588
|
+
...params.polygonType ? { polygonType: params.polygonType } : {},
|
|
589
|
+
...params.faceCount != null ? { faceCount: params.faceCount } : {},
|
|
590
|
+
...params.pbr ? { pbr: true } : {},
|
|
591
|
+
...params.model ? { model: params.model } : {}
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
if (!submitted.ok) return text(`3D generation failed: ${submitted.message}`);
|
|
595
|
+
const jobId = submitted.data.jobId;
|
|
596
|
+
if (!jobId) return text("3D generation failed: Privateer did not return a job id.");
|
|
597
|
+
const deadline = Date.now() + MESH_POLL_TIMEOUT_MS;
|
|
598
|
+
const cancelled = /* @__PURE__ */ __name(() => text(`3D job ${jobId} was submitted but the wait was cancelled. It is still running and will still be billed.`), "cancelled");
|
|
599
|
+
for (; ; ) {
|
|
600
|
+
if (signal?.aborted) return cancelled();
|
|
601
|
+
await sleep(MESH_POLL_INTERVAL_MS, signal);
|
|
602
|
+
if (signal?.aborted) return cancelled();
|
|
603
|
+
const poll = await callAccount(`/api/agent/media/models/${encodeURIComponent(jobId)}`, {
|
|
604
|
+
method: "GET",
|
|
605
|
+
signal
|
|
606
|
+
});
|
|
607
|
+
if (!poll.ok) return text(`3D job ${jobId} could not be polled: ${poll.message}`);
|
|
608
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
609
|
+
if (status === "failed") return text(`3D generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
|
|
610
|
+
if (status === "completed") {
|
|
611
|
+
if (!poll.data.data) {
|
|
612
|
+
return text(`3D job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
|
|
613
|
+
}
|
|
614
|
+
const delivered = String(poll.data.format || "glb").toLowerCase();
|
|
615
|
+
const target = abs(cwd, params.path);
|
|
616
|
+
const asked = extname(target).replace(/^\./, "").toLowerCase();
|
|
617
|
+
const out = `${target.slice(0, target.length - extname(target).length)}.${delivered}`;
|
|
618
|
+
const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
|
|
619
|
+
const note = asked && asked !== delivered ? `
|
|
620
|
+
(Asked for .${asked}; the provider returned ${delivered.toUpperCase()}, so the file was saved with that extension.)` : "";
|
|
621
|
+
return text(`Generated 3D model with ${poll.data.model ?? submitted.data.model ?? "the account 3D model"}: ${summary}${note}`);
|
|
622
|
+
}
|
|
623
|
+
if (Date.now() > deadline) {
|
|
624
|
+
return text(
|
|
625
|
+
`3D job ${jobId} is still ${status || "running"} after ${Math.round(MESH_POLL_TIMEOUT_MS / 6e4)} minutes. It will still complete and still be billed; nothing was saved here.`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
var generateSpeechToolDefinition = {
|
|
632
|
+
name: "generate_speech",
|
|
633
|
+
label: "Generate Speech",
|
|
634
|
+
description: "Turn text into spoken audio and save it to disk. Use it to narrate a video you are assembling, or to produce a spoken version of a written answer. Billed to the user's Privateer account; the account's default voice model is a confidential-compute one, so the text is processed inside an enclave rather than by a retaining provider. Mux the result onto video with video_compose. Call media_capabilities first if you want to override `voice` or `model` \u2014 it lists the exact wire voice ids for the account's current TTS model (and any model you pass it), which is not the same as a spoken character or brand name.",
|
|
635
|
+
parameters: typebox_exports.Object({
|
|
636
|
+
text: typebox_exports.String({ description: "The words to speak. Write them as they should be read aloud." }),
|
|
637
|
+
path: typebox_exports.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/narration.mp3')." }),
|
|
638
|
+
voice: typebox_exports.Optional(typebox_exports.String({
|
|
639
|
+
description: "Voice id, if the account's TTS model offers a choice. Leave unset for its default. These are PROVIDER wire ids, not free text \u2014 e.g. Deepgram Aura-2 voices are 'aura-2-<name>-<lang>' ('aura-2-jupiter-en', not 'jupiter' or 'Jupiter'). Get the exact legal list for the model in play from media_capabilities' `speech.voices` before guessing one; an unrecognised id is refused with VOICE_UNSUPPORTED rather than silently served on a different voice."
|
|
640
|
+
})),
|
|
641
|
+
model: typebox_exports.Optional(typebox_exports.String({ description: "Override the account's text-to-speech model." })),
|
|
642
|
+
...deferredParams
|
|
643
|
+
}),
|
|
644
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
645
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
646
|
+
const body = String(params.text ?? "").trim();
|
|
647
|
+
if (!body && !String(params.resumeJobId ?? "").trim()) return text("Error: text is required.");
|
|
648
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the audio.");
|
|
649
|
+
const r = await callDeferred("speech", {
|
|
650
|
+
text: body,
|
|
651
|
+
...params.voice ? { voice: params.voice } : {},
|
|
652
|
+
...params.model ? { ttsModelId: params.model } : {}
|
|
653
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
654
|
+
if (r.ok === "started") return text(startedText("speech", r.jobId));
|
|
655
|
+
if (!r.ok) return text(`Speech generation failed: ${r.message}`);
|
|
656
|
+
if (!r.data.audioBase64) return text("Speech generation returned no audio.");
|
|
657
|
+
const target = abs(cwd, params.path);
|
|
658
|
+
const ext = extname(target) || extForMime(r.data.mimeType ?? "", ".mp3");
|
|
659
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
660
|
+
const via = [r.data.model, r.data.voice].filter(Boolean).join(" / ");
|
|
661
|
+
return text(
|
|
662
|
+
`Generated speech${via ? ` with ${via}` : ""}: ${writeOut(out, Buffer.from(r.data.audioBase64, "base64"))}`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
var generateMusicToolDefinition = {
|
|
667
|
+
name: "generate_music",
|
|
668
|
+
label: "Generate Music",
|
|
669
|
+
description: "Generate an instrumental music clip from a text prompt and save it to disk \u2014 a soundtrack for a video you are assembling. PRIVACY: music is the one media type with no zero-retention or confidential option anywhere in the catalog, so the prompt reaches a provider that may retain it. Privateer sends it unattributed (no account id, no history), but it is not private the way the other media tools are. Do not put anything sensitive or personal in a music prompt, and say so if the user's own wording would carry something identifying.",
|
|
670
|
+
parameters: typebox_exports.Object({
|
|
671
|
+
prompt: typebox_exports.String({ description: "The music to generate: genre, mood, instrumentation, tempo. Keep it about the music, not about the user." }),
|
|
672
|
+
path: typebox_exports.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/score.mp3')." }),
|
|
673
|
+
model: typebox_exports.Optional(typebox_exports.String({ description: "Override the account's music model." })),
|
|
674
|
+
...deferredParams
|
|
675
|
+
}),
|
|
676
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
677
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
678
|
+
const prompt = String(params.prompt ?? "").trim();
|
|
679
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
680
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the audio.");
|
|
681
|
+
const r = await callDeferred(
|
|
682
|
+
"music",
|
|
683
|
+
{ prompt, ...params.model ? { musicModelId: params.model } : {} },
|
|
684
|
+
{ signal, resumeJobId: params.resumeJobId, wait: params.wait }
|
|
685
|
+
);
|
|
686
|
+
if (r.ok === "started") return text(startedText("music", r.jobId));
|
|
687
|
+
if (!r.ok) return text(`Music generation failed: ${r.message}`);
|
|
688
|
+
if (!r.data.audioBase64) return text("Music generation returned no audio.");
|
|
689
|
+
const target = abs(cwd, params.path);
|
|
690
|
+
const ext = extname(target) || extForMime(r.data.mimeType ?? "", ".mp3");
|
|
691
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
692
|
+
return text(
|
|
693
|
+
`Generated music with ${r.data.model ?? "the account music model"}: ${writeOut(out, Buffer.from(r.data.audioBase64, "base64"))}
|
|
694
|
+
(Reminder for the answer you give the user: music prompts are sent to a provider with no zero-retention option, unattributed.)`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
var SFX_MIN_SECONDS = 1;
|
|
699
|
+
var SFX_MAX_SECONDS = 30;
|
|
700
|
+
var generateSfxToolDefinition = {
|
|
701
|
+
name: "generate_sfx",
|
|
702
|
+
label: "Generate Sound Effect",
|
|
703
|
+
description: "Generate a single sound effect from a text prompt and save it to disk \u2014 an impact, a whoosh, a UI click, a room ambience. Cheap (about $0.01-$0.02 a call) and quick. This is for ONE sound, not for a score: use generate_music for a bed and generate_speech for narration. Place the result in time with video_compose's mix_audio, which is what makes an effect land on the frame it belongs to.\nPRIVACY: every effect model is a non-zero-retention provider, so this is gated exactly like image and video generation \u2014 an account with Require ZDR on is REFUSED until its owner enables non-ZDR media. That is a setting only they can change, so do not retry a refusal. (Do not reach for generate_music instead on privacy grounds: music has no ZDR gate at all, which is looser, not safer.) Check media_capabilities if you need to know whether this account can use it before you plan a sequence around a dozen effects.",
|
|
704
|
+
parameters: typebox_exports.Object({
|
|
705
|
+
prompt: typebox_exports.String({
|
|
706
|
+
description: "The sound itself, described as a sound: 'heavy wooden door slamming shut, close mic, reverberant hall'. Effect models are tuned for one event, so keep it to one \u2014 a scene comes out muddy."
|
|
707
|
+
}),
|
|
708
|
+
path: typebox_exports.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/sfx/door-slam.mp3')." }),
|
|
709
|
+
seconds: typebox_exports.Optional(
|
|
710
|
+
typebox_exports.Number({
|
|
711
|
+
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.`
|
|
712
|
+
})
|
|
713
|
+
),
|
|
714
|
+
model: typebox_exports.Optional(
|
|
715
|
+
typebox_exports.String({
|
|
716
|
+
description: "Override the account's effect model. 'fal-ai/elevenlabs/sound-effects/v2' is the most convincing foley in the catalog; the default is a cheaper model tuned for single sounds."
|
|
717
|
+
})
|
|
718
|
+
),
|
|
719
|
+
...deferredParams
|
|
720
|
+
}),
|
|
721
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
722
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
723
|
+
const prompt = String(params.prompt ?? "").trim();
|
|
724
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
725
|
+
if (!params.path) return text("Error: path is required \u2014 say where to save the audio.");
|
|
726
|
+
let seconds;
|
|
727
|
+
if (params.seconds != null) {
|
|
728
|
+
seconds = Number(params.seconds);
|
|
729
|
+
if (!Number.isFinite(seconds) || seconds < SFX_MIN_SECONDS || seconds > SFX_MAX_SECONDS) {
|
|
730
|
+
return text(
|
|
731
|
+
`Error: seconds must be between ${SFX_MIN_SECONDS} and ${SFX_MAX_SECONDS} (got ${params.seconds}). For anything longer, generate a few effects and place them with video_compose mix_audio, or use generate_music for a bed.`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
seconds = Math.round(seconds);
|
|
735
|
+
}
|
|
736
|
+
const r = await callDeferred("sfx", {
|
|
737
|
+
prompt,
|
|
738
|
+
...seconds != null ? { duration: seconds } : {},
|
|
739
|
+
...params.model ? { sfxModelId: params.model } : {}
|
|
740
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
741
|
+
if (r.ok === "started") return text(startedText("sfx", r.jobId));
|
|
742
|
+
if (!r.ok) return text(`Sound-effect generation failed: ${r.message}`);
|
|
743
|
+
if (!r.data.audioBase64) return text("Sound-effect generation returned no audio.");
|
|
744
|
+
const target = abs(cwd, params.path);
|
|
745
|
+
const ext = extname(target) || extForMime(r.data.mimeType ?? "", ".mp3");
|
|
746
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
747
|
+
const length = r.data.durationSeconds ? `${r.data.durationSeconds}s ` : "";
|
|
748
|
+
return text(
|
|
749
|
+
`Generated a ${length}sound effect with ${r.data.model ?? "the account effect model"}: ` + writeOut(out, Buffer.from(r.data.audioBase64, "base64"))
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
function describeModel3d(model3d) {
|
|
754
|
+
if (model3d?.configured === false) {
|
|
755
|
+
return ["3D model generation: NOT AVAILABLE on this deployment (no provider key). Do not call generate_model."];
|
|
756
|
+
}
|
|
757
|
+
const lines = [
|
|
758
|
+
`3D model: ${model3d?.model ?? "unknown"}${model3d?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
759
|
+
` formats: ${model3d?.formats?.length ? model3d.formats.join(", ") : "glb"}; up to ${model3d?.maxViews ?? 4} reference view(s)`,
|
|
760
|
+
` cost: $${(model3d?.priceUsd?.min ?? 0).toFixed(2)}-$${(model3d?.priceUsd?.max ?? 0).toFixed(2)} charged per mesh, depending on the options below`
|
|
761
|
+
];
|
|
762
|
+
if (model3d?.axes?.length) {
|
|
763
|
+
lines.push(` options (pass as generate_model's \`axes\`, e.g. {"${model3d.axes[0].name}": ...}):`);
|
|
764
|
+
for (const a of model3d.axes) lines.push(describeAxis(a));
|
|
765
|
+
}
|
|
766
|
+
for (const c of model3d?.conflicts ?? []) {
|
|
767
|
+
lines.push(` NOTE: ${c.forbids} cannot be used when ${c.whenAxis} is ${JSON.stringify(c.is)}.`);
|
|
768
|
+
}
|
|
769
|
+
if (model3d?.catalog?.length) {
|
|
770
|
+
lines.push(" every 3D model available (pass `model` to generate_model, or to this tool to see its options):");
|
|
771
|
+
for (const m of model3d.catalog) {
|
|
772
|
+
const p = m.priceUsd ? ` $${(m.priceUsd.min ?? 0).toFixed(2)}-$${(m.priceUsd.max ?? 0).toFixed(2)}` : "";
|
|
773
|
+
lines.push(` ${m.id}${p}${m.id === model3d.model ? " [described above]" : ""}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return lines;
|
|
777
|
+
}
|
|
778
|
+
__name(describeModel3d, "describeModel3d");
|
|
779
|
+
function describeAxis(a) {
|
|
780
|
+
const cost = a.priced ? " [affects price]" : "";
|
|
781
|
+
if (a.kind === "int") return ` ${a.name}: number ${a.min ?? "?"}-${a.max ?? "?"}, omit to let the provider choose${cost}`;
|
|
782
|
+
if (a.kind === "views") return ` ${a.name}: driven by how many images you pass${cost}`;
|
|
783
|
+
if (a.kind === "bool") return ` ${a.name}: true | false (default ${a.default === true})${cost}`;
|
|
784
|
+
return ` ${a.name}: ${(a.values ?? []).join(" | ")} (default ${String(a.default)})${cost}`;
|
|
785
|
+
}
|
|
786
|
+
__name(describeAxis, "describeAxis");
|
|
787
|
+
var usd = /* @__PURE__ */ __name((n) => `$${(n ?? 0).toFixed(2)}`, "usd");
|
|
788
|
+
function describeImageVideo(image, video) {
|
|
789
|
+
const imagePrice = typeof image?.priceUsdEach === "number" ? `, about ${usd(image.priceUsdEach)} each` : ", price not reported";
|
|
790
|
+
const lines = [
|
|
791
|
+
`Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
792
|
+
` up to ${image?.maxPerCall ?? 1} image(s) per call${imagePrice}`,
|
|
793
|
+
`Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
794
|
+
` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
|
|
795
|
+
` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`
|
|
796
|
+
];
|
|
797
|
+
const table = video?.priceTable ?? [];
|
|
798
|
+
if (video?.priceUsd && table.length) {
|
|
799
|
+
const fallback = video.priceSource === "fallback" ? " (no price row for this model \u2014 a conservative estimate)" : "";
|
|
800
|
+
lines.push(` cost: ${usd(video.priceUsd.min)}-${usd(video.priceUsd.max)} a clip${fallback}, reserved up front and settled at the real cost`);
|
|
801
|
+
const plain = table.filter((r) => r.resolution === null && !r.audio && r.seconds !== null);
|
|
802
|
+
if (plain.length) lines.push(` by length (default resolution, no audio): ${plain.map((r) => `${r.seconds}s ${usd(r.usd)}`).join(", ")}`);
|
|
803
|
+
const resolutions = [...new Set(table.map((r) => r.resolution).filter((r) => !!r))];
|
|
804
|
+
if (resolutions.length) lines.push(` resolution changes the price: ${resolutions.join(", ")} (pass generate_video's \`resolution\`)`);
|
|
805
|
+
if (table.some((r) => r.audio)) lines.push(" audio: true costs more on this model");
|
|
806
|
+
} else {
|
|
807
|
+
lines.push(" cost: not reported by this server \u2014 roughly $0.10-$1 a clip, more for long or high-resolution clips");
|
|
808
|
+
}
|
|
809
|
+
return lines;
|
|
810
|
+
}
|
|
811
|
+
__name(describeImageVideo, "describeImageVideo");
|
|
812
|
+
function quoteMediaCallUsd(tool, input, caps) {
|
|
813
|
+
const args = input && typeof input === "object" ? input : {};
|
|
814
|
+
const num = /* @__PURE__ */ __name((v) => typeof v === "number" && Number.isFinite(v) ? v : void 0, "num");
|
|
815
|
+
const clip = /* @__PURE__ */ __name((seconds, resolution, audio) => {
|
|
816
|
+
const table = caps.video?.priceTable ?? [];
|
|
817
|
+
if (!table.length) return null;
|
|
818
|
+
const exact = table.find(
|
|
819
|
+
(r) => r.seconds === (seconds ?? r.seconds) && r.resolution === (resolution ?? null) && r.audio === audio
|
|
820
|
+
);
|
|
821
|
+
if (exact && seconds !== void 0) return exact.usd;
|
|
822
|
+
const candidates = table.filter((r) => r.resolution === (resolution ?? null) && (audio || !r.audio));
|
|
823
|
+
const pool = candidates.length ? candidates : table;
|
|
824
|
+
return Math.max(...pool.map((r) => r.usd));
|
|
825
|
+
}, "clip");
|
|
826
|
+
switch (tool) {
|
|
827
|
+
case "generate_image": {
|
|
828
|
+
if (typeof args.model === "string" && args.model && args.model !== caps.image?.model) return null;
|
|
829
|
+
const each = caps.image?.priceUsdEach;
|
|
830
|
+
if (typeof each !== "number") return null;
|
|
831
|
+
const n = Math.min(Math.max(Math.trunc(num(args.count) ?? 1), 1), caps.image?.maxPerCall ?? 4);
|
|
832
|
+
return each * n;
|
|
833
|
+
}
|
|
834
|
+
case "generate_video":
|
|
835
|
+
return clip(num(args.seconds), typeof args.resolution === "string" ? args.resolution : void 0, args.audio === true);
|
|
836
|
+
case "generate_model":
|
|
837
|
+
return typeof caps.model3d?.priceUsd?.max === "number" ? caps.model3d.priceUsd.max : null;
|
|
838
|
+
case "generate_sprite": {
|
|
839
|
+
const set = typeof args.directions === "string" ? args.directions : "one";
|
|
840
|
+
const row = caps.sprites?.directionSets?.find((d) => d.id === set);
|
|
841
|
+
const silent = (caps.video?.priceTable ?? []).filter((r) => !r.audio);
|
|
842
|
+
const clipUsd = silent.length ? Math.max(...silent.map((r) => r.usd)) : null;
|
|
843
|
+
if (!row || clipUsd === null) return null;
|
|
844
|
+
const stills = row.billedTurnStills ?? 0;
|
|
845
|
+
const each = caps.image?.priceUsdEach;
|
|
846
|
+
if (stills > 0 && (typeof each !== "number" || typeof args.image_model === "string" && args.image_model && args.image_model !== caps.image?.model)) return null;
|
|
847
|
+
return clipUsd * (row.billedClips ?? 1) + stills * (each ?? 0);
|
|
848
|
+
}
|
|
849
|
+
case "generate_sfx":
|
|
850
|
+
return 0.02;
|
|
851
|
+
default:
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
__name(quoteMediaCallUsd, "quoteMediaCallUsd");
|
|
856
|
+
async function readMediaCapabilities(query = {}, signal) {
|
|
857
|
+
const q = new URLSearchParams();
|
|
858
|
+
if (query.imageModel) q.set("imageModel", query.imageModel);
|
|
859
|
+
if (query.videoModel) q.set("videoModel", query.videoModel);
|
|
860
|
+
if (query.model) q.set("model", query.model);
|
|
861
|
+
const qs = q.toString();
|
|
862
|
+
const r = await callAccount(`/api/agent/media/capabilities${qs ? `?${qs}` : ""}`, { method: "GET", signal });
|
|
863
|
+
return r.ok ? { ok: true, data: r.data } : { ok: false, message: r.message };
|
|
864
|
+
}
|
|
865
|
+
__name(readMediaCapabilities, "readMediaCapabilities");
|
|
866
|
+
function describeSfx(sfx) {
|
|
867
|
+
if (sfx?.configured === false) {
|
|
868
|
+
return ["Sound effects: NOT AVAILABLE on this deployment (no provider key). Do not call generate_sfx."];
|
|
869
|
+
}
|
|
870
|
+
if (sfx?.blockedByZdr) {
|
|
871
|
+
return [
|
|
872
|
+
`Sound effects: ${sfx.model ?? "unknown"} [BLOCKED by this account's ZDR setting]`,
|
|
873
|
+
" Every effect model is non-ZDR, so this account cannot generate one until its owner enables non-ZDR media (Settings \u2192 Privacy). Do not substitute generate_music for an effect \u2014 it is ungated, not more private."
|
|
874
|
+
];
|
|
875
|
+
}
|
|
876
|
+
return [
|
|
877
|
+
`Sound effects: ${sfx?.model ?? "unknown"}`,
|
|
878
|
+
` up to ${sfx?.maxDurationSeconds ?? SFX_MAX_SECONDS}s per effect, one sound per call, ~$0.01-$0.02 each`
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
__name(describeSfx, "describeSfx");
|
|
882
|
+
function describeSpeech(speech) {
|
|
883
|
+
const blocked = speech?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : "";
|
|
884
|
+
const lines = [`Speech model: ${speech?.model ?? "unknown"}${blocked}`];
|
|
885
|
+
if (speech?.blockedByZdr) {
|
|
886
|
+
lines.push(
|
|
887
|
+
" This voice model is non-ZDR, so this account cannot use it until its owner enables non-ZDR media (Settings \u2192 Privacy). Omit `model` to use the account's confidential-compute default instead of retrying this one."
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
if (speech?.voices?.length) {
|
|
891
|
+
lines.push(
|
|
892
|
+
` ${speech.voices.length} voice id(s) for this model \u2014 pass one of these EXACTLY as generate_speech's \`voice\`, never a guessed name: ${speech.voices.join(", ")}`
|
|
893
|
+
);
|
|
894
|
+
} else {
|
|
895
|
+
lines.push(" No enumerable voice list for this model; omit `voice` to use its default.");
|
|
896
|
+
}
|
|
897
|
+
if (speech?.catalog?.length) {
|
|
898
|
+
lines.push(" every TTS model available (pass `ttsModel` to this tool to see its voices):");
|
|
899
|
+
for (const m of speech.catalog) {
|
|
900
|
+
const tags = [m.isTee ? "confidential" : m.isZdr ? "ZDR" : "non-ZDR", `${m.voiceCount ?? 0} voice(s)`];
|
|
901
|
+
if (m.blockedByZdr) tags.push("BLOCKED");
|
|
902
|
+
lines.push(
|
|
903
|
+
` ${m.id} (${tags.join(", ")})${m.id === speech.model ? " [described above]" : ""}`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return lines;
|
|
908
|
+
}
|
|
909
|
+
__name(describeSpeech, "describeSpeech");
|
|
910
|
+
var mediaCapabilitiesToolDefinition = {
|
|
911
|
+
name: "media_capabilities",
|
|
912
|
+
label: "Media Capabilities",
|
|
913
|
+
description: "Report what this Privateer account can generate right now: which image and video models it resolves to, what an image and a clip cost, the clip lengths and aspect ratios that video model accepts, and whether the account's privacy settings currently block media generation. Free and instant. Call it before planning a multi-clip video so you pick a legal clip length instead of discovering it through a rejected \u2014 or worse, billed \u2014 call.\nIt is also the ONLY way to find out which 3D models exist and what options each one takes: they range from $0.14 to $2.41 a mesh and no two take the same options, so call this with `model` set to the id you are considering BEFORE generate_model, or you will pay the default model's price for a job a cheaper one could have done.\nIt is also the ONLY way to find a text-to-speech model's real voice ids \u2014 Deepgram Aura-2's are 'aura-2-<name>-<lang>', not a bare character name \u2014 so call this with `ttsModel` set BEFORE generate_speech whenever you plan to pass `voice`.",
|
|
914
|
+
parameters: typebox_exports.Object({
|
|
915
|
+
model: typebox_exports.Optional(
|
|
916
|
+
typebox_exports.String({
|
|
917
|
+
description: "A 3D model id to describe instead of the account default (e.g. 'fal-ai/trellis-2'). The response's 3D catalog lists every legal id; every field reported \u2014 the options, their legal values and the price \u2014 is per-model."
|
|
918
|
+
})
|
|
919
|
+
),
|
|
920
|
+
imageModel: typebox_exports.Optional(
|
|
921
|
+
typebox_exports.String({
|
|
922
|
+
description: "An image model id to describe instead of the default (e.g. 'google/gemini-3.1-flash-image'), so the price per image is the one generate_image will charge for it."
|
|
923
|
+
})
|
|
924
|
+
),
|
|
925
|
+
videoModel: typebox_exports.Optional(
|
|
926
|
+
typebox_exports.String({
|
|
927
|
+
description: "A video model id to describe instead of the default (e.g. 'bytedance/seedance-2.0'). Clip lengths, aspect ratios and the price per clip are all per-model \u2014 check the one you will pass as generate_video's `model` before you spend on it."
|
|
928
|
+
})
|
|
929
|
+
),
|
|
930
|
+
ttsModel: typebox_exports.Optional(
|
|
931
|
+
typebox_exports.String({
|
|
932
|
+
description: "A text-to-speech model id to describe instead of the account default (e.g. 'deepgram/aura-2'). The response's `speech.voices` lists every legal voice id for THIS model \u2014 voice ids are not shared across models."
|
|
933
|
+
})
|
|
934
|
+
)
|
|
935
|
+
}),
|
|
936
|
+
async execute(_toolCallId, params, signal) {
|
|
937
|
+
const imageModel = effectiveMediaModel("image", params?.imageModel);
|
|
938
|
+
const videoModel = effectiveMediaModel("video", params?.videoModel);
|
|
939
|
+
const query = new URLSearchParams();
|
|
940
|
+
if (params?.model) query.set("model", params.model);
|
|
941
|
+
if (imageModel) query.set("imageModel", imageModel);
|
|
942
|
+
if (videoModel) query.set("videoModel", videoModel);
|
|
943
|
+
if (params?.ttsModel) query.set("ttsModel", params.ttsModel);
|
|
944
|
+
const qs = query.toString();
|
|
945
|
+
const r = await callAccount(
|
|
946
|
+
`/api/agent/media/capabilities${qs ? `?${qs}` : ""}`,
|
|
947
|
+
{ method: "GET", signal }
|
|
948
|
+
);
|
|
949
|
+
if (!r.ok) return text(`Could not read media capabilities: ${r.message}`);
|
|
950
|
+
const { image, video, model3d, sfx, speech, privacy } = r.data;
|
|
951
|
+
const lines = describeImageVideo(image, video);
|
|
952
|
+
if (imageModel && image?.model && image.model !== imageModel) {
|
|
953
|
+
lines.push(` (generate_image will use ${imageModel}; this server described the account default instead)`);
|
|
954
|
+
}
|
|
955
|
+
lines.push(...describeModel3d(model3d));
|
|
956
|
+
lines.push(...describeSfx(sfx));
|
|
957
|
+
lines.push(...describeSpeech(speech));
|
|
958
|
+
lines.push(`Privacy: requireZdr=${privacy?.requireZdr ?? "?"}, allowNonZdrMedia=${privacy?.allowNonZdrMedia ?? "?"}`);
|
|
959
|
+
if (image?.blockedByZdr || video?.blockedByZdr || model3d?.blockedByZdr || sfx?.blockedByZdr || speech?.blockedByZdr) {
|
|
960
|
+
lines.push(
|
|
961
|
+
"A [BLOCKED] model means the account requires Zero Data Retention and that model has no ZDR endpoint. Only the account owner can change it (Settings \u2192 Privacy); do not keep retrying."
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
lines.push(
|
|
965
|
+
"Music is always available; sound effects and (per above) speech's CURRENT model may not be. Music has no ZDR gate at all; effects are gated like image and video, so a blocked effect must never be answered with music instead."
|
|
966
|
+
);
|
|
967
|
+
return text(lines.join("\n"));
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
function pathInside(root, target) {
|
|
971
|
+
const rel = relative(root, target);
|
|
972
|
+
return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep);
|
|
973
|
+
}
|
|
974
|
+
__name(pathInside, "pathInside");
|
|
975
|
+
function archiveEntryPath(name, root) {
|
|
976
|
+
const rel = name.split("\\").join("/");
|
|
977
|
+
const traverses = rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || rel.split("/").some((seg) => seg === "..");
|
|
978
|
+
if (traverses || !pathInside(root, resolve(root, rel))) {
|
|
979
|
+
throw new Error(`archive entry "${name}" escapes the destination directory ${root}`);
|
|
980
|
+
}
|
|
981
|
+
return rel;
|
|
982
|
+
}
|
|
983
|
+
__name(archiveEntryPath, "archiveEntryPath");
|
|
984
|
+
function extractStoredZip(zip, destDir) {
|
|
985
|
+
const written = [];
|
|
986
|
+
const root = resolve(destDir);
|
|
987
|
+
let at = 0;
|
|
988
|
+
while (at + 30 <= zip.length && zip.readUInt32LE(at) === 67324752) {
|
|
989
|
+
const method = zip.readUInt16LE(at + 8);
|
|
990
|
+
const size = zip.readUInt32LE(at + 18);
|
|
991
|
+
const nameLen = zip.readUInt16LE(at + 26);
|
|
992
|
+
const extraLen = zip.readUInt16LE(at + 28);
|
|
993
|
+
const name = zip.toString("utf8", at + 30, at + 30 + nameLen);
|
|
994
|
+
const dataAt = at + 30 + nameLen + extraLen;
|
|
995
|
+
if (method !== 0) throw new Error(`archive entry "${name}" is compressed; only stored entries are expected`);
|
|
996
|
+
if (dataAt + size > zip.length) throw new Error(`archive entry "${name}" is truncated`);
|
|
997
|
+
const target = resolve(root, archiveEntryPath(name, root));
|
|
998
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
999
|
+
writeFileSync(target, zip.subarray(dataAt, dataAt + size));
|
|
1000
|
+
written.push(name);
|
|
1001
|
+
at = dataAt + size;
|
|
1002
|
+
}
|
|
1003
|
+
if (!written.length) throw new Error("the archive contained no files");
|
|
1004
|
+
return written;
|
|
1005
|
+
}
|
|
1006
|
+
__name(extractStoredZip, "extractStoredZip");
|
|
1007
|
+
function guessResPath(cwd, dir) {
|
|
1008
|
+
const target = resolve(abs(cwd, dir));
|
|
1009
|
+
const root = resolve(cwd);
|
|
1010
|
+
if (target === root || !pathInside(root, target)) return void 0;
|
|
1011
|
+
return `res://${relative(root, target).split("\\").join("/")}/`;
|
|
1012
|
+
}
|
|
1013
|
+
__name(guessResPath, "guessResPath");
|
|
1014
|
+
var SPRITE_DIRECTION_SETS = {
|
|
1015
|
+
one: { animations: 1, billed: 1 },
|
|
1016
|
+
four: { animations: 4, billed: 3 },
|
|
1017
|
+
eight: { animations: 8, billed: 5 }
|
|
1018
|
+
};
|
|
1019
|
+
function spritePollWorthRetrying(status) {
|
|
1020
|
+
return status === void 0 || status === 429 || status >= 500;
|
|
1021
|
+
}
|
|
1022
|
+
__name(spritePollWorthRetrying, "spritePollWorthRetrying");
|
|
1023
|
+
var SPRITE_POLL_FAILURE_BUDGET = 10;
|
|
1024
|
+
function stashBundle(bundle, jobId) {
|
|
1025
|
+
try {
|
|
1026
|
+
const path = join(tmpdir(), `privateer-sprite-${jobId.replace(/[^A-Za-z0-9_-]/g, "")}.zip`);
|
|
1027
|
+
writeFileSync(path, bundle);
|
|
1028
|
+
return path;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
__name(stashBundle, "stashBundle");
|
|
1034
|
+
async function awaitSpriteJob(jobId, cwd, dir, expectedAnimations, billed, signal) {
|
|
1035
|
+
const deadline = Date.now() + SPRITE_POLL_TIMEOUT_MS;
|
|
1036
|
+
const resumeHint = `Resume it with generate_sprite { resumeJobId: "${jobId}", dir: "${dir}" } \u2014 that waits on this same job and bills nothing further.`;
|
|
1037
|
+
const cancelled = /* @__PURE__ */ __name(() => text(
|
|
1038
|
+
`Sprite job ${jobId} was submitted but the wait was cancelled. Its ${billed ?? "queued"} clip(s) are still rendering and will still be billed. ` + resumeHint
|
|
1039
|
+
), "cancelled");
|
|
1040
|
+
let consecutiveFailures = 0;
|
|
1041
|
+
for (; ; ) {
|
|
1042
|
+
if (signal?.aborted) return cancelled();
|
|
1043
|
+
await sleep(SPRITE_POLL_INTERVAL_MS, signal);
|
|
1044
|
+
if (signal?.aborted) return cancelled();
|
|
1045
|
+
const poll = await callAccount(
|
|
1046
|
+
`/api/agent/media/sprites/${encodeURIComponent(jobId)}`,
|
|
1047
|
+
{ method: "GET", signal }
|
|
1048
|
+
);
|
|
1049
|
+
if (!poll.ok) {
|
|
1050
|
+
if (!spritePollWorthRetrying(poll.status)) {
|
|
1051
|
+
return text(`Sprite job ${jobId} could not be polled: ${poll.message}`);
|
|
1052
|
+
}
|
|
1053
|
+
if (++consecutiveFailures >= SPRITE_POLL_FAILURE_BUDGET || Date.now() > deadline) {
|
|
1054
|
+
return text(
|
|
1055
|
+
`Sprite job ${jobId} could not be polled ${consecutiveFailures} times in a row: ${poll.message}. The clips are still rendering and are still billed. ${resumeHint}`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
consecutiveFailures = 0;
|
|
1061
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
1062
|
+
if (status === "failed") {
|
|
1063
|
+
return text(`Sprite generation failed: ${poll.data.error?.message ?? poll.data.message ?? "the provider reported a failure"}.`);
|
|
1064
|
+
}
|
|
1065
|
+
if (status === "completed") {
|
|
1066
|
+
if (!poll.data.zip_base64) {
|
|
1067
|
+
return text(`Sprite job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the files are missing.`);
|
|
1068
|
+
}
|
|
1069
|
+
const destination = abs(cwd, dir);
|
|
1070
|
+
const bundle = Buffer.from(poll.data.zip_base64, "base64");
|
|
1071
|
+
const stash = stashBundle(bundle, jobId);
|
|
1072
|
+
let written;
|
|
1073
|
+
try {
|
|
1074
|
+
written = extractStoredZip(bundle, destination);
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
1077
|
+
return text(
|
|
1078
|
+
`Sprite job ${jobId} rendered but the bundle could not be unpacked into ${destination}: ${why}. ` + (stash ? `The archive itself was saved to ${stash} \u2014 unzip it there; the clips are already paid for and will not be delivered again.` : "The archive could not be saved either, so the clips are lost.")
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
if (stash) {
|
|
1082
|
+
try {
|
|
1083
|
+
unlinkSync(stash);
|
|
1084
|
+
} catch {
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const tres = written.find((f) => f.endsWith(".tres"));
|
|
1088
|
+
const anims = poll.data.animations ?? [];
|
|
1089
|
+
const mirrored = anims.filter((a) => a.origin === "mirrored").length;
|
|
1090
|
+
const sheet = poll.data.sheet;
|
|
1091
|
+
const missing = expectedAnimations != null ? expectedAnimations - anims.length : 0;
|
|
1092
|
+
const lines = [
|
|
1093
|
+
`Generated sprite animation: ${written.length} files in ${destination}`,
|
|
1094
|
+
sheet ? `Sheet ${sheet.width}x${sheet.height}px, ${sheet.frame_width}x${sheet.frame_height} cells, ${sheet.columns}x${sheet.rows} grid.` : "",
|
|
1095
|
+
anims.length ? `Animations: ${anims.map((a) => a.name).join(", ")}${mirrored ? ` (${mirrored} mirrored, not billed)` : ""}.` : "",
|
|
1096
|
+
// Said out loud rather than left to be counted: a sheet short a facing is
|
|
1097
|
+
// not the sheet that was asked for, and the animation GDScript plays for
|
|
1098
|
+
// that direction is simply not in the resource.
|
|
1099
|
+
missing > 0 ? `WARNING: ${missing} of ${expectedAnimations} facings did not render and are NOT in the sheet or the .tres \u2014 playing them will fail in Godot. The facings that did land were billed. Re-run to try for the missing ones.` : "",
|
|
1100
|
+
tres ? `Set an AnimatedSprite2D's Sprite Frames to ${poll.data.res_path ?? "res://"}${tres.split("/").pop()}.` : "",
|
|
1101
|
+
"Set the sheet's texture Filter to Nearest in the Import dock, or the pixel art imports blurry.",
|
|
1102
|
+
// Surfaced rather than swallowed: the flat backdrop the clip was asked
|
|
1103
|
+
// for is a prompt the model can ignore, and when it does the key leaves
|
|
1104
|
+
// a rim. The caller can see it here instead of finding it in-game.
|
|
1105
|
+
poll.data.key_residue != null && poll.data.key_residue > 0.08 ? `NOTE: the background did not key cleanly (residue ${poll.data.key_residue.toFixed(2)}) \u2014 the frames may have a fringe. Re-run, or clean them up before shipping.` : ""
|
|
1106
|
+
].filter(Boolean);
|
|
1107
|
+
return text(lines.join("\n"));
|
|
1108
|
+
}
|
|
1109
|
+
if (Date.now() > deadline) {
|
|
1110
|
+
return text(
|
|
1111
|
+
`Sprite job ${jobId} is still ${status || "running"} after ${Math.round(SPRITE_POLL_TIMEOUT_MS / 6e4)} minutes. It will still complete and still be billed; nothing was saved here. ${resumeHint}`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
__name(awaitSpriteJob, "awaitSpriteJob");
|
|
1117
|
+
var generateSpriteToolDefinition = {
|
|
1118
|
+
name: "generate_sprite",
|
|
1119
|
+
label: "Generate Sprite Animation",
|
|
1120
|
+
description: "Generate a 2D SPRITE ANIMATION for a game engine \u2014 a packed sprite sheet, the individual frame PNGs, and a Godot 4 SpriteFrames .tres resource \u2014 from ONE picture of a character plus a description of how it moves. The files are written straight into your project directory, so an AnimatedSprite2D can use them without any further conversion. There is no text-to-sprite here: generate or find the character art first (generate_image works), look at it, and pass that path as `image` \u2014 every facing is derived from that one picture, which is what stops the character changing between frames.\nHOW IT IS BILLED, because it is not one generation: the motion is rendered as a short video per FACING and then sampled into frames. `directions: 'one'` costs one video generation, 'four' costs THREE, and 'eight' costs FIVE \u2014 the left-facing animations are mirrored from the right-facing ones rather than rendered, which is why eight animations cost five clips and not eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely expensive; say the total to the user before batching characters.\nTWO models are involved, which matters when one of them is down AND when you are quoting a price: an IMAGE model turns the picture to face each direction (only for `directions` 'four' and 'eight'), then a VIDEO model renders the motion per facing. `image_model` and `model` override them separately. The turns are billed too, at the image rate \u2014 one still per billed facing after the first, so 'four' draws two and 'eight' draws four. They are small beside a clip, but a total that counts only clips is short; media_capabilities reports both numbers per direction set (`billedClips` and `billedTurnStills`).\nIt takes several minutes \u2014 the picture is turned to face each direction one at a time before anything is billed, then the clips render together \u2014 and this tool waits. Frame count, cell size and frame rate are chosen here and cost nothing extra.\nIf a call comes back saying the job is still running, or that it could not be polled, DO NOT call this again with the same image and prompt \u2014 that bills a whole second fan-out. Call it with `resumeJobId` set to the job id it reported (and the same `dir`) to keep waiting on the clips the account has already paid for.\nAVAILABILITY: this needs a video decoder on the Privateer API and some deployments do not have one \u2014 call media_capabilities and check `sprites.available` before spending, or you will get a clear refusal instead of a sheet. PRIVACY: video and image models have no zero-retention option, so this is gated the way 3D is \u2014 a ZDR account must have enabled non-ZDR media.",
|
|
1121
|
+
parameters: typebox_exports.Object({
|
|
1122
|
+
image: typebox_exports.String({
|
|
1123
|
+
description: "Path to ONE picture of the character, ideally full-body, centred and facing the viewer. Every other facing is generated as an edit of this image, so its framing sets the framing of the whole sheet."
|
|
1124
|
+
}),
|
|
1125
|
+
prompt: typebox_exports.String({
|
|
1126
|
+
description: "How the character MOVES, not what it looks like \u2014 'walking at a steady pace', 'swinging a sword overhead', 'idle, breathing'. The appearance comes from `image`; describing it again here only competes with the picture."
|
|
1127
|
+
}),
|
|
1128
|
+
dir: typebox_exports.String({
|
|
1129
|
+
description: "Directory to write the sheet, frames and .tres into, relative to cwd or absolute (e.g. 'sprites/knight'). It is created if missing. When it sits inside cwd, the res:// path baked into the .tres is derived from it, so running at your Godot project root means the resource resolves with nothing to edit."
|
|
1130
|
+
}),
|
|
1131
|
+
resumeJobId: typebox_exports.Optional(typebox_exports.String({
|
|
1132
|
+
description: "Resume waiting on a job already submitted (from a previous call that timed out or lost its poll). Nothing is generated and nothing is billed: it only polls and unpacks. `image`, `prompt` and every other setting are ignored \u2014 pass the same `dir`."
|
|
1133
|
+
})),
|
|
1134
|
+
action: typebox_exports.Optional(
|
|
1135
|
+
typebox_exports.String({
|
|
1136
|
+
description: `The animation-name stem, e.g. 'walk' \u2014 GDScript will play "walk_down", "walk_left" and so on. Defaults to 'anim'. Keep it lowercase and ASCII; it ends up in game code.`
|
|
1137
|
+
})
|
|
1138
|
+
),
|
|
1139
|
+
directions: typebox_exports.Optional(
|
|
1140
|
+
typebox_exports.Union(
|
|
1141
|
+
[typebox_exports.Literal("one"), typebox_exports.Literal("four"), typebox_exports.Literal("eight")],
|
|
1142
|
+
{
|
|
1143
|
+
description: "'one' (default, one animation, ONE clip billed), 'four' (down/right/up/left, THREE billed) or 'eight' (adds the diagonals, FIVE billed). Four is the usual choice for a top-down or 2.5D character; eight only if the game actually turns that finely. These three words are the only accepted values \u2014 not '4', not 'four-way'."
|
|
1144
|
+
}
|
|
1145
|
+
)
|
|
1146
|
+
),
|
|
1147
|
+
frames: typebox_exports.Optional(
|
|
1148
|
+
typebox_exports.Number({
|
|
1149
|
+
description: "Frames per animation, 2-24 (default 8). Sampled out of the rendered clip, so this costs nothing extra and can be chosen for the look: 8 is a classic walk cycle, 12+ is smoother and makes a larger sheet."
|
|
1150
|
+
})
|
|
1151
|
+
),
|
|
1152
|
+
frame_size: typebox_exports.Optional(
|
|
1153
|
+
typebox_exports.Number({
|
|
1154
|
+
description: "Cell size in pixels, 8-512 (default 64). Frames are downscaled with nearest-neighbour, so pixel art stays crisp. Pick the size the game actually draws at."
|
|
1155
|
+
})
|
|
1156
|
+
),
|
|
1157
|
+
fps: typebox_exports.Optional(
|
|
1158
|
+
typebox_exports.Number({ description: "Playback rate written into the resource, 1-120 (default 12)." })
|
|
1159
|
+
),
|
|
1160
|
+
loop: typebox_exports.Optional(
|
|
1161
|
+
typebox_exports.Boolean({ description: "Whether the animations loop (default true)." })
|
|
1162
|
+
),
|
|
1163
|
+
name: typebox_exports.Optional(
|
|
1164
|
+
typebox_exports.String({ description: "Name for the sprite; sets the file names. Defaults to `action`." })
|
|
1165
|
+
),
|
|
1166
|
+
res_path: typebox_exports.Optional(
|
|
1167
|
+
typebox_exports.String({
|
|
1168
|
+
description: "Override the res:// folder the .tres points at, e.g. 'res://art/mobs/'. Only needed when `dir` is not inside your Godot project root \u2014 otherwise it is derived from `dir`."
|
|
1169
|
+
})
|
|
1170
|
+
),
|
|
1171
|
+
model: typebox_exports.Optional(
|
|
1172
|
+
typebox_exports.String({ description: "Video model id to render the motion with. Omit for the one chosen with /video-model, or the account default." })
|
|
1173
|
+
),
|
|
1174
|
+
image_model: typebox_exports.Optional(
|
|
1175
|
+
typebox_exports.String({
|
|
1176
|
+
description: "Image model id used to TURN the picture to face each direction, e.g. 'google/gemini-3.1-flash-image'. Omit for the one chosen with /image-model, or the account default. Only used when `directions` is 'four' or 'eight' \u2014 'one' renders no turns and never touches an image model. Worth setting when a run fails with SPRITE_IMAGE_MODEL_UNAVAILABLE: that is the image provider being down, not the request being wrong, and naming another model here retries the whole run for nothing (the turns are drawn before any clip is billed). media_capabilities lists the account's current image model."
|
|
1177
|
+
})
|
|
1178
|
+
)
|
|
1179
|
+
}),
|
|
1180
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1181
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
1182
|
+
if (!params.dir) return text("Error: dir is required \u2014 say where to write the sheet and the .tres.");
|
|
1183
|
+
const resumeJobId = String(params.resumeJobId ?? "").trim();
|
|
1184
|
+
if (resumeJobId) return awaitSpriteJob(resumeJobId, cwd, params.dir, null, null, signal);
|
|
1185
|
+
if (!params.image) return text("Error: image is required \u2014 sprite generation derives every facing from one picture.");
|
|
1186
|
+
if (!params.prompt?.trim()) return text("Error: prompt is required \u2014 describe how the character moves.");
|
|
1187
|
+
const directions = params.directions ?? "one";
|
|
1188
|
+
if (!Object.prototype.hasOwnProperty.call(SPRITE_DIRECTION_SETS, directions)) {
|
|
1189
|
+
return text(`Error: directions must be 'one', 'four' or 'eight' \u2014 got ${JSON.stringify(params.directions)}.`);
|
|
1190
|
+
}
|
|
1191
|
+
const ranges = [
|
|
1192
|
+
["frames", params.frames, 2, 24],
|
|
1193
|
+
["frame_size", params.frame_size, 8, 512],
|
|
1194
|
+
["fps", params.fps, 1, 120]
|
|
1195
|
+
];
|
|
1196
|
+
for (const [label, value, min, max] of ranges) {
|
|
1197
|
+
if (value == null) continue;
|
|
1198
|
+
if (!Number.isFinite(value) || value < min || value > max) {
|
|
1199
|
+
return text(`Error: ${label} must be between ${min} and ${max} \u2014 got ${value}.`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
let seed;
|
|
1203
|
+
try {
|
|
1204
|
+
seed = readInputImage(cwd, params.image);
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
1207
|
+
}
|
|
1208
|
+
const submitted = await callAccount("/api/agent/media/sprites", {
|
|
1209
|
+
method: "POST",
|
|
1210
|
+
signal,
|
|
1211
|
+
body: {
|
|
1212
|
+
image: seed.data,
|
|
1213
|
+
prompt: params.prompt,
|
|
1214
|
+
...params.action ? { action: params.action } : {},
|
|
1215
|
+
...params.name ? { name: params.name } : {},
|
|
1216
|
+
directions,
|
|
1217
|
+
...params.frames != null ? { frames: params.frames } : {},
|
|
1218
|
+
...params.frame_size != null ? { frame_size: params.frame_size } : {},
|
|
1219
|
+
...params.fps != null ? { fps: params.fps } : {},
|
|
1220
|
+
...params.loop != null ? { loop: params.loop } : {},
|
|
1221
|
+
// The caller's own res:// wins; otherwise derive it from where the files
|
|
1222
|
+
// are going, which is right whenever the agent runs at the project root.
|
|
1223
|
+
...params.res_path ? { res_path: params.res_path } : (() => {
|
|
1224
|
+
const guessed = guessResPath(cwd, params.dir);
|
|
1225
|
+
return guessed ? { res_path: guessed } : {};
|
|
1226
|
+
})(),
|
|
1227
|
+
.../* @__PURE__ */ ((m) => m ? { model: m } : {})(effectiveMediaModel("video", params.model)),
|
|
1228
|
+
.../* @__PURE__ */ ((m) => m ? { image_model: m } : {})(effectiveMediaModel("image", params.image_model))
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
|
|
1232
|
+
const jobId = submitted.data.id;
|
|
1233
|
+
if (!jobId) return text("Sprite generation failed: Privateer did not return a job id.");
|
|
1234
|
+
const expected = submitted.data.animations ?? SPRITE_DIRECTION_SETS[directions].animations;
|
|
1235
|
+
return awaitSpriteJob(jobId, cwd, params.dir, expected, submitted.data.billed_facings ?? null, signal);
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
function makeMediaTools() {
|
|
1239
|
+
return (pi) => {
|
|
1240
|
+
pi.registerTool?.(generateImageToolDefinition);
|
|
1241
|
+
pi.registerTool?.(generateVideoToolDefinition);
|
|
1242
|
+
pi.registerTool?.(generateModelToolDefinition);
|
|
1243
|
+
pi.registerTool?.(generateSpriteToolDefinition);
|
|
1244
|
+
pi.registerTool?.(generateSpeechToolDefinition);
|
|
1245
|
+
pi.registerTool?.(generateMusicToolDefinition);
|
|
1246
|
+
pi.registerTool?.(generateSfxToolDefinition);
|
|
1247
|
+
pi.registerTool?.(mediaCapabilitiesToolDefinition);
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
__name(makeMediaTools, "makeMediaTools");
|
|
1251
|
+
export {
|
|
1252
|
+
MEDIA_HTTP_TIMEOUT_MS,
|
|
1253
|
+
MEDIA_TOOL_NAMES,
|
|
1254
|
+
deferredResumeHint,
|
|
1255
|
+
describeImageVideo,
|
|
1256
|
+
describeModel3d,
|
|
1257
|
+
describeSfx,
|
|
1258
|
+
describeSpeech,
|
|
1259
|
+
describeTransportFailure,
|
|
1260
|
+
extractStoredZip,
|
|
1261
|
+
generateImageToolDefinition,
|
|
1262
|
+
generateModelToolDefinition,
|
|
1263
|
+
generateMusicToolDefinition,
|
|
1264
|
+
generateSfxToolDefinition,
|
|
1265
|
+
generateSpeechToolDefinition,
|
|
1266
|
+
generateSpriteToolDefinition,
|
|
1267
|
+
generateVideoToolDefinition,
|
|
1268
|
+
guessResPath,
|
|
1269
|
+
makeMediaTools,
|
|
1270
|
+
mediaCapabilitiesToolDefinition,
|
|
1271
|
+
openSealedResult,
|
|
1272
|
+
pathInside,
|
|
1273
|
+
quoteMediaCallUsd,
|
|
1274
|
+
readMediaCapabilities,
|
|
1275
|
+
spritePollWorthRetrying
|
|
1276
|
+
};
|