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 +1 -1
- package/src/config/moat.ts +5 -1
- package/src/remote/librarySave.ts +156 -0
- package/src/remote/relayClient.ts +86 -0
- package/src/remote/remoteBridge.ts +73 -0
- package/src/tools/relayFileTools.ts +12 -1
- package/src/tools/saveToLibrary.ts +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
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",
|
package/src/config/moat.ts
CHANGED
|
@@ -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?: {
|
|
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(
|
|
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
|
+
}
|