privateer-agent 0.12.21 → 0.12.23

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.
@@ -47,6 +47,7 @@ export const MEDIA_TOOL_NAMES = [
47
47
  "generate_image",
48
48
  "generate_video",
49
49
  "generate_model",
50
+ "generate_sprite",
50
51
  "generate_speech",
51
52
  "generate_music",
52
53
  "generate_sfx",
@@ -63,6 +64,12 @@ const VIDEO_POLL_INTERVAL_MS = 5_000;
63
64
  // id is reported on timeout so the caller can resume the poll rather than pay
64
65
  // for a second generation.
65
66
  const MESH_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_MESH_TIMEOUT_MS) || 10 * 60_000;
67
+ // A sprite job renders one clip per BILLED facing, sequentially, so an eight-way
68
+ // set waits on five video generations rather than one. The ceiling is
69
+ // correspondingly generous; as with video, the job id is reported on timeout so
70
+ // the caller can resume the poll rather than pay for another run.
71
+ const SPRITE_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_SPRITE_TIMEOUT_MS) || 25 * 60_000;
72
+ const SPRITE_POLL_INTERVAL_MS = 6_000;
66
73
  const MESH_POLL_INTERVAL_MS = 5_000;
67
74
  // Four reference views at 8 MB each would be ~43 MB of base64 — past the
68
75
  // server's own body limit, so the request would be refused by a JSON parser with
@@ -1036,6 +1043,297 @@ export const mediaCapabilitiesToolDefinition = {
1036
1043
  },
1037
1044
  };
1038
1045
 
