privateer-agent 0.12.21 → 0.12.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.21",
3
+ "version": "0.12.22",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@ import type { GateController } from "../ext/permissionGate.ts";
45
45
  import type { SendFileBridge } from "../tools/sendFile.ts";
46
46
  import type { CargoSaveBridge } from "../tools/cargo.ts";
47
47
  import type { ChartOpBridge } from "../tools/charts.ts";
48
+ import type { LibrarySaveBridge } from "../tools/saveToLibrary.ts";
48
49
  import type { AttachmentStore } from "../util/attachmentStore.ts";
49
50
 
50
51
  /** A Pi extension factory, as DefaultResourceLoader takes them. */
@@ -73,7 +74,10 @@ export interface MoatOptions {
73
74
  * its module-level bridge and stands them down inside the daemon, so a live spawn's own
74
75
  * pair is what the model gets (see tools/relayFileTools.ts).
75
76
  */
76
- relayFiles?: { bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge; attachments: AttachmentStore };
77
+ relayFiles?: {
78
+ bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge & LibrarySaveBridge;
79
+ attachments: AttachmentStore;
80
+ };
77
81
  /**
78
82
  * THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
79
83
  * a path whose result reaches the app's Inbox — a scheduled routine, a submitted
@@ -0,0 +1,156 @@
1
+ // The wire contract for CLI → app Library saves, shared by the modules that have
2
+ // to agree on it: RelayClient (sends the frames), RemoteBridge (correlates the
3
+ // reply), and the save_to_library tool (validates before either runs).
4
+ //
5
+ // WHY THE ROUND TRIP EXISTS AT ALL. The same reason save_cargo's does, and the
6
+ // argument is worth restating because this path moves bytes rather than text and
7
+ // the temptation to "just upload it" is correspondingly stronger. Everything in
8
+ // the Library is ciphertext under the account master key — the picture, the
9
+ // clip, the mesh, the document, and the filename and mime type sealed beside
10
+ // them (treeview CLAUDE.md §5). The terminal deliberately holds no master key:
11
+ // the device-grant login mints a session token and nothing else, and
12
+ // crypto/accountVerify.ts says so out loud. So a CLI that POSTed
13
+ // /api/chat/upload-file by itself could only put bytes on S3 that nothing can
14
+ // ever open, and a row pointing at them that nothing can ever read.
15
+ //
16
+ // The app can. It is already signed in, already holds the key, and already has
17
+ // the per-shelf save functions the Library's own screens call. So the CLI hands
18
+ // it plaintext bytes over the relay the user already trusts to carry their
19
+ // prompts and their approvals, and the app does the encrypting.
20
+ //
21
+ // WHY THE APP ALSO PICKS THE SHELF AND THE BACKEND. Two decisions travel with
22
+ // the key, and neither is the terminal's to make:
23
+ //
24
+ // - WHICH SHELF. An image belongs in Images, a clip in Audio, a mesh in Models,
25
+ // a report in Documents — each with its own registration route and its own
26
+ // on-device index. The app routes by the same classifier a dragged-in file
27
+ // goes through (client/utils/webFileDrop.ts), so an agent-saved file and a
28
+ // hand-dropped identical file land in the same place. A table on this side
29
+ // would be the second copy, and the drift would show up as the user's
30
+ // generated PNG filed under Documents.
31
+ // - CLOUD OR ON DEVICE. This is the one the tool's name invites a model to
32
+ // guess at, and it must never be a parameter. `resolveStorageBackend()`
33
+ // answers it from the account: a local-backend account's files live on the
34
+ // device and nothing reaches the server (CLAUDE.md §2). A terminal able to
35
+ // override that could put bytes on our servers for an account that chose
36
+ // device-only storage. The tool therefore takes no destination argument and
37
+ // REPORTS which one was used, rather than asking for one.
38
+ //
39
+ // WHAT THAT COSTS. The app has to be attached. A harbor is headless by design
40
+ // and there is no controller to ask, so save_to_library is not registered there —
41
+ // an unattended run still delivers a file the way it always has, as a sealed
42
+ // attachment on its Inbox result (routines/resultMedia.ts). Don't "fix" that by
43
+ // widening this.
44
+
45
+ /** Which Library shelf the app filed a save on. Mirrors AgentSaveShelf app-side. */
46
+ export const LIBRARY_SHELVES = ["image", "video", "audio", "model3d", "document"] as const;
47
+ export type LibraryShelf = (typeof LIBRARY_SHELVES)[number];
48
+
49
+ /**
50
+ * Ceiling on a save, in bytes — the plaintext file, before base64.
51
+ *
52
+ * Matches MAX_AGENT_SAVE_BYTES app-side (client/services/agentLibrarySave.ts),
53
+ * and the number is a WIRE decision rather than a storage one: these bytes cross
54
+ * a relay a phone may be holding over a mobile connection, inflated by a third
55
+ * and cut into 256 KB frames. Checked HERE as well as there, because a refusal
56
+ * that costs nothing to compute should not cost a 33 MB upload first — and
57
+ * because refusing on this side is the only way the message can name the file.
58
+ *
59
+ * Per-shelf server limits still apply UNDER this (an image caps at 10 MB) and
60
+ * are deliberately NOT restated: they live in services/ImageUpload.js, the app
61
+ * surfaces whatever the route says, and a copy here would be a third number to
62
+ * keep in step.
63
+ */
64
+ export const MAX_LIBRARY_SAVE_BYTES = 25 * 1024 * 1024;
65
+
66
+ /**
67
+ * Base64 characters of file per `library_chunk` frame.
68
+ *
69
+ * Same figure the file-send path uses and for the same reason: the relay caps a
70
+ * frame at 256 KB, and 3/4 of that leaves room for the frame's own JSON. Base64
71
+ * rather than raw bytes because the relay carries JSON text — a Library save may
72
+ * be a PNG or a GLB, so unlike a Cargo artifact there is nothing to be gained by
73
+ * sending the payload as a string.
74
+ */
75
+ export const LIBRARY_CHUNK_CHARS = 180_000;
76
+
77
+ /** A CLI-initiated Library save, relayed to the app to encrypt, file and store. */
78
+ export interface LibrarySaveRequest {
79
+ /** File bytes, base64. */
80
+ base64: string;
81
+ /** Plaintext byte length, so the app can refuse before reassembling. */
82
+ size: number;
83
+ /** The name to file it under — the user's vocabulary, not a path. */
84
+ name: string;
85
+ mediaType: string;
86
+ /**
87
+ * Free-text provenance sealed into the row's encrypted metadata — the prompt
88
+ * that drew it, the command that produced it. Optional, and never a title:
89
+ * the Library titles a row from `name`.
90
+ */
91
+ note?: string;
92
+ }
93
+
94
+ /**
95
+ * The app's answer. `ok: false` carries a reason written for a person — a locked
96
+ * vault, full cloud storage, a guest session, a file type with no shelf —
97
+ * because the tool hands it straight to the model, and "save failed" is not
98
+ * something it can act on.
99
+ *
100
+ * `storageType` on the success arm is the point of the whole feature being
101
+ * REPORTED rather than requested: it is how the model learns whether the file
102
+ * went to the account's cloud or stayed on the device, so it can tell the user
103
+ * the truth about where their file is without ever having chosen.
104
+ */
105
+ export type LibrarySaveResult =
106
+ | { ok: true; shelf: LibraryShelf; storageType: "cloud" | "local"; name: string; bytes: number }
107
+ | { ok: false; reason: string };
108
+
109
+ /**
110
+ * Media type for a path, by extension. Deliberately a SUPERSET of the send-file
111
+ * table (tools/sendFile.ts): that one types a file for presentation in a feed,
112
+ * where an unknown extension falling to application/octet-stream costs nothing.
113
+ * Here the mime is one of the two things the app classifies on, so a .glb typed
114
+ * as octet-stream would still reach the mesh shelf (matched on extension) but a
115
+ * .txt typed that way would reach no shelf at all and be refused.
116
+ *
117
+ * Returns null rather than a fallback when the extension is unknown, so the tool
118
+ * can say which formats have a shelf instead of sending bytes the app will
119
+ * bounce.
120
+ */
121
+ const LIBRARY_MEDIA: Record<string, string> = {
122
+ // Stills. Narrower than what the app calls an image elsewhere, because the
123
+ // upload route filters on the EXTENSION even for client-encrypted bodies —
124
+ // a HEIC is rejected at the door however well the device decodes it.
125
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
126
+ // Video.
127
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", m4v: "video/x-m4v",
128
+ // Audio.
129
+ mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
130
+ ogg: "audio/ogg", flac: "audio/flac",
131
+ // 3D. Matched app-side on the extension rather than this mime, but a mime is
132
+ // still sent so the stored metadata says something true.
133
+ glb: "model/gltf-binary", obj: "text/plain", fbx: "application/octet-stream",
134
+ usdz: "model/vnd.usdz+zip",
135
+ // Documents.
136
+ pdf: "application/pdf",
137
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
138
+ csv: "text/csv",
139
+ // Text and code, all of which the app files as its `code` document type.
140
+ txt: "text/plain", md: "text/markdown", markdown: "text/markdown",
141
+ json: "application/json", yaml: "text/yaml", yml: "text/yaml", xml: "text/xml",
142
+ html: "text/html", css: "text/css", sql: "text/plain",
143
+ js: "text/javascript", ts: "text/plain", tsx: "text/plain", jsx: "text/plain",
144
+ py: "text/plain", rb: "text/plain", go: "text/plain", rs: "text/plain",
145
+ java: "text/plain", kt: "text/plain", swift: "text/plain", c: "text/plain",
146
+ cpp: "text/plain", h: "text/plain", cs: "text/plain", php: "text/plain",
147
+ sh: "text/plain",
148
+ };
149
+
150
+ export function libraryMediaTypeForPath(p: string): string | null {
151
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
152
+ return LIBRARY_MEDIA[ext] ?? null;
153
+ }
154
+
155
+ /** The extensions that have a shelf, for a refusal that tells the model what to do. */
156
+ export const LIBRARY_EXTENSIONS = Object.keys(LIBRARY_MEDIA);
@@ -23,6 +23,13 @@ import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
23
23
  import type { EngineEvent } from "../engine/events.ts";
24
24
  import type { PermissionRequest } from "../permissions/gate.ts";
25
25
  import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
26
+ import {
27
+ LIBRARY_CHUNK_CHARS,
28
+ LIBRARY_SHELVES,
29
+ type LibraryShelf,
30
+ type LibrarySaveRequest,
31
+ type LibrarySaveResult,
32
+ } from "./librarySave.ts";
26
33
  import { parseChartResult, type ChartOpRequest, type ChartOpResult } from "./chartOps.ts";
27
34
 
28
35
  // Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
@@ -149,6 +156,13 @@ export interface RelayCallbacks {
149
156
  // chart_request simply never answers, which the bridge's bounded wait turns into a
150
157
  // clean "this app can't do charts yet" rather than a wedged tool call.
151
158
  onChartResult?: (id: string, result: ChartOpResult) => void;
159
+ // The app finished a CLI-initiated Library save (the id from requestLibrarySave):
160
+ // it classified the file, encrypted it under the account master key and filed it on
161
+ // the matching shelf — or refused. Optional for the same reason the two above are: a
162
+ // controller too old to understand library_begin simply never answers, which the
163
+ // bridge's bounded wait turns into a clean "this app can't save files yet" rather
164
+ // than a wedged tool call.
165
+ onLibrarySaved?: (id: string, result: LibrarySaveResult) => void;
152
166
  // The app's composer is autocompleting an `@file` mention — reply with the cwd
153
167
  // files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
154
168
  // Read-only; resolution of the picked path still happens on the prompt turn.
@@ -698,6 +712,11 @@ export class RelayClient {
698
712
  cargoId?: string;
699
713
  storageType?: string;
700
714
  reason?: string;
715
+ // library_saved (the app's verdict on a save_to_library round trip). `name`
716
+ // and `size` above are reused by file_begin, which is fine — every field on
717
+ // this type is optional and validated per case.
718
+ shelf?: string;
719
+ bytes?: number;
701
720
  // chart_result (the app's answer to a chart op). Left `unknown` on purpose —
702
721
  // it has four shapes and parseChartResult is what decides which one arrived.
703
722
  result?: unknown;
@@ -776,6 +795,37 @@ export class RelayClient {
776
795
  this.cb.onCargoSaved?.(frame.id, result);
777
796
  break;
778
797
  }
798
+ // The app's verdict on a Library save. Re-typed off the wire rather than
799
+ // trusted, for cargo_saved's reason and one more of its own: `shelf` and
800
+ // `storageType` are the two things the model REPEATS TO THE USER about where
801
+ // their file went, so a malformed frame must not be able to make it claim a
802
+ // file is in the cloud when it is on the device, or on a shelf it isn't on.
803
+ // Neither is defaulted — an unrecognised value collapses the whole frame to a
804
+ // refusal, because "saved, somewhere" is worse than "didn't save".
805
+ case "library_saved": {
806
+ if (typeof frame.id !== "string" || !frame.id) break;
807
+ const shelfOk =
808
+ typeof frame.shelf === "string" && (LIBRARY_SHELVES as readonly string[]).includes(frame.shelf);
809
+ const storageOk = frame.storageType === "local" || frame.storageType === "cloud";
810
+ const result: LibrarySaveResult =
811
+ frame.ok === true && shelfOk && storageOk
812
+ ? {
813
+ ok: true,
814
+ shelf: frame.shelf as LibraryShelf,
815
+ storageType: frame.storageType as "cloud" | "local",
816
+ name: typeof frame.name === "string" ? frame.name : "",
817
+ bytes: typeof frame.bytes === "number" && Number.isFinite(frame.bytes) ? frame.bytes : 0,
818
+ }
819
+ : {
820
+ ok: false,
821
+ reason:
822
+ typeof frame.reason === "string" && frame.reason
823
+ ? frame.reason
824
+ : "the app refused the save without giving a reason",
825
+ };
826
+ this.cb.onLibrarySaved?.(frame.id, result);
827
+ break;
828
+ }
779
829
  // The app's answer to a chart op. Re-typed rather than trusted, for the same
780
830
  // reason cargo_saved is: the id keys a pending tool call and the payload is
781
831
  // quoted to the model.
@@ -1457,6 +1507,42 @@ export class RelayClient {
1457
1507
  this.rawSend({ type: "cargo_end", id });
1458
1508
  }
1459
1509
 
1510
+ // Ask the app to save a file into the account's Library: it classifies the file,
1511
+ // encrypts under the account master key (which this process does not have — see
1512
+ // librarySave.ts) and files it on the matching shelf, then answers with a
1513
+ // library_saved keyed by `id`.
1514
+ //
1515
+ // Chunked like sendFile and requestCargoSave, and for the relay's reason rather
1516
+ // than the payload's: a frame caps at 256 KB and a file may be 25 MB. Ordering is
1517
+ // the WS's, so the app can reject a seq gap as a dropped transfer rather than
1518
+ // silently filing a truncated file — which for binary media means a row that opens
1519
+ // to a broken image the user finds weeks later.
1520
+ //
1521
+ // Yields between frames of a multi-chunk send for sendFile's reason: a 25 MB file
1522
+ // is ~140 frames, and pushing them in one synchronous burst starves the event loop
1523
+ // for the whole transfer, including the heartbeat that keeps this socket up.
1524
+ //
1525
+ // NOT run through `safe()`. It redacts secrets, and redacting inside a file is
1526
+ // corruption — a PNG with `[redacted]` spliced into its pixel data is not an
1527
+ // image. Same call sendFile makes about its bytes; the tool decides what is safe
1528
+ // to send.
1529
+ async requestLibrarySave(id: string, req: LibrarySaveRequest): Promise<void> {
1530
+ this.flushDeltas(); // land the save in order relative to buffered text
1531
+ this.rawSend({
1532
+ type: "library_begin",
1533
+ id,
1534
+ name: clip(req.name, 200),
1535
+ mediaType: req.mediaType,
1536
+ size: req.size,
1537
+ note: req.note ? clip(req.note, 500) : undefined,
1538
+ });
1539
+ for (let off = 0, seq = 0; off < req.base64.length; off += LIBRARY_CHUNK_CHARS, seq++) {
1540
+ this.rawSend({ type: "library_chunk", id, seq, data: req.base64.slice(off, off + LIBRARY_CHUNK_CHARS) });
1541
+ if (req.base64.length > LIBRARY_CHUNK_CHARS) await new Promise((r) => setImmediate(r));
1542
+ }
1543
+ this.rawSend({ type: "library_end", id });
1544
+ }
1545
+
1460
1546
  // Ask the app to run a chart operation: it decrypts to read, encrypts to write, and
1461
1547
  // answers with a chart_result keyed by `id`. See chartOps.ts for why the round trip
1462
1548
  // is the feature rather than a convenience.
@@ -16,6 +16,7 @@ import type { PermissionRequest } from "../permissions/gate.ts";
16
16
  import type { AskOutcome } from "../permissions/modeGate.ts";
17
17
  import type { RelayCallbacks } from "./relayClient.ts";
18
18
  import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
19
+ import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
19
20
  import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
20
21
 
21
22
  // How much of a driven turn's reply we hold for possible outbox delivery. The
@@ -36,6 +37,14 @@ const cargoSaveTimeoutMs = (): number => Number(process.env.PRIVATEER_CARGO_TIME
36
37
  // work, not a round trip.
37
38
  const chartOpTimeoutMs = (): number => Number(process.env.PRIVATEER_CHART_TIMEOUT_MS) || 60_000;
38
39
 
40
+ // How long the app gets to file a Library save. Longer than the two above, and the
41
+ // difference is real work rather than slack: those move a document and a few cards,
42
+ // this moves up to 25 MB across the relay and then puts it through an encrypt and an
43
+ // S3 upload on the other side. A phone on a mobile connection can spend a minute on
44
+ // that legitimately, and timing out on a save that then succeeds is the worst
45
+ // outcome — the model tells the user it failed while the file is in their library.
46
+ const librarySaveTimeoutMs = (): number => Number(process.env.PRIVATEER_LIBRARY_TIMEOUT_MS) || 180_000;
47
+
39
48
  // The outbound surface the bridge needs; RelayClient implements all of it.
40
49
  export interface RelayLike {
41
50
  requestApproval(id: string, req: PermissionRequest): void;
@@ -56,6 +65,9 @@ export interface RelayLike {
56
65
  sendSkills(payload: SkillsPayload): void;
57
66
  requestCargoSave(id: string, req: CargoSaveRequest): void;
58
67
  requestChartOp(id: string, req: ChartOpRequest): void;
68
+ // Async, unlike its two siblings: a multi-megabyte send yields between frames so it
69
+ // can't starve the event loop, so the transport has a promise to hand back.
70
+ requestLibrarySave(id: string, req: LibrarySaveRequest): Promise<void>;
59
71
  }
60
72
 
61
73
  // The installed-extensions snapshot relayed to the app's extensions manager.
@@ -158,6 +170,7 @@ export class RemoteBridge {
158
170
  private readonly pendingInputs = new Map<string, (v: string | null) => void>();
159
171
  private readonly pendingCargo = new Map<string, (r: CargoSaveResult) => void>();
160
172
  private readonly pendingCharts = new Map<string, (r: ChartOpResult) => void>();
173
+ private readonly pendingLibrary = new Map<string, (r: LibrarySaveResult) => void>();
161
174
  private pendingAttachments: RemoteAttachment[] = [];
162
175
  // The driven turn in flight, kept only so it can be delivered to the outbox if it
163
176
  // turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
@@ -253,6 +266,10 @@ export class RemoteBridge {
253
266
  const resolve = this.pendingCargo.get(id);
254
267
  if (resolve) resolve(result);
255
268
  },
269
+ onLibrarySaved: (id, result) => {
270
+ const resolve = this.pendingLibrary.get(id);
271
+ if (resolve) resolve(result);
272
+ },
256
273
  onChartResult: (id, result) => {
257
274
  const resolve = this.pendingCharts.get(id);
258
275
  if (resolve) resolve(result);
@@ -480,6 +497,53 @@ export class RemoteBridge {
480
497
  });
481
498
  };
482
499
 
500
+ // Hand a file to the app to classify, encrypt and file in the user's Library (the
501
+ // save_to_library tool), and wait for its verdict. Structurally the twin of
502
+ // saveCargoRemote — the app owns the master key, so this process cannot file
503
+ // anything itself — with two differences worth stating, because both change what a
504
+ // failure means:
505
+ //
506
+ // - The send is AWAITED. requestLibrarySave yields between frames so a 25 MB
507
+ // transfer can't starve the event loop, so unlike the cargo path the frames are
508
+ // not all on the wire by the time we start waiting. A send that throws (the
509
+ // socket died mid-transfer) settles as a refusal rather than leaving the tool
510
+ // waiting out the full deadline for an answer to a message that never arrived.
511
+ // - The deadline is longer (see librarySaveTimeoutMs). Timing out early here is
512
+ // the expensive mistake: the app may still be uploading, and the model would
513
+ // tell the user their file didn't save while it lands in their library.
514
+ saveToLibraryRemote = (req: LibrarySaveRequest, signal?: AbortSignal): Promise<LibrarySaveResult> => {
515
+ if (!this.relay) return Promise.resolve({ ok: false, reason: "remote access is not enabled — run /remote-access on and drive this terminal from the Privateer app" });
516
+ if (!this.relay.isConnected()) return Promise.resolve({ ok: false, reason: "the relay is not connected" });
517
+ if (this.relay.hasController && !this.relay.hasController()) {
518
+ return Promise.resolve({ ok: false, reason: "the Privateer app is not attached to this terminal — only the app holds the key that encrypts a file, so open it and attach before saving" });
519
+ }
520
+ const id = randomUUID();
521
+ return new Promise<LibrarySaveResult>((resolve) => {
522
+ const settle = (r: LibrarySaveResult) => {
523
+ if (!this.pendingLibrary.has(id)) return; // already settled (abort raced the reply)
524
+ this.pendingLibrary.delete(id);
525
+ clearTimeout(timer);
526
+ signal?.removeEventListener("abort", onAbort);
527
+ resolve(r);
528
+ };
529
+ const onAbort = () => settle({ ok: false, reason: "the turn was interrupted before the app confirmed the save" });
530
+ const deadline = librarySaveTimeoutMs();
531
+ const timer = setTimeout(
532
+ () => settle({ ok: false, reason: `the app did not answer within ${Math.round(deadline / 1000)}s — it may be an older version that cannot save files from a terminal, or the upload is still running` }),
533
+ deadline,
534
+ );
535
+ timer.unref?.();
536
+ this.pendingLibrary.set(id, settle);
537
+ if (signal) {
538
+ if (signal.aborted) return onAbort();
539
+ signal.addEventListener("abort", onAbort, { once: true });
540
+ }
541
+ void this.relay!.requestLibrarySave(id, req).catch((e) =>
542
+ settle({ ok: false, reason: `the transfer failed before the app could file it: ${(e as Error)?.message || "connection lost"}` }),
543
+ );
544
+ });
545
+ };
546
+
483
547
  // Run a chart operation on the app and wait for its answer. Structurally the twin of
484
548
  // saveCargoRemote — the app owns the master key, so reading a card and writing one are
485
549
  // both round trips — with one difference worth stating: this is the only place the CLI
@@ -545,5 +609,14 @@ export class RemoteBridge {
545
609
  resolve({ ok: false, reason: "the app disconnected before answering — some of the change may already have been applied; open the chart in the app to see what landed before trying again" });
546
610
  }
547
611
  this.pendingCharts.clear();
612
+ // A Library save whose controller vanished mid-flight gets the same honest
613
+ // "unknown" wording as a cargo save, and the stakes are the same shape: the app
614
+ // may have encrypted and filed the file before its socket dropped. Telling the
615
+ // model it failed invites a retry that leaves the user with the same picture in
616
+ // their library twice, under the same name, with no way to tell which is which.
617
+ for (const resolve of this.pendingLibrary.values()) {
618
+ resolve({ ok: false, reason: "the app disconnected before confirming the save — it may or may not have filed the file; check the library in the app before saving again" });
619
+ }
620
+ this.pendingLibrary.clear();
548
621
  }
549
622
  }
@@ -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
+ }