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.
@@ -28,6 +28,7 @@ import { globalDir } from "../config/paths.ts";
28
28
  import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
29
29
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
30
30
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
31
+ import { visionInput } from "./vision.ts";
31
32
  import { piAuthStore } from "./piAuthStore.ts";
32
33
  import {
33
34
  sealedEnabled,
@@ -49,10 +50,11 @@ import type { PhalaEnclaveIdentity } from "./phalaSeal.ts";
49
50
  const DEFAULT_MODELS = [
50
51
  ACCOUNT_DEFAULT_MODEL_ID,
51
52
  ACCOUNT_NEAR_MODEL_ID,
52
- // Both former defaults (see TINFOIL_MODEL_ID for the dates). They stay in the floor
53
- // so a user who saved either as their own default still resolves it synchronously at
54
- // launch, rather than falling through to "first model with configured auth" — the BYO
55
- // dead end this seed list exists to prevent.
53
+ // All three former defaults (see TINFOIL_MODEL_ID for the dates). They stay in the
54
+ // floor so a user who saved any of them as their own default still resolves it
55
+ // synchronously at launch, rather than falling through to "first model with
56
+ // configured auth" — the BYO dead end this seed list exists to prevent.
57
+ "tinfoil/gpt-oss-120b",
56
58
  "tinfoil/kimi-k2-6",
57
59
  "tinfoil/glm-5-2",
58
60
  "anthropic/claude-opus-5",
@@ -78,7 +80,10 @@ function seedModel(id: string) {
78
80
  // reasoning + how to steer it, for the enclave models where we verified the
79
81
  // control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
80
82
  ...(thinkingProfile(id) ?? { reasoning: false as const }),
81
- input: ["text"] as ("text" | "image")[],
83
+ // Honest modalities. This was hardcoded to text for the whole catalog, which made
84
+ // Pi strip every image from every account request — including the ones `read`
85
+ // attaches when the user points at a screenshot. See providers/vision.ts.
86
+ input: visionInput(id),
82
87
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
88
  contextWindow: 128000,
84
89
  maxTokens: 16384,
@@ -15,15 +15,16 @@ import { join } from "node:path";
15
15
  import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
- // A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil runs it
19
- // inside an attestable TEE (the serving enclave's quote is published and the live TLS
20
- // key is bound to it), which is the strongest privacy tier we offer — so a capable
21
- // model on that tier is what a privacy-first agent should boot on.
18
+ // A capable MULTIMODAL Tinfoil chat model, and Privateer's default everywhere. Tinfoil
19
+ // runs it inside an attestable TEE (the serving enclave's quote is published and the
20
+ // live TLS key is bound to it), which is the strongest privacy tier we offer — so a
21
+ // capable model on that tier is what a privacy-first agent should boot on.
22
22
  // One definition, three consumers: this resolver, providers/account.ts's seed catalog,
23
23
  // and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
24
24
  //
25
- // This has moved twice. The history matters, because both moves were about the same
26
- // two axes — first-token latency and reasoning control — pulling in opposite directions:
25
+ // This has moved three times. The history matters, because the first two moves were
26
+ // about the same two axes — first-token latency and reasoning control — pulling in
27
+ // opposite directions, and the third added a third axis that outranks both:
27
28
  //
28
29
  // • until 2026-08-01 — glm-5-2.
29
30
  // • 2026-08-01 → 2026-08-06 — kimi-k2-6, a LATENCY swap, not a capability one. Over
@@ -55,10 +56,41 @@ import { agentDir } from "../config/paths.ts";
55
56
  // three that gives the user a working dial. It is a smaller model than GLM 5.2 and
56
57
  // Kimi K2.6; that capability trade was made knowingly. It also serves from NEAR as
57
58
  // well as Tinfoil — the only capable model here with two attested homes.
59
+ // • 2026-08-27 — gemma4-31b, on SIGHT. Every model above is text-only, and the
60
+ // default is what a signed-in user actually runs, so the agent could be pointed at
61
+ // a screenshot, a design mock or a sheet it had just generated and would answer
62
+ // about a picture it was never sent (Pi drops image blocks a model doesn't declare
63
+ // — see providers/vision.ts). "Reads what you show it" beats a reasoning dial on a
64
+ // tool whose whole job is looking at the user's work.
65
+ //
66
+ // The candidate set is small: of the account catalog's confidential-compute
67
+ // models, only Gemma 4 (Tinfoil, NEAR and Phala all serve it) and
68
+ // near/Qwen/Qwen3-VL-30B-A3B-Instruct take images at all. Gemma 4 31B wins on the
69
+ // invariant below — it is in Tinfoil's own catalog, so the direct and subscription
70
+ // routes stay the SAME model, which Qwen3-VL (NEAR only) would have broken.
71
+ //
72
+ // Two things this costs, both knowingly:
73
+ // - the reasoning dial. thinkingProfile() annotates gpt-oss and the GLM/Qwen
74
+ // chat-template family and nothing else, so Gemma registers `reasoning: false`
75
+ // — as would Qwen3-VL, whose `-Instruct` id is the non-thinking variant. No
76
+ // vision model in the confidential tier has a dial we have verified, so this
77
+ // was not a choice between sight and thinking control; there was no option
78
+ // with both.
79
+ // - size. 31B against gpt-oss-120b, continuing the trade the 2026-08-06 entry
80
+ // started.
81
+ //
82
+ // NOT re-measured for latency: the daily message cap (25/day, free tier) refused
83
+ // every probe on 2026-08-27 with 429 DAILY_CAP_HIT, so the TTFT table above has no
84
+ // gemma4-31b row. It is the same enclave provider and transport as the two models
85
+ // that never stalled, which is the reason to expect it behaves like them and not
86
+ // like glm-5-2 — but that is an expectation, not a measurement. Run the probe when
87
+ // the cap allows and record the row here.
58
88
  //
59
89
  // Re-measure before moving this again. The stall behaviour is a property of a
60
90
  // provider's deployment, not of a model, and it has already changed under us twice.
61
- export const TINFOIL_MODEL_ID = "tinfoil/gpt-oss-120b";
91
+ // And whatever replaces it must still accept images: acceptsImages() in
92
+ // providers/vision.ts is the test, and defaultModel.test.ts asserts it.
93
+ export const TINFOIL_MODEL_ID = "tinfoil/gemma4-31b";
62
94
 
63
95
  // Same model, reached two ways:
64
96
  // - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
@@ -175,8 +207,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
175
207
  // A terminal launched with a BYO key (or an explicit --model) is pinned to whatever it
176
208
  // resolved at launch; without an in-session switch a mid-session /login changes nothing
177
209
  // visible and the user is left wondering what signing in bought them. This resolves the
178
- // model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
179
- // is present and over the subscription otherwise — no BYO key needed.
210
+ // model sign-in should activate RIGHT AWAY: TINFOIL_MODEL_ID, direct when a Tinfoil key
211
+ // is present and over the subscription otherwise — no BYO key needed. Being signed in
212
+ // is what buys sight, so this is also the switch that makes images work mid-session.
180
213
  // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
181
214
  // `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
182
215
  // confidential model. Whether to actually move a session that sits on a deliberate
@@ -0,0 +1,95 @@
1
+ // Which models can actually LOOK at an image — the one place that answers it, for
2
+ // every catalog we register.
3
+ //
4
+ // The bug this fixes: every model we register — the whole 270-model account catalog
5
+ // (providers/account.ts seedModel) and Tinfoil's direct catalog
6
+ // (extensions/privateer-privacy.ts tinfoilModel) — declared `input: ["text"]`,
7
+ // copied from pi-privacy's own seed shape. That field is not decoration. Pi gates
8
+ // image handling on it in four places:
9
+ //
10
+ // • pi-coding-agent core/tools/read.js — reading a PNG appends "[Current model does
11
+ // not support images. The image will be omitted from this request.]" and drops it;
12
+ // • pi-ai api/openai-completions.js — `hasImages && model.input.includes("image")`,
13
+ // so image blocks are stripped from the request body;
14
+ // • api/transform-messages.js and api/openai-responses-shared.js — same test again.
15
+ //
16
+ // So a signed-in user could point the agent at a screenshot, a design mock, or a
17
+ // generated sprite sheet and get a confident answer about a picture the model was
18
+ // never sent. Not a refusal — a silent omission, which is worse. Declaring the
19
+ // modality honestly is what makes `read` on an image, and every media tool that hands
20
+ // one back, actually reach the model.
21
+ //
22
+ // WHY AN ALLOWLIST AND NOT A SERVER FIELD: `GET /api/models` returns
23
+ // `{ modelId, displayName, provider, rate*, enabled, privacy: { tier } }` and no
24
+ // modality at all (checked against the live listing, 273 models, 2026-08-27). Until it
25
+ // carries one, the client has to decide, and the two failure directions are NOT
26
+ // symmetric:
27
+ //
28
+ // • miss a vision model → images are dropped, i.e. exactly today's behaviour;
29
+ // • claim one that isn't → pi-ai sends image_url blocks the upstream rejects and the
30
+ // whole turn 400s.
31
+ //
32
+ // So this list is deliberately conservative: a family goes in when the id itself says
33
+ // vision (`-vl-`, a trailing `v` on a GLM), or when every member of that family has
34
+ // shipped multimodal. Anything uncertain stays out and keeps the old behaviour.
35
+
36
+ // Ids arrive in three shapes and all three must match the same rules:
37
+ // `anthropic/claude-opus-5` — account catalog, vendor/model
38
+ // `near/Qwen/Qwen3-VL-30B-A3B-Instruct` — account catalog, tee/vendor/model
39
+ // `gemma4-31b` — Tinfoil's direct catalog, bare id
40
+ // Callers holding a bare id should prefix it with its provider (see visionInput's use
41
+ // in privateer-privacy.ts); the patterns below are anchored loosely enough that a
42
+ // bare `gemma4-31b` still matches on its own.
43
+ const VISION_PATTERNS: RegExp[] = [
44
+ // ── Named in the id ────────────────────────────────────────────────────────
45
+ // Qwen's vision line (qwen3-vl-*, qwen2.5-vl-72b, near/Qwen/Qwen3-VL-30B-A3B) and
46
+ // Baidu's (ernie-4.5-vl-424b). Qwen ships VL as a SEPARATE line, which is also why
47
+ // the plain qwen3.x ids below are deliberately absent.
48
+ /-vl[-_.]/i,
49
+ // Z.ai marks vision with a trailing v on the version: glm-4.5v, glm-4.6v,
50
+ // glm-5v-turbo. Same reasoning — the unsuffixed glm-5.x are their text siblings.
51
+ /\bglm-\d+(?:\.\d+)?v\b/i,
52
+ // ByteDance's GUI agent reads screenshots; that is the whole point of it.
53
+ /\bui-tars\b/i,
54
+
55
+ // ── Families that are multimodal throughout ────────────────────────────────
56
+ // Every Claude from 3 onwards takes images, and 3 is the oldest in the catalog.
57
+ /(^|\/)anthropic\/claude-/i,
58
+ // Gemini has been multimodal since 1.0.
59
+ /(^|\/)google\/gemini-/i,
60
+ // Gemma from 3 on is multimodal; gemma-2 is not, so the version is part of the
61
+ // match. Covers google/gemma-3-27b-it, google/gemma-4-31b-it, tinfoil/gemma4-31b,
62
+ // near/google/gemma-4-31B-it, phala/google/gemma-4-31b-it and the Phala
63
+ // gemma-4 derivatives.
64
+ /gemma-?[34]/i,
65
+ // gpt-4o, gpt-4.1 and the whole gpt-5 line. Deliberately NOT `openai/gpt-4`
66
+ // (the original is text-only), not gpt-3.5, and not gpt-oss — the open-weights
67
+ // models are text-only, which is what our own default was until this change.
68
+ /(^|\/)openai\/gpt-(?:4o|4\.1|5)/i,
69
+ // Llama 4 (maverick, scout) is natively multimodal; 3.x is not.
70
+ /(^|\/)meta-llama\/llama-4-/i,
71
+ // Nova lite/pro/premier take images; nova-micro is text-only.
72
+ /(^|\/)amazon\/nova-(?:2-)?(?:lite|pro|premier)/i,
73
+ // Grok 4 and up.
74
+ /(^|\/)x-ai\/grok-4/i,
75
+ // Mistral's multimodal tiers: small 3.2 and the medium 3 line.
76
+ /(^|\/)mistralai\/mistral-(?:small-3\.2|medium-3)/i,
77
+ ];
78
+
79
+ /** Whether `modelId` can be sent an image. See the note above on why it's an allowlist. */
80
+ export function acceptsImages(modelId: string): boolean {
81
+ return VISION_PATTERNS.some((re) => re.test(modelId));
82
+ }
83
+
84
+ /**
85
+ * The `input` modality array for a registered model entry.
86
+ *
87
+ * pi-ai's ModelData only knows `"text" | "image"` — there is no video or pdf modality
88
+ * to declare, so a clip or a document reaches the model through a TOOL (media.ts's
89
+ * generators, `read` for a file) rather than as an input block. Image is therefore the
90
+ * whole of what this field can say, and saying it correctly is what lets `read` attach
91
+ * a screenshot instead of quietly dropping it.
92
+ */
93
+ export function visionInput(modelId: string): ("text" | "image")[] {
94
+ return acceptsImages(modelId) ? ["text", "image"] : ["text"];
95
+ }
@@ -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
  }