1046
+ interface SpriteSubmitResponse {
1047
+ id?: string;
1048
+ status?: string;
1049
+ billed_facings?: number;
1050
+ mirrored_facings?: number;
1051
+ animations?: number;
1052
+ message?: string;
1053
+ }
1054
+
1055
+ interface SpriteStatusResponse {
1056
+ id?: string;
1057
+ status?: string;
1058
+ zip_base64?: string;
1059
+ bytes?: number;
1060
+ sheet?: { width: number; height: number; columns: number; rows: number; frame_width: number; frame_height: number };
1061
+ animations?: { name: string; direction: string; origin: string }[];
1062
+ res_path?: string;
1063
+ key_residue?: number;
1064
+ error?: { message?: string };
1065
+ message?: string;
1066
+ }
1067
+
1068
+ /**
1069
+ * Unpack the bundle into a directory.
1070
+ *
1071
+ * A hand-rolled reader rather than a dependency, and it is about thirty lines
1072
+ * because the archive is STORED — Privateer writes no compressed entries (the
1073
+ * payload is already PNG, so deflating it twice buys nothing), which means every
1074
+ * entry is a header followed by its bytes verbatim.
1075
+ *
1076
+ * ZIP-SLIP: entry names come off the wire, so each resolved path is checked to
1077
+ * be inside the destination before anything is written. A `..` segment here
1078
+ * would let a generated archive write anywhere the agent can reach, which on an
1079
+ * unattended run is the user's whole machine.
1080
+ */
1081
+ export function extractStoredZip(zip: Buffer, destDir: string): string[] {
1082
+ const written: string[] = [];
1083
+ const root = resolve(destDir);
1084
+ let at = 0;
1085
+
1086
+ while (at + 30 <= zip.length && zip.readUInt32LE(at) === 0x04034b50) {
1087
+ const method = zip.readUInt16LE(at + 8);
1088
+ const size = zip.readUInt32LE(at + 18);
1089
+ const nameLen = zip.readUInt16LE(at + 26);
1090
+ const extraLen = zip.readUInt16LE(at + 28);
1091
+ const name = zip.toString("utf8", at + 30, at + 30 + nameLen);
1092
+ const dataAt = at + 30 + nameLen + extraLen;
1093
+
1094
+ if (method !== 0) throw new Error(`archive entry "${name}" is compressed; only stored entries are expected`);
1095
+ if (dataAt + size > zip.length) throw new Error(`archive entry "${name}" is truncated`);
1096
+
1097
+ const target = resolve(root, name);
1098
+ if (target !== root && !target.startsWith(root + "/")) {
1099
+ throw new Error(`archive entry "${name}" escapes the destination directory`);
1100
+ }
1101
+ mkdirSync(dirname(target), { recursive: true });
1102
+ writeFileSync(target, zip.subarray(dataAt, dataAt + size));
1103
+ written.push(name);
1104
+
1105
+ at = dataAt + size;
1106
+ }
1107
+
1108
+ if (!written.length) throw new Error("the archive contained no files");
1109
+ return written;
1110
+ }
1111
+
1112
+ /**
1113
+ * Guess the res:// path from where the files are being written.
1114
+ *
1115
+ * The .tres refers to its sheet by an absolute res:// path, so getting it wrong
1116
+ * means the caller opens Godot and edits a line by hand. In the overwhelmingly
1117
+ * common case the agent is running AT the project root, so the directory
1118
+ * relative to cwd IS the res:// path — deriving it is right far more often than
1119
+ * a fixed default would be. Returns undefined when the target is outside cwd,
1120
+ * where the guess would be worse than letting the server default.
1121
+ */
1122
+ export function guessResPath(cwd: string, dir: string): string | undefined {
1123
+ const target = resolve(abs(cwd, dir));
1124
+ const root = resolve(cwd);
1125
+ if (target === root || !target.startsWith(root + "/")) return undefined;
1126
+ return `res://${target.slice(root.length + 1).split("\\").join("/")}/`;
1127
+ }
1128
+
1129
+ export const generateSpriteToolDefinition = {
1130
+ name: "generate_sprite",
1131
+ label: "Generate Sprite Animation",
1132
+ description:
1133
+ "Generate a 2D SPRITE ANIMATION for a game engine — a packed sprite sheet, the individual frame " +
1134
+ "PNGs, and a Godot 4 SpriteFrames .tres resource — from ONE picture of a character plus a " +
1135
+ "description of how it moves. The files are written straight into your project directory, so an " +
1136
+ "AnimatedSprite2D can use them without any further conversion. There is no text-to-sprite here: " +
1137
+ "generate or find the character art first (generate_image works), look at it, and pass that path " +
1138
+ "as `image` — every facing is derived from that one picture, which is what stops the character " +
1139
+ "changing between frames.\n" +
1140
+ "HOW IT IS BILLED, because it is not one generation: the motion is rendered as a short video per " +
1141
+ "FACING and then sampled into frames. `directions: 'one'` costs one video generation, 'four' " +
1142
+ "costs THREE, and 'eight' costs FIVE — the left-facing animations are mirrored from the " +
1143
+ "right-facing ones rather than rendered, which is why eight animations cost five clips and not " +
1144
+ "eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely " +
1145
+ "expensive; say the total to the user before batching characters.\n" +
1146
+ "It takes several minutes (the clips render sequentially) and this tool waits. Frame count, cell " +
1147
+ "size and frame rate are chosen here and cost nothing extra. AVAILABILITY: this needs a video " +
1148
+ "decoder on the Privateer API and some deployments do not have one — call media_capabilities and " +
1149
+ "check `sprites.available` before spending, or you will get a clear refusal instead of a sheet. " +
1150
+ "PRIVACY: video and image models have no zero-retention option, so this is gated the way 3D is — " +
1151
+ "a ZDR account must have enabled non-ZDR media.",
1152
+ parameters: Type.Object({
1153
+ image: Type.String({
1154
+ description:
1155
+ "Path to ONE picture of the character, ideally full-body, centred and facing the viewer. " +
1156
+ "Every other facing is generated as an edit of this image, so its framing sets the framing of " +
1157
+ "the whole sheet.",
1158
+ }),
1159
+ prompt: Type.String({
1160
+ description:
1161
+ "How the character MOVES, not what it looks like — 'walking at a steady pace', 'swinging a " +
1162
+ "sword overhead', 'idle, breathing'. The appearance comes from `image`; describing it again " +
1163
+ "here only competes with the picture.",
1164
+ }),
1165
+ dir: Type.String({
1166
+ description:
1167
+ "Directory to write the sheet, frames and .tres into, relative to cwd or absolute " +
1168
+ "(e.g. 'sprites/knight'). It is created if missing. When it sits inside cwd, the res:// path " +
1169
+ "baked into the .tres is derived from it, so running at your Godot project root means the " +
1170
+ "resource resolves with nothing to edit.",
1171
+ }),
1172
+ action: Type.Optional(
1173
+ Type.String({
1174
+ description:
1175
+ "The animation-name stem, e.g. 'walk' — GDScript will play \"walk_down\", \"walk_left\" and so " +
1176
+ "on. Defaults to 'anim'. Keep it lowercase and ASCII; it ends up in game code.",
1177
+ }),
1178
+ ),
1179
+ directions: Type.Optional(
1180
+ Type.String({
1181
+ description:
1182
+ "'one' (default, one animation, ONE clip billed), 'four' (down/right/up/left, THREE billed) " +
1183
+ "or 'eight' (adds the diagonals, FIVE billed). Four is the usual choice for a top-down or " +
1184
+ "2.5D character; eight only if the game actually turns that finely.",
1185
+ }),
1186
+ ),
1187
+ frames: Type.Optional(
1188
+ Type.Number({
1189
+ description:
1190
+ "Frames per animation, 2-24 (default 8). Sampled out of the rendered clip, so this costs " +
1191
+ "nothing extra and can be chosen for the look: 8 is a classic walk cycle, 12+ is smoother " +
1192
+ "and makes a larger sheet.",
1193
+ }),
1194
+ ),
1195
+ frame_size: Type.Optional(
1196
+ Type.Number({
1197
+ description:
1198
+ "Cell size in pixels, 8-512 (default 64). Frames are downscaled with nearest-neighbour, so " +
1199
+ "pixel art stays crisp. Pick the size the game actually draws at.",
1200
+ }),
1201
+ ),
1202
+ fps: Type.Optional(
1203
+ Type.Number({ description: "Playback rate written into the resource, 1-120 (default 12)." }),
1204
+ ),
1205
+ loop: Type.Optional(
1206
+ Type.Boolean({ description: "Whether the animations loop (default true)." }),
1207
+ ),
1208
+ name: Type.Optional(
1209
+ Type.String({ description: "Name for the sprite; sets the file names. Defaults to `action`." }),
1210
+ ),
1211
+ res_path: Type.Optional(
1212
+ Type.String({
1213
+ description:
1214
+ "Override the res:// folder the .tres points at, e.g. 'res://art/mobs/'. Only needed when " +
1215
+ "`dir` is not inside your Godot project root — otherwise it is derived from `dir`.",
1216
+ }),
1217
+ ),
1218
+ model: Type.Optional(
1219
+ Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
1220
+ ),
1221
+ }),
1222
+ async execute(
1223
+ _toolCallId: string,
1224
+ params: {
1225
+ image: string; prompt: string; dir: string; action?: string; directions?: string;
1226
+ frames?: number; frame_size?: number; fps?: number; loop?: boolean;
1227
+ name?: string; res_path?: string; model?: string;
1228
+ },
1229
+ signal?: AbortSignal,
1230
+ _onUpdate?: unknown,
1231
+ ctx?: { cwd?: string },
1232
+ ) {
1233
+ const cwd = ctx?.cwd ?? process.cwd();
1234
+ if (!params.image) return text("Error: image is required — sprite generation derives every facing from one picture.");
1235
+ if (!params.prompt?.trim()) return text("Error: prompt is required — describe how the character moves.");
1236
+ if (!params.dir) return text("Error: dir is required — say where to write the sheet and the .tres.");
1237
+
1238
+ let seed: { data: string; mimeType: string };
1239
+ try {
1240
+ seed = readInputImage(cwd, params.image);
1241
+ } catch (e) {
1242
+ return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
1243
+ }
1244
+
1245
+ const submitted = await callAccount<SpriteSubmitResponse>("/api/agent/media/sprites", {
1246
+ method: "POST",
1247
+ signal,
1248
+ body: {
1249
+ image: seed.data,
1250
+ prompt: params.prompt,
1251
+ ...(params.action ? { action: params.action } : {}),
1252
+ ...(params.name ? { name: params.name } : {}),
1253
+ ...(params.directions ? { directions: params.directions } : {}),
1254
+ ...(params.frames != null ? { frames: params.frames } : {}),
1255
+ ...(params.frame_size != null ? { frame_size: params.frame_size } : {}),
1256
+ ...(params.fps != null ? { fps: params.fps } : {}),
1257
+ ...(params.loop != null ? { loop: params.loop } : {}),
1258
+ // The caller's own res:// wins; otherwise derive it from where the files
1259
+ // are going, which is right whenever the agent runs at the project root.
1260
+ ...(params.res_path
1261
+ ? { res_path: params.res_path }
1262
+ : (() => {
1263
+ const guessed = guessResPath(cwd, params.dir);
1264
+ return guessed ? { res_path: guessed } : {};
1265
+ })()),
1266
+ ...(params.model ? { model: params.model } : {}),
1267
+ },
1268
+ });
1269
+ if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
1270
+ const jobId = submitted.data.id;
1271
+ if (!jobId) return text("Sprite generation failed: Privateer did not return a job id.");
1272
+
1273
+ const billed = submitted.data.billed_facings;
1274
+ const deadline = Date.now() + SPRITE_POLL_TIMEOUT_MS;
1275
+ // The clips are charged as they land, so an abandoned poll still costs money —
1276
+ // hence every exit below names the job id and says so plainly.
1277
+ const cancelled = () =>
1278
+ text(`Sprite job ${jobId} was submitted but the wait was cancelled. Its ${billed ?? "queued"} clip(s) are still rendering and will still be billed.`);
1279
+
1280
+ for (;;) {
1281
+ if (signal?.aborted) return cancelled();
1282
+ await sleep(SPRITE_POLL_INTERVAL_MS, signal);
1283
+ if (signal?.aborted) return cancelled();
1284
+
1285
+ const poll = await callAccount<SpriteStatusResponse>(
1286
+ `/api/agent/media/sprites/${encodeURIComponent(jobId)}`,
1287
+ { method: "GET", signal },
1288
+ );
1289
+ if (!poll.ok) return text(`Sprite job ${jobId} could not be polled: ${poll.message}`);
1290
+
1291
+ const status = String(poll.data.status ?? "").toLowerCase();
1292
+ if (status === "failed") {
1293
+ return text(`Sprite generation failed: ${poll.data.error?.message ?? poll.data.message ?? "the provider reported a failure"}.`);
1294
+ }
1295
+ if (status === "completed") {
1296
+ if (!poll.data.zip_base64) {
1297
+ return text(`Sprite job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the files are missing.`);
1298
+ }
1299
+ const destination = abs(cwd, params.dir);
1300
+ let written: string[];
1301
+ try {
1302
+ written = extractStoredZip(Buffer.from(poll.data.zip_base64, "base64"), destination);
1303
+ } catch (e) {
1304
+ return text(`Sprite job ${jobId} rendered but the bundle could not be unpacked: ${e instanceof Error ? e.message : String(e)}`);
1305
+ }
1306
+
1307
+ const tres = written.find((f) => f.endsWith(".tres"));
1308
+ const anims = poll.data.animations ?? [];
1309
+ const mirrored = anims.filter((a) => a.origin === "mirrored").length;
1310
+ const sheet = poll.data.sheet;
1311
+
1312
+ const lines = [
1313
+ `Generated sprite animation: ${written.length} files in ${destination}`,
1314
+ sheet ? `Sheet ${sheet.width}x${sheet.height}px, ${sheet.frame_width}x${sheet.frame_height} cells, ${sheet.columns}x${sheet.rows} grid.` : "",
1315
+ anims.length ? `Animations: ${anims.map((a) => a.name).join(", ")}${mirrored ? ` (${mirrored} mirrored, not billed)` : ""}.` : "",
1316
+ tres ? `Set an AnimatedSprite2D's Sprite Frames to ${poll.data.res_path ?? "res://"}${tres.split("/").pop()}.` : "",
1317
+ "Set the sheet's texture Filter to Nearest in the Import dock, or the pixel art imports blurry.",
1318
+ // Surfaced rather than swallowed: the flat backdrop the clip was asked
1319
+ // for is a prompt the model can ignore, and when it does the key leaves
1320
+ // a rim. The caller can see it here instead of finding it in-game.
1321
+ poll.data.key_residue != null && poll.data.key_residue > 0.08
1322
+ ? `NOTE: the background did not key cleanly (residue ${poll.data.key_residue.toFixed(2)}) — the frames may have a fringe. Re-run, or clean them up before shipping.`
1323
+ : "",
1324
+ ].filter(Boolean);
1325
+ return text(lines.join("\n"));
1326
+ }
1327
+ if (Date.now() > deadline) {
1328
+ return text(
1329
+ `Sprite job ${jobId} is still ${status || "running"} after ${Math.round(SPRITE_POLL_TIMEOUT_MS / 60000)} minutes. ` +
1330
+ "It will still complete and still be billed; nothing was saved here.",
1331
+ );
1332
+ }
1333
+ }
1334
+ },
1335
+ };
1336
+
1039
1337
  /**
1040
1338
  * Extension factory registering every account-backed media tool. Used by the surfaces
1041
1339
  * that build their session from an explicit `extensionFactories` list (harbor, channels,
@@ -1051,6 +1349,7 @@ export function makeMediaTools() {
1051
1349
  pi.registerTool?.(generateImageToolDefinition);
1052
1350
  pi.registerTool?.(generateVideoToolDefinition);
1053
1351
  pi.registerTool?.(generateModelToolDefinition);
1352
+ pi.registerTool?.(generateSpriteToolDefinition);
1054
1353
  pi.registerTool?.(generateSpeechToolDefinition);
1055
1354
  pi.registerTool?.(generateMusicToolDefinition);
1056
1355
  pi.registerTool?.(generateSfxToolDefinition);
@@ -15,6 +15,7 @@ import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
15
15
  import { makeSaveAttachmentTool } from "./saveAttachment.ts";
16
16
  import { makeSaveCargoTool, type CargoSaveBridge } from "./cargo.ts";
17
17
  import { makeChartTools, type ChartOpBridge } from "./charts.ts";
18
+ import { makeSaveToLibraryTool, type LibrarySaveBridge } from "./saveToLibrary.ts";
18
19
  import type { AttachmentStore } from "../util/attachmentStore.ts";
19
20
 
20
21
  // save_cargo rides with the file pair rather than with the media tools, because it
@@ -22,17 +23,27 @@ import type { AttachmentStore } from "../util/attachmentStore.ts";
22
23
  // account. Registering it in the moat's media block would put it in every harbor and
23
24
  // channels session, where there is no controller and every call would fail — see
24
25
  // remote/cargoSave.ts on why unattended runs deliver an artifact a different way.
26
+ // save_to_library rides here for save_cargo's reason exactly — it needs a CONNECTED
27
+ // APP rather than a signed-in account, because only the app holds the master key that
28
+ // a Library row's ciphertext is under. Putting it in the moat's media block would give
29
+ // it to every harbor and channels session, where there is no controller and every call
30
+ // would fail; an unattended run already delivers a file a different way, as a sealed
31
+ // attachment on its Inbox result (routines/resultMedia.ts).
25
32
  // The chart tools ride here too, for the same reason save_cargo does and one more of
26
33
  // their own. Same reason: they need a CONNECTED APP, not a signed-in account, so the
27
34
  // moat's media block would put them in every harbor and channels session where there is
28
35
  // no controller and every call would fail. Their own reason: unlike cargo they also READ
29
36
  // the user's stored content, so an unattended session that could call them would be a
30
37
  // terminal asking for decrypted chat content with nobody watching the request.
31
- export function makeRelayFileTools(bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge, attachments: AttachmentStore) {
38
+ export function makeRelayFileTools(
39
+ bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge & LibrarySaveBridge,
40
+ attachments: AttachmentStore,
41
+ ) {
32
42
  return function relayFileTools(pi: any): void {
33
43
  pi.registerTool?.(makeSendFileTool(bridge));
34
44
  pi.registerTool?.(makeSaveAttachmentTool(attachments));
35
45
  pi.registerTool?.(makeSaveCargoTool(bridge));
46
+ pi.registerTool?.(makeSaveToLibraryTool(bridge));
36
47
  for (const tool of makeChartTools(bridge)) pi.registerTool?.(tool);
37
48
  };
38
49
  }
@@ -0,0 +1,181 @@
1
+ // The `save_to_library` tool — put a file from disk into the user's Privateer
2
+ // account, where it lives beside everything else they own and reaches every
3
+ // device they have signed in.
4
+ //
5
+ // The interesting part is not this file, it's why the save is a round trip
6
+ // through the app at all — src/remote/librarySave.ts has that (short version:
7
+ // the terminal holds no master key, and every Library row is ciphertext). What
8
+ // matters here is the shape that follows from it:
9
+ //
10
+ // TAKES A PATH, NOT CONTENT. Same call save_cargo makes, and here it isn't even
11
+ // close: a file may be 25 MB, and no amount of it belongs in a tool call. The
12
+ // model WRITES the file with its ordinary tools — or generates it, which is the
13
+ // common case, since every generate_* tool already names an output path — and
14
+ // passes that path. The two compose without either knowing about the other:
15
+ // generate_image writes a PNG, save_to_library puts it in the user's pocket.
16
+ //
17
+ // DOES NOT CHOOSE WHERE IT LANDS, AND SAYS SO. There is deliberately no
18
+ // `destination: 'local' | 'cloud'` parameter, and adding one would be a
19
+ // privacy bug rather than a feature. Whether an account's files live on its
20
+ // device or in our cloud is a setting the person owns (treeview CLAUDE.md §2);
21
+ // a terminal that could override it could put bytes on our servers for someone
22
+ // who chose device-only storage. So the app decides from the account, and this
23
+ // tool REPORTS which one happened — the success line names it, so the model can
24
+ // tell the user where their file actually is without ever having picked.
25
+ //
26
+ // Which SHELF it lands on is the app's call for a smaller reason: the app
27
+ // classifies a file the same way it classifies one the user drags in, and a
28
+ // second table on this side would drift into filing a generated PNG under
29
+ // Documents. The result names the shelf so the model can say where to look.
30
+ //
31
+ // SAYS WHAT DID AND DIDN'T TRAVEL. Like save_cargo and unlike the generate_*
32
+ // tools, this is genuinely end-to-end encrypted: the app encrypts before the
33
+ // upload and the server stores ciphertext it cannot read. A model that can't
34
+ // tell the two apart will describe generation with this one's guarantees, so
35
+ // the description states it plainly and the success line repeats it.
36
+
37
+ import { Type } from "typebox";
38
+ import { existsSync, readFileSync, statSync } from "node:fs";
39
+ import { basename, extname, isAbsolute, resolve } from "node:path";
40
+ import {
41
+ MAX_LIBRARY_SAVE_BYTES,
42
+ libraryMediaTypeForPath,
43
+ type LibrarySaveRequest,
44
+ type LibrarySaveResult,
45
+ } from "../remote/librarySave.ts";
46
+
47
+ function text(t: string) {
48
+ return { content: [{ type: "text", text: t }], details: {} };
49
+ }
50
+
51
+ /** The bridge surface this tool needs; RemoteBridge implements it. */
52
+ export interface LibrarySaveBridge {
53
+ saveToLibraryRemote(req: LibrarySaveRequest, signal?: AbortSignal): Promise<LibrarySaveResult>;
54
+ }
55
+
56
+ export const LIBRARY_TOOL_NAMES = ["save_to_library"] as const;
57
+
58
+ /** Where the app filed it, in the words the app's own navigation uses. */
59
+ const SHELF_LABEL: Record<string, string> = {
60
+ image: "Images",
61
+ video: "Videos",
62
+ audio: "Audio",
63
+ model3d: "Models",
64
+ document: "Documents",
65
+ };
66
+
67
+ export function makeSaveToLibraryTool(bridge: LibrarySaveBridge) {
68
+ return {
69
+ name: "save_to_library",
70
+ label: "Save to Library",
71
+ description:
72
+ "Save a file from disk into the user's Privateer account, so it appears in their Library on every " +
73
+ "device they're signed in on rather than only in this working directory. Use whenever the user asks " +
74
+ "to KEEP something you made or found — an image, a video, an audio clip, a 3D model, a report, a " +
75
+ "spreadsheet. Write or generate the file first with your normal tools and pass its path.\n" +
76
+ "Handles images (png/jpg/gif/webp), video (mp4/mov/webm), audio (mp3/wav/m4a/aac/ogg/flac), 3D " +
77
+ "models (glb/obj/fbx/usdz) and documents (pdf, docx, csv, and text or code files). Each lands on the " +
78
+ "matching shelf in the Library — you don't choose which, and the result tells you where it went so " +
79
+ "you can say. Max 25 MB; a bigger file, or a format not in that list, can still be handed over with " +
80
+ "send_file_to_client, which shows it on the user's device without filing it.\n" +
81
+ "You also do NOT choose whether it goes to the cloud or stays on the device — that follows the " +
82
+ "user's own storage setting, and the result reports which one happened. Say what it reports; don't " +
83
+ "assume cloud.\n" +
84
+ "Needs the Privateer app attached to this terminal: the app holds the key, and it encrypts the file " +
85
+ "on the device before storing it, so the contents are never readable by the server. That is a " +
86
+ "stronger guarantee than the generate_* tools have — do not describe those the same way.",
87
+ parameters: Type.Object({
88
+ path: Type.String({
89
+ description: "Path of the file to save, relative to cwd or absolute (e.g. 'out/cover.png').",
90
+ }),
91
+ name: Type.Optional(
92
+ Type.String({
93
+ description:
94
+ "Name to file it under in the Library, WITH its extension. Say what the thing is, the way the " +
95
+ "user would name it ('Q3 Expenses.csv', 'Harbour at dusk.png') — not a build path. Defaults to " +
96
+ "the file's own name, which is usually worse.",
97
+ }),
98
+ ),
99
+ note: Type.Optional(
100
+ Type.String({
101
+ description:
102
+ "One line on where it came from — the prompt that drew it, the command that produced it. " +
103
+ "Stored encrypted alongside the file. Not a title: the Library titles the row from `name`.",
104
+ }),
105
+ ),
106
+ }),
107
+ async execute(
108
+ _toolCallId: string,
109
+ params: { path: string; name?: string; note?: string },
110
+ signal?: AbortSignal,
111
+ _onUpdate?: unknown,
112
+ ctx?: { cwd?: string },
113
+ ) {
114
+ if (!params.path) return text("Error: path is required — say which file to save.");
115
+ const cwd = ctx?.cwd ?? process.cwd();
116
+ const target = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
117
+
118
+ if (!existsSync(target)) return text(`File not found: ${params.path}`);
119
+ const stat = statSync(target);
120
+ if (stat.isDirectory()) return text(`${params.path} is a directory — save a single file.`);
121
+ if (stat.size === 0) return text(`${params.path} is empty — nothing to save.`);
122
+ if (stat.size > MAX_LIBRARY_SAVE_BYTES) {
123
+ return text(
124
+ `${params.path} is ${(stat.size / 1048576).toFixed(1)} MB; saving to the library caps at ` +
125
+ `${MAX_LIBRARY_SAVE_BYTES / 1048576} MB. Use send_file_to_client to hand it to the user's device instead.`,
126
+ );
127
+ }
128
+
129
+ // The NAME decides the shelf, not the path — the app classifies on the name,
130
+ // and the name is what the user will see. So the extension has to survive a
131
+ // rename, and a model asked for a human title will reliably drop it: told to
132
+ // name a file the way the user would, it answers 'Q3 Expenses', not
133
+ // 'Q3 Expenses.csv'. Borrowing the source file's extension is the honest
134
+ // repair — it keeps the name the model chose AND the type the bytes actually
135
+ // are. Refusing instead would reject a request that is right about everything
136
+ // that matters, and dropping the name silently would have the model tell the
137
+ // user about a file called something it isn't.
138
+ const named = params.name?.trim();
139
+ const fileName = named ? (extname(named) ? named : named + extname(target)) : basename(target);
140
+ const mediaType = libraryMediaTypeForPath(fileName);
141
+ if (!mediaType) {
142
+ const ext = extname(fileName);
143
+ return text(
144
+ `${fileName} can't go in the library: ${ext || "a file with no extension"} isn't a format it holds. ` +
145
+ `The library holds images (png/jpg/gif/webp), video (mp4/mov/webm), audio (mp3/wav/m4a/aac/ogg/flac), ` +
146
+ `3D models (glb/obj/fbx/usdz) and documents (pdf, docx, csv, text and code files). ` +
147
+ `To put any other file on the user's device, use send_file_to_client.`,
148
+ );
149
+ }
150
+ let bytes: Buffer;
151
+ try {
152
+ bytes = readFileSync(target);
153
+ } catch (e) {
154
+ return text(`Couldn't read ${params.path}: ${(e as Error).message}`);
155
+ }
156
+
157
+ const res = await bridge.saveToLibraryRemote(
158
+ {
159
+ base64: bytes.toString("base64"),
160
+ size: bytes.length,
161
+ name: fileName,
162
+ mediaType,
163
+ note: params.note?.trim() || undefined,
164
+ },
165
+ signal,
166
+ );
167
+
168
+ if (!res.ok) return text(`Couldn't save ${fileName} to the library: ${res.reason}`);
169
+
170
+ const shelf = SHELF_LABEL[res.shelf] ?? res.shelf;
171
+ const where =
172
+ res.storageType === "local"
173
+ ? "It is stored on this account's device, which is where this account keeps its files — it is not on our servers"
174
+ : "It went to the account's cloud storage, encrypted on the device first, so the server holds only ciphertext";
175
+ return text(
176
+ `Saved "${res.name}" to the Library under ${shelf} (${(res.bytes / 1024).toFixed(0)} KB). ` +
177
+ `${where}. The user can open, download and share it from the Library in the Privateer app.`,
178
+ );
179
+ },
180
+ };
181
+ }