privateer-agent 0.12.56 → 0.12.58

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.
@@ -491,11 +491,25 @@ const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPR
491
491
  // anything, and media.ts has no business in the TUI's startup graph.
492
492
  async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
493
493
  const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
494
+ const { effectiveMediaModel } = await import("../src/config/mediaModels.ts");
494
495
  const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
495
- const model = typeof a.model === "string" && a.model ? a.model : undefined;
496
- const query = tool === "generate_video" || tool === "generate_sprite" ? { videoModel: model } : tool === "generate_model" ? { model } : {};
496
+ // Price the model the tool will actually SEND — its own `model`, else this machine's
497
+ // /image-model or /video-model choice — not the account default it would fall past.
498
+ let priced = a;
499
+ let query: { imageModel?: string; videoModel?: string; model?: string } = {};
500
+ if (tool === "generate_image") {
501
+ const imageModel = effectiveMediaModel("image", a.model);
502
+ priced = { ...a, model: imageModel };
503
+ query = { imageModel };
504
+ } else if (tool === "generate_video" || tool === "generate_sprite") {
505
+ const imageModel = tool === "generate_sprite" ? effectiveMediaModel("image", a.image_model) : undefined;
506
+ priced = tool === "generate_sprite" ? { ...a, image_model: imageModel } : a;
507
+ query = { videoModel: effectiveMediaModel("video", a.model), imageModel };
508
+ } else if (tool === "generate_model") {
509
+ query = { model: typeof a.model === "string" && a.model ? a.model : undefined };
510
+ }
497
511
  const caps = await readMediaCapabilities(query, signal);
498
- return caps.ok ? quoteMediaCallUsd(tool, input, caps.data) : null;
512
+ return caps.ok ? quoteMediaCallUsd(tool, priced, caps.data) : null;
499
513
  }
500
514
 
501
515
  const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
@@ -20,14 +20,20 @@
20
20
  // already on disk — no account, no network, no spend — so it stays useful to a
21
21
  // signed-out terminal, and to a child whose job is to cut together what its parent
22
22
  // generated.
23
+ //
24
+ // /image-model and /video-model are registered unconditionally too: they only write a
25
+ // local preference, and a signed-out terminal is told to /login when it opens the
26
+ // picker rather than finding the command missing.
23
27
  import { makeMediaTools } from "../src/tools/media.ts";
24
28
  import { makeComposeTools } from "../src/tools/videoCompose.ts";
25
29
  import { mediaEnabled } from "../src/config/hosted.ts";
26
30
  import { childHoldsSpendGrant } from "../src/permissions/childSpend.ts";
27
31
  import { isSubagentChild } from "../src/remote/subagentRelay.ts";
32
+ import { registerMediaModelCommands } from "../src/tools/mediaModelCommands.ts";
28
33
 
29
34
  export default function privateerMedia(pi: any): void {
30
35
  const canSpend = !isSubagentChild() || childHoldsSpendGrant();
31
36
  if (mediaEnabled() && canSpend) makeMediaTools()(pi);
32
37
  makeComposeTools()(pi);
38
+ registerMediaModelCommands(pi);
33
39
  }
@@ -32,17 +32,39 @@
32
32
  // gates on hasUI, so harbor/daemon/ACP surfaces stay silent by design (this extension is
33
33
  // manifest-only, like privateer-hints: buildMoat never includes it).
34
34
  //
35
- // VOICES. qwen3-tts speakers, mirrored from the server's TINFOIL_VOICES (the list is
36
- // static there too; first entry is the server-side default). Tinfoil requires the voice
37
- // param server-side, so the server applies "serena" when none is sent — leaving voice
38
- // unset here is always safe. If the account's default TTS model ever changes, update
39
- // this list with it.
35
+ // VOICES. Every TTS model the account can reach, each with the voices it really offers,
36
+ // comes from GET /api/agent/media/capabilities: its `speech.catalog` lists the models
37
+ // (name, provider, confidential or not, blocked by the account's ZDR setting or not),
38
+ // and `?ttsModel=<id>` returns one model's exact voice ids. Models blocked by the
39
+ // account's privacy setting are left out rather than offered to fail. Each voice is
40
+ // described in the app's words (speak-voices.data.ts, generated from the app's sourced
41
+ // table) and can be heard through /api/audio/voice-preview, which serves a fixed
42
+ // sentence from a cache shared across accounts: after the first listen, free.
43
+ //
44
+ // The account default (qwen3-tts, confidential) stays the default: a voice on it saves
45
+ // no model, so a change of default on the server carries users along. Picking a voice
46
+ // on another model saves that model, and it's sent as ttsModelId.
47
+ //
48
+ // Signed out, or the catalog unreachable: the qwen3-tts speakers below, so the picker
49
+ // is never empty. Tinfoil requires a voice server-side and applies "serena" when none is
50
+ // sent, so leaving voice unset is always safe.
40
51
  import { join } from "node:path";
41
- import { makePiSpeakExtension, registerSpeechProvider, registerTranscriptionProvider } from "privateer-speak";
52
+ import {
53
+ kokoroVoice,
54
+ languageName,
55
+ makePiSpeakExtension,
56
+ registerSpeechProvider,
57
+ registerTranscriptionProvider,
58
+ type SpeechAudio,
59
+ type TranscriptionModelInfo,
60
+ type VoiceInfo,
61
+ } from "privateer-speak";
42
62
  import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
43
63
  import { globalDir } from "../src/config/paths.ts";
64
+ import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
44
65
 
45
66
  const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
67
+ const DEFAULT_MODEL = "qwen3-tts";
46
68
 
47
69
  // Call an account audio endpoint and hand back the parsed JSON, throwing the server's
48
70
  // own (person-readable, localized) message on failure — same policy as media.ts's
@@ -72,18 +94,163 @@ async function accountJson<T>(path: string, body: unknown, signal?: AbortSignal)
72
94
  return (await res.json()) as T;
73
95
  }
74
96
 
97
+ // ── the voice catalog ───────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * The language a voice speaks, when its id or its maker says so. Same rules as the
101
+ * app's voiceLabels.ts: Voxtral's `de_male` / `en_paul_sad`, Aura-2's trailing code,
102
+ * MAI's leading tag, Kokoro's first letter, and Qwen's published native languages.
103
+ */
104
+ const VOXTRAL_LANGUAGES = new Set(["ar", "de", "es", "fr", "hi", "it", "nl", "pt"]);
105
+ const ENGLISH_STYLES = new Set(["neutral", "casual", "cheerful", "american", "british"]);
106
+ const KOKORO_LANGUAGES: Record<string, string> = { a: "en-US", b: "en-GB", e: "es", f: "fr", h: "hi", i: "it", j: "ja", p: "pt-BR", z: "zh" };
107
+ const QWEN_NATIVE: Record<string, string> = {
108
+ serena: "zh", vivian: "zh", uncle_fu: "zh", dylan: "zh", eric: "zh", ryan: "en", aiden: "en", ono_anna: "ja", sohee: "ko",
109
+ };
110
+
111
+ export function voiceLanguage(id: string): string | undefined {
112
+ const two = /^([a-z]+)_(female|male)$/.exec(id);
113
+ if (two) {
114
+ if (VOXTRAL_LANGUAGES.has(two[1]!)) return languageName(two[1]!);
115
+ if (ENGLISH_STYLES.has(two[1]!)) return "English";
116
+ }
117
+ const kokoro = /^([abefhijpz])[fm]_/.exec(id);
118
+ if (kokoro) return languageName(KOKORO_LANGUAGES[kokoro[1]!]!);
119
+ const styled = /^([a-z]{2})_[a-z]+_[a-z]+$/.exec(id);
120
+ if (styled && (styled[1] === "en" || VOXTRAL_LANGUAGES.has(styled[1]!))) return languageName(styled[1]!);
121
+ const aura = /^(?:aura-2|flux)-.+-([a-z]{2})$/.exec(id);
122
+ if (aura) return languageName(aura[1]!);
123
+ const mai = /^([a-z]{2}-[A-Z]{2})-[^:]+:/.exec(id);
124
+ if (mai) return languageName(mai[1]!);
125
+ return QWEN_NATIVE[id] ? languageName(QWEN_NATIVE[id]!) : undefined;
126
+ }
127
+
128
+ /** A readable name for ids that are really slugs: "aura-2-thalia-en" → "Thalia". */
129
+ function displayName(id: string): string | undefined {
130
+ const aura = /^(?:aura-2|flux)-(.+)-[a-z]{2}$/.exec(id);
131
+ if (aura) return aura[1]!.charAt(0).toUpperCase() + aura[1]!.slice(1);
132
+ const mai = /^[a-z]{2}-[A-Z]{2}-([^:]+):/.exec(id);
133
+ return mai?.[1];
134
+ }
135
+
136
+ interface CatalogModel {
137
+ id: string;
138
+ name?: string;
139
+ provider?: string;
140
+ isZdr?: boolean;
141
+ isTee?: boolean;
142
+ blockedByZdr?: boolean;
143
+ voiceCount?: number;
144
+ }
145
+ interface SpeechCapabilities {
146
+ speech?: { model?: string; voices?: string[]; catalog?: CatalogModel[] };
147
+ }
148
+
149
+ const CLIP_FORMATS: Record<string, SpeechAudio["format"]> = {
150
+ "audio/mpeg": "mp3",
151
+ "audio/mp3": "mp3",
152
+ "audio/wav": "wav",
153
+ "audio/x-wav": "wav",
154
+ "audio/ogg": "ogg",
155
+ };
156
+
157
+ /** A voice as the picker shows it: described, with its language, model and a sample. */
158
+ function voiceInfo(id: string, model: CatalogModel | undefined, modelId: string): VoiceInfo {
159
+ // Kokoro's id states name and gender outright; the app's table adds the author's grade.
160
+ const kokoro = /^[abefhijpz][fm]_/.test(id) ? kokoroVoice(id, modelId) : undefined;
161
+ const description = kokoro?.description ?? VOICE_DESCRIPTIONS[id];
162
+ const language = voiceLanguage(id);
163
+ const name = kokoro?.name ?? displayName(id);
164
+ return {
165
+ id,
166
+ model: modelId,
167
+ ...(model?.name ? { modelName: model.name } : {}),
168
+ ...(name ? { name } : {}),
169
+ ...(description ? { description } : {}),
170
+ ...(language ? { language } : {}),
171
+ // The enclave is attested by Privateer's server, which then forwards the text: the
172
+ // shield says so ("TEE · attested by Privateer"), not that this terminal checked it.
173
+ ...(model
174
+ ? model.isTee
175
+ ? { privacy: "confidential" as const, attestedBy: "Privateer" }
176
+ : { privacy: model.isZdr ? ("zdr" as const) : ("standard" as const) }
177
+ : {}),
178
+ preview: async (signal?: AbortSignal): Promise<SpeechAudio> => {
179
+ const { audioBase64, mimeType } = await accountJson<{ audioBase64?: string; mimeType?: string }>(
180
+ "/api/audio/voice-preview",
181
+ { ttsModelId: modelId, voice: id, locale: "en" },
182
+ signal,
183
+ );
184
+ if (!audioBase64) throw new Error("Privateer returned no sample");
185
+ return { data: Buffer.from(audioBase64, "base64"), format: CLIP_FORMATS[mimeType ?? ""] ?? "mp3" };
186
+ },
187
+ };
188
+ }
189
+
190
+ async function capabilities(ttsModel?: string): Promise<SpeechCapabilities> {
191
+ const res = await apiRequest(`/api/agent/media/capabilities${ttsModel ? `?ttsModel=${encodeURIComponent(ttsModel)}` : ""}`, {
192
+ method: "GET",
193
+ signal: AbortSignal.timeout(10_000),
194
+ });
195
+ if (!res.ok) throw new Error(`Privateer returned ${res.status}`);
196
+ return (await res.json()) as SpeechCapabilities;
197
+ }
198
+
199
+ // Read once per session: the catalog changes on deploys, not between keypresses.
200
+ let catalog: Promise<VoiceInfo[]> | undefined;
201
+ let accountDefault: string | undefined;
202
+
203
+ function loadCatalog(): Promise<VoiceInfo[]> {
204
+ return (catalog ??= (async () => {
205
+ const top = await capabilities();
206
+ accountDefault = top.speech?.model;
207
+ const models = (top.speech?.catalog ?? []).filter((m) => !m.blockedByZdr);
208
+ // The default model first, then the rest as the server sorts them.
209
+ models.sort((a, b) => Number(b.id === accountDefault) - Number(a.id === accountDefault));
210
+ const lists = await Promise.all(
211
+ models.map(async (m) => {
212
+ const ids = m.id === accountDefault ? (top.speech?.voices ?? []) : m.voiceCount ? ((await capabilities(m.id).catch(() => ({}))) as SpeechCapabilities).speech?.voices ?? [] : [];
213
+ // A model whose voices are prose, not a list: its default voice is still usable.
214
+ return ids.length ? ids.map((id) => voiceInfo(id, m, m.id)) : [{ ...voiceInfo("", m, m.id), name: "default voice", preview: undefined }];
215
+ }),
216
+ );
217
+ return lists.flat();
218
+ })().catch((e) => {
219
+ catalog = undefined; // try again next time the picker opens
220
+ throw e;
221
+ }));
222
+ }
223
+
224
+ const fallbackVoices = (): VoiceInfo[] =>
225
+ QWEN3_TTS_VOICES.map((id) => ({
226
+ ...voiceInfo(id, undefined, DEFAULT_MODEL),
227
+ modelName: "Qwen3 TTS",
228
+ privacy: "confidential" as const,
229
+ attestedBy: "Privateer",
230
+ }));
231
+
75
232
  registerSpeechProvider(
76
233
  {
77
234
  id: "privateer",
78
- label: "Privateer account TTS (attested enclave)",
235
+ label: "Privateer",
79
236
  privacy: "confidential",
237
+ attestedBy: "Privateer",
80
238
  defaultVoice: QWEN3_TTS_VOICES[0],
81
- voices: () => QWEN3_TTS_VOICES,
239
+ model: () => accountDefault ?? DEFAULT_MODEL,
240
+ voices: async () => {
241
+ if (!hasCredentials()) return fallbackVoices();
242
+ try {
243
+ const voices = await loadCatalog();
244
+ return voices.length ? voices : fallbackVoices();
245
+ } catch {
246
+ return fallbackVoices();
247
+ }
248
+ },
82
249
  available: () => hasCredentials(),
83
- fetchSpeech: async (text, { voice, signal }) => {
250
+ fetchSpeech: async (text, { voice, model, signal }) => {
84
251
  const { audioBase64 } = await accountJson<{ audioBase64?: string }>(
85
252
  "/api/audio/speech",
86
- { text, ...(voice ? { voice } : {}) },
253
+ { text, ...(voice ? { voice } : {}), ...(model ? { ttsModelId: model } : {}) },
87
254
  signal,
88
255
  );
89
256
  if (!audioBase64) throw new Error("Privateer returned no audio");
@@ -93,18 +260,85 @@ registerSpeechProvider(
93
260
  { preferWhen: () => hasCredentials() },
94
261
  );
95
262
 
263
+ // ── transcription models ────────────────────────────────────────────────────
264
+ //
265
+ // The account's STT catalog is GET /api/models/openrouter?action=stt: OpenRouter's
266
+ // ZDR-covered transcription models plus the NEAR AI and Tinfoil enclave ones, each with
267
+ // isTee / isZdr. A model id goes back as sttModelId.
268
+ //
269
+ // THE DEFAULT IS ASKED FOR BY NAME. The server's own default (DEFAULT_STT_MODEL) is a
270
+ // deployment setting and today an OpenRouter ZDR model, not an enclave, so sending no
271
+ // model would let this provider's "confidential" badge describe something it doesn't
272
+ // control. Unless you pick otherwise, transcription asks for Tinfoil's enclave Whisper.
273
+ const CONFIDENTIAL_STT = "tinfoil/whisper-large-v3-turbo";
274
+
275
+ interface SttCatalogModel {
276
+ id: string;
277
+ name?: string;
278
+ description?: string;
279
+ isTee?: boolean;
280
+ isZdr?: boolean;
281
+ }
282
+
283
+ /** The catalog's first sentence, short enough for a row. */
284
+ function oneLine(text: string | undefined): string | undefined {
285
+ const first = text?.split(/(?<=\.)\s/)[0]?.trim().replace(/\.$/, "");
286
+ if (!first) return undefined;
287
+ return first.length > 90 ? `${first.slice(0, 89)}…` : first;
288
+ }
289
+
290
+ let sttCatalog: Promise<TranscriptionModelInfo[]> | undefined;
291
+ function loadSttCatalog(): Promise<TranscriptionModelInfo[]> {
292
+ return (sttCatalog ??= (async () => {
293
+ const res = await apiRequest("/api/models/openrouter?action=stt", { method: "GET", signal: AbortSignal.timeout(10_000) });
294
+ if (!res.ok) throw new Error(`Privateer returned ${res.status}`);
295
+ const { models = [] } = (await res.json()) as { models?: SttCatalogModel[] };
296
+ const described = models.map((m): TranscriptionModelInfo => {
297
+ const about = oneLine(m.description);
298
+ return {
299
+ id: m.id,
300
+ ...(m.name ? { name: m.name } : {}),
301
+ ...(about ? { description: about } : {}),
302
+ ...(m.isTee
303
+ ? { privacy: "confidential" as const, attestedBy: "Privateer" }
304
+ : { privacy: m.isZdr ? ("zdr" as const) : ("standard" as const) }),
305
+ };
306
+ });
307
+ // Enclave models first: they're what the default is and what the badge promises.
308
+ return described.sort((a, b) => Number(b.privacy === "confidential") - Number(a.privacy === "confidential"));
309
+ })().catch((e) => {
310
+ sttCatalog = undefined;
311
+ throw e;
312
+ }));
313
+ }
314
+
96
315
  registerTranscriptionProvider(
97
316
  {
98
317
  id: "privateer",
99
- label: "Privateer account STT (attested enclave)",
318
+ label: "Privateer",
100
319
  privacy: "confidential",
320
+ attestedBy: "Privateer",
321
+ defaultModel: () => CONFIDENTIAL_STT,
322
+ models: async () => {
323
+ const fallback: TranscriptionModelInfo[] = [
324
+ { id: CONFIDENTIAL_STT, name: "Whisper Large v3 Turbo", privacy: "confidential", attestedBy: "Privateer" },
325
+ ];
326
+ if (!hasCredentials()) return fallback;
327
+ try {
328
+ const models = await loadSttCatalog();
329
+ return models.length ? models : fallback;
330
+ } catch {
331
+ return fallback;
332
+ }
333
+ },
101
334
  available: () => hasCredentials(),
102
- transcribe: async (audio, { language, signal }) => {
335
+ transcribe: async (audio, { language, model, signal }) => {
103
336
  const { text } = await accountJson<{ text?: string }>(
104
337
  "/api/audio/transcribe",
105
338
  {
106
339
  audioBase64: Buffer.from(audio.data).toString("base64"),
107
340
  format: audio.format,
341
+ sttModelId: model ?? CONFIDENTIAL_STT,
108
342
  ...(language ? { language } : {}),
109
343
  },
110
344
  signal,
@@ -0,0 +1,206 @@
1
+ // GENERATED by scripts/gen-speak-voices.mjs from the Privateer app's voiceProfiles.ts.
2
+ // Do not edit by hand: change the app's table and regenerate. Every entry is the
3
+ // provider's own published description of the voice (see that file's sources).
4
+ export const VOICE_DESCRIPTIONS: Readonly<Record<string, string>> = {
5
+ "aura-2-amalthea-en": "female · young · Filipino · engaging, natural, cheerful · for casual chat",
6
+ "aura-2-andromeda-en": "female · adult · American · casual, expressive, comfortable · for customer service, phone menus",
7
+ "aura-2-apollo-en": "male · adult · American · confident, comfortable, casual · for casual chat",
8
+ "aura-2-arcas-en": "male · adult · American · natural, smooth, clear, comfortable · for customer service, casual chat",
9
+ "aura-2-aries-en": "male · adult · American · warm, energetic, caring · for casual chat",
10
+ "aura-2-asteria-en": "female · adult · American · clear, confident, knowledgeable, energetic · for advertising",
11
+ "aura-2-athena-en": "female · mature · American · calm, smooth, professional · for storytelling",
12
+ "aura-2-atlas-en": "male · mature · American · enthusiastic, confident, approachable, friendly · for advertising",
13
+ "aura-2-aurora-en": "female · adult · American · cheerful, expressive, energetic · for interview",
14
+ "aura-2-callista-en": "female · adult · American · clear, energetic, professional, smooth · for phone menus",
15
+ "aura-2-cora-en": "female · adult · American · smooth, melodic, caring · for storytelling",
16
+ "aura-2-cordelia-en": "female · young · American · approachable, warm, polite · for storytelling",
17
+ "aura-2-delia-en": "female · young · American · casual, friendly, cheerful, breathy · for interview",
18
+ "aura-2-draco-en": "male · adult · British · warm, approachable, trustworthy, baritone · for storytelling",
19
+ "aura-2-electra-en": "female · adult · American · professional, engaging, knowledgeable · for phone menus, advertising, customer service",
20
+ "aura-2-harmonia-en": "female · adult · American · empathetic, clear, calm, confident · for customer service",
21
+ "aura-2-helena-en": "female · adult · American · caring, natural, positive, friendly, raspy · for phone menus, casual chat",
22
+ "aura-2-hera-en": "female · adult · American · smooth, warm, professional · for informative",
23
+ "aura-2-hermes-en": "male · adult · American · expressive, engaging, professional · for informative",
24
+ "aura-2-hyperion-en": "male · adult · Australian · caring, warm, empathetic · for interview",
25
+ "aura-2-iris-en": "female · young · American · cheerful, positive, approachable · for phone menus, advertising, customer service",
26
+ "aura-2-janus-en": "female · adult · American · southern, smooth, trustworthy · for storytelling",
27
+ "aura-2-juno-en": "female · adult · American · natural, engaging, melodic, breathy · for interview",
28
+ "aura-2-jupiter-en": "male · adult · American · expressive, knowledgeable, baritone · for informative",
29
+ "aura-2-luna-en": "female · young · American · friendly, natural, engaging · for phone menus",
30
+ "aura-2-mars-en": "male · adult · American · smooth, patient, trustworthy, baritone · for customer service",
31
+ "aura-2-minerva-en": "female · adult · American · positive, friendly, natural · for storytelling",
32
+ "aura-2-neptune-en": "male · adult · American · professional, patient, polite · for customer service",
33
+ "aura-2-odysseus-en": "male · adult · American · calm, smooth, comfortable, professional · for advertising",
34
+ "aura-2-ophelia-en": "female · adult · American · expressive, enthusiastic, cheerful · for interview",
35
+ "aura-2-orion-en": "male · adult · American · approachable, comfortable, calm, polite · for informative",
36
+ "aura-2-orpheus-en": "male · adult · American · professional, clear, confident, trustworthy · for customer service, storytelling",
37
+ "aura-2-pandora-en": "female · adult · British · smooth, calm, melodic, breathy · for phone menus, informative",
38
+ "aura-2-phoebe-en": "female · adult · American · energetic, warm, casual · for customer service",
39
+ "aura-2-pluto-en": "male · adult · American · smooth, calm, empathetic, baritone · for interview, storytelling",
40
+ "aura-2-saturn-en": "male · adult · American · knowledgeable, confident, baritone · for customer service",
41
+ "aura-2-selene-en": "female · adult · American · expressive, engaging, energetic · for informative",
42
+ "aura-2-thalia-en": "female · adult · American · clear, confident, energetic, enthusiastic · for casual chat, customer service, phone menus",
43
+ "aura-2-theia-en": "female · adult · Australian · expressive, polite, sincere · for informative",
44
+ "aura-2-vesta-en": "female · adult · American · natural, expressive, patient, empathetic · for customer service, interview, storytelling",
45
+ "aura-2-zeus-en": "male · adult · American · deep, trustworthy, smooth · for phone menus",
46
+ "aura-2-agustina-es": "female · adult · Peninsular · calm, clear, expressive, knowledgeable, professional · for interview, casual chat",
47
+ "aura-2-alvaro-es": "male · adult · Peninsular · calm, professional, clear, knowledgeable, approachable · for interview, customer service",
48
+ "aura-2-antonia-es": "female · adult · Argentine · approachable, enthusiastic, friendly, natural, professional · for customer service, interview, casual chat",
49
+ "aura-2-aquila-es": "male · adult · Latin American · expressive, enthusiastic, confident, casual, comfortable · for casual chat, informative",
50
+ "aura-2-carina-es": "female · adult · Peninsular · professional, raspy, energetic, breathy, confident · for interview, customer service, phone menus",
51
+ "aura-2-celeste-es": "female · young · Colombian · clear, energetic, positive, friendly, enthusiastic · for casual chat, advertising, phone menus",
52
+ "aura-2-diana-es": "female · adult · Peninsular · professional, confident, expressive, polite, knowledgeable · for storytelling, advertising",
53
+ "aura-2-estrella-es": "female · mature · Mexican · approachable, natural, calm, comfortable, expressive · for casual chat, interview",
54
+ "aura-2-gloria-es": "female · young · Colombian · casual, clear, expressive, natural, smooth · for customer service, casual chat",
55
+ "aura-2-javier-es": "male · adult · Mexican · approachable, professional, friendly, comfortable, calm · for casual chat, phone menus, storytelling",
56
+ "aura-2-luciano-es": "male · adult · Mexican · charismatic, cheerful, energetic, expressive, friendly · for customer service, casual chat",
57
+ "aura-2-nestor-es": "male · adult · Peninsular · calm, professional, approachable, clear, confident · for casual chat, customer service",
58
+ "aura-2-olivia-es": "female · adult · Mexican · breathy, calm, casual, expressive, warm · for customer service, casual chat",
59
+ "aura-2-selena-es": "female · young · Latin American · approachable, casual, friendly, calm, positive · for customer service, informative",
60
+ "aura-2-silvia-es": "female · adult · Peninsular · charismatic, clear, expressive, natural, warm · for customer service, casual chat",
61
+ "aura-2-sirio-es": "male · adult · Mexican · calm, professional, comfortable, empathetic, baritone · for casual chat, interview",
62
+ "aura-2-valerio-es": "male · adult · Mexican · deep, knowledgeable, natural, polite, professional · for customer service, informative",
63
+ "aura-2-beatrix-nl": "female · adult · cheerful, enthusiastic, friendly, trustworthy, warm · for customer service",
64
+ "aura-2-cornelia-nl": "female · adult · approachable, friendly, polite, positive, warm · for customer service",
65
+ "aura-2-daphne-nl": "female · adult · calm, clear, confident, professional, smooth · for healthcare, interview, casual chat, audiobook",
66
+ "aura-2-hestia-nl": "female · adult · approachable, caring, expressive, friendly, knowledgeable · for customer service",
67
+ "aura-2-lars-nl": "male · adult · breathy, casual, comfortable, sincere, trustworthy · for customer service",
68
+ "aura-2-leda-nl": "female · adult · caring, comfortable, empathetic, friendly, sincere · for sales",
69
+ "aura-2-rhea-nl": "female · adult · caring, knowledgeable, positive, smooth, warm · for customer service",
70
+ "aura-2-roman-nl": "male · adult · calm, casual, deep, natural, patient · for customer service",
71
+ "aura-2-sander-nl": "male · adult · calm, clear, deep, professional, smooth · for customer service",
72
+ "aura-2-agathe-fr": "female · adult · charismatic, cheerful, enthusiastic, friendly, natural · for customer service",
73
+ "aura-2-hector-fr": "male · adult · confident, empathetic, expressive, friendly, patient · for customer service",
74
+ "aura-2-aurelia-de": "female · young · approachable, casual, comfortable, natural, sincere · for healthcare, customer service, sales, financial services",
75
+ "aura-2-elara-de": "female · adult · calm, clear, natural, patient, trustworthy · for healthcare, customer service, sales, financial services",
76
+ "aura-2-fabian-de": "male · mature · confident, knowledgeable, natural, polite, professional · for healthcare, customer service, sales, financial services",
77
+ "aura-2-julius-de": "male · adult · casual, cheerful, engaging, expressive, friendly · for healthcare, customer service, sales, financial services",
78
+ "aura-2-kara-de": "female · young · caring, empathetic, expressive, professional, warm · for healthcare, customer service, sales, financial services",
79
+ "aura-2-lara-de": "female · young · caring, cheerful, empathetic, expressive, warm · for healthcare, customer service, sales, financial services",
80
+ "aura-2-viktoria-de": "female · adult · charismatic, cheerful, enthusiastic, friendly, warm · for healthcare, customer service, sales, financial services",
81
+ "aura-2-cesare-it": "male · adult · clear, empathetic, knowledgeable, natural, smooth · for casual chat, customer service, interview, phone menus",
82
+ "aura-2-cinzia-it": "female · mature · approachable, friendly, smooth, trustworthy, warm · for customer service, interview, narration",
83
+ "aura-2-demetra-it": "female · adult · calm, comfortable, patient · for casual chat, interview, narration",
84
+ "aura-2-dionisio-it": "male · adult · confident, engaging, friendly, melodic, positive · for interview, casual chat, customer service",
85
+ "aura-2-elio-it": "male · adult · breathy, calm, professional, smooth, trustworthy · for interview, casual chat, customer service",
86
+ "aura-2-flavio-it": "male · adult · confident, deep, empathetic, professional, trustworthy · for casual chat, interview, customer service",
87
+ "aura-2-livia-it": "female · adult · approachable, cheerful, clear, engaging, expressive · for customer service, interview, audiobook",
88
+ "aura-2-maia-it": "female · young · caring, energetic, expressive, professional, warm · for interview, casual chat, customer service",
89
+ "aura-2-melia-it": "female · adult · clear, comfortable, engaging, friendly, natural · for casual chat, customer service, interview",
90
+ "aura-2-ama-ja": "female · adult · casual, comfortable, confident, knowledgeable, natural · for interview, phone menus",
91
+ "aura-2-ebisu-ja": "male · young · calm, deep, natural, patient, sincere · for casual chat, customer service",
92
+ "aura-2-fujin-ja": "male · adult · calm, confident, knowledgeable, professional, smooth · for interview, casual chat, phone menus",
93
+ "aura-2-izanami-ja": "female · adult · approachable, clear, knowledgeable, polite, professional · for casual chat, customer service, interview, phone menus",
94
+ "aura-2-uzume-ja": "female · young · approachable, clear, polite, professional, trustworthy · for customer service, interview, phone menus, commercial",
95
+ "Zephyr": "bright",
96
+ "Puck": "upbeat",
97
+ "Charon": "informative",
98
+ "Kore": "firm",
99
+ "Fenrir": "excitable",
100
+ "Leda": "youthful",
101
+ "Orus": "firm",
102
+ "Aoede": "breezy",
103
+ "Callirrhoe": "easy-going",
104
+ "Autonoe": "bright",
105
+ "Enceladus": "breathy",
106
+ "Iapetus": "clear",
107
+ "Umbriel": "easy-going",
108
+ "Algieba": "smooth",
109
+ "Despina": "smooth",
110
+ "Erinome": "clear",
111
+ "Algenib": "gravelly",
112
+ "Rasalgethi": "informative",
113
+ "Laomedeia": "upbeat",
114
+ "Achernar": "soft",
115
+ "Alnilam": "firm",
116
+ "Schedar": "even",
117
+ "Gacrux": "mature",
118
+ "Pulcherrima": "forward",
119
+ "Achird": "friendly",
120
+ "Zubenelgenubi": "casual",
121
+ "Vindemiatrix": "gentle",
122
+ "Sadachbia": "lively",
123
+ "Sadaltager": "knowledgeable",
124
+ "Sulafat": "warm",
125
+ "Rachel": "female · young · American · calm · for narration",
126
+ "Sarah": "female · young · American · confident, warm, professional · for news",
127
+ "Laura": "female · young · American · sunny, quirky, upbeat · for social media",
128
+ "Charlie": "male · young · Australian · confident, energetic, natural · for casual chat",
129
+ "George": "male · adult · British · calm, resonant, warm · for audiobook, narration",
130
+ "Callum": "male · adult · Transatlantic · gravelly, intense · for characters",
131
+ "River": "neutral · adult · American · relaxed · for narration, casual chat",
132
+ "Liam": "male · young · American · warm, energetic · for social media",
133
+ "Charlotte": "female · young · Swedish · sensual, raspy · for characters",
134
+ "Alice": "female · adult · British · clear, engaging · for e-learning",
135
+ "Matilda": "female · adult · American · professional, warm · for narration",
136
+ "Will": "male · young · American · friendly · for social media",
137
+ "Jessica": "female · young · American · playful, expressive · for casual chat",
138
+ "Eric": "male · adult · American · smooth · for casual chat",
139
+ "Chris": "male · adult · American · natural, down-to-earth · for casual chat",
140
+ "Brian": "male · adult · American · resonant, comforting, deep · for narration",
141
+ "Daniel": "male · adult · British · authoritative, clear · for news",
142
+ "Lily": "female · adult · British · velvety, warm · for narration, news",
143
+ "Bill": "male · mature · American · friendly, comforting · for advertising",
144
+ "serena": "female · young · warm, gentle",
145
+ "vivian": "female · young · bright, edgy",
146
+ "ono_anna": "female · playful, light",
147
+ "uncle_fu": "male · mature · deep, mellow",
148
+ "dylan": "male · young · Beijing · clear, natural",
149
+ "eric": "male · Sichuan · lively, husky, bright",
150
+ "ryan": "male · dynamic, rhythmic",
151
+ "aiden": "male · American · sunny, clear",
152
+ "conversational_a": "for casual chat",
153
+ "conversational_b": "for casual chat",
154
+ "read_speech_a": "for narration",
155
+ "read_speech_b": "for narration",
156
+ "read_speech_c": "for narration",
157
+ "read_speech_d": "for narration",
158
+ "af_heart": "grade A",
159
+ "af_bella": "grade A-",
160
+ "af_nicole": "grade B-",
161
+ "bf_emma": "grade B-",
162
+ "ff_siwis": "grade B-",
163
+ "af_aoede": "grade C+",
164
+ "af_kore": "grade C+",
165
+ "af_sarah": "grade C+",
166
+ "am_fenrir": "grade C+",
167
+ "am_michael": "grade C+",
168
+ "am_puck": "grade C+",
169
+ "jf_alpha": "grade C+",
170
+ "af_alloy": "grade C",
171
+ "af_nova": "grade C",
172
+ "bf_isabella": "grade C",
173
+ "bm_fable": "grade C",
174
+ "bm_george": "grade C",
175
+ "hf_alpha": "grade C",
176
+ "hf_beta": "grade C",
177
+ "hm_omega": "grade C",
178
+ "hm_psi": "grade C",
179
+ "if_sara": "grade C",
180
+ "im_nicola": "grade C",
181
+ "jf_gongitsune": "grade C",
182
+ "jf_tebukuro": "grade C",
183
+ "af_sky": "grade C-",
184
+ "jf_nezumi": "grade C-",
185
+ "jm_kumo": "grade C-",
186
+ "bm_lewis": "grade D+",
187
+ "af_jessica": "grade D",
188
+ "af_river": "grade D",
189
+ "am_echo": "grade D",
190
+ "am_eric": "grade D",
191
+ "am_liam": "grade D",
192
+ "am_onyx": "grade D",
193
+ "bf_alice": "grade D",
194
+ "bf_lily": "grade D",
195
+ "bm_daniel": "grade D",
196
+ "zf_xiaobei": "grade D",
197
+ "zf_xiaoni": "grade D",
198
+ "zf_xiaoxiao": "grade D",
199
+ "zf_xiaoyi": "grade D",
200
+ "zm_yunjian": "grade D",
201
+ "zm_yunxi": "grade D",
202
+ "zm_yunxia": "grade D",
203
+ "zm_yunyang": "grade D",
204
+ "am_santa": "grade D-",
205
+ "am_adam": "grade F+",
206
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.56",
3
+ "version": "0.12.58",
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",
@@ -86,7 +86,7 @@
86
86
  "pi-privacy": "0.14.1",
87
87
  "pi-subagents": "0.70.0",
88
88
  "picomatch": "4.0.5",
89
- "privateer-speak": "0.5.0",
89
+ "privateer-speak": "0.6.0",
90
90
  "privateer-workflow": "0.1.0",
91
91
  "tinfoil": "1.1.11",
92
92
  "tsx": "4.23.0",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which image and video models this machine's agent generates with, when the agent
3
+ * doesn't name one itself. Set by `/image-model` and `/video-model`
4
+ * (extensions/privateer-media.ts), stored as `media.imageModel` / `media.videoModel`
5
+ * in ~/.privateer/config.json.
6
+ *
7
+ * PER MACHINE, NOT PER ACCOUNT. The account already has a default for each (the app's
8
+ * own picker writes it, server-side), and that is what an unset value here falls through
9
+ * to. A terminal choice deliberately does not write back to it: picking a cheap video
10
+ * model for a batch of terminal drafts should not change what the phone renders with.
11
+ *
12
+ * PRECEDENCE, highest first: the tool call's own `model` → this file → the account
13
+ * default. The agent naming a model still wins, because it only does that when a job
14
+ * calls for a specific one (a 4K clip, a model that takes a last frame).
15
+ *
16
+ * Read at CALL time, like piiAllow.ts: a pick made in one terminal reaches a harbor or
17
+ * a second terminal on the same machine on its next generation, without a restart.
18
+ * Missing file, unreadable file and malformed JSON all mean "no choice" — the account
19
+ * default — never a crash in the middle of a tool call.
20
+ */
21
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+ import { configPath } from "./paths.ts";
24
+
25
+ export type MediaKind = "image" | "video";
26
+
27
+ const KEY: Record<MediaKind, "imageModel" | "videoModel"> = { image: "imageModel", video: "videoModel" };
28
+
29
+ function readConfig(): Record<string, unknown> {
30
+ try {
31
+ const cfg = JSON.parse(readFileSync(configPath(), "utf8"));
32
+ return cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /** The chosen model id for `kind`, or undefined to use the account default. */
39
+ export function mediaModelPref(kind: MediaKind): string | undefined {
40
+ const media = readConfig().media as Record<string, unknown> | undefined;
41
+ const v = media?.[KEY[kind]];
42
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
43
+ }
44
+
45
+ /**
46
+ * Set (or, with null, clear) the choice for `kind`. Every other key in config.json is
47
+ * preserved — it also holds webhooks, privacy and coauthor settings this must not drop.
48
+ */
49
+ export function setMediaModelPref(kind: MediaKind, id: string | null): void {
50
+ const cfg = readConfig();
51
+ const media = { ...((cfg.media as Record<string, unknown>) ?? {}) };
52
+ const clean = id?.trim();
53
+ if (clean) media[KEY[kind]] = clean;
54
+ else delete media[KEY[kind]];
55
+ const next: Record<string, unknown> = { ...cfg, media };
56
+ if (Object.keys(media).length === 0) delete next.media;
57
+ mkdirSync(dirname(configPath()), { recursive: true });
58
+ writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
59
+ }
60
+
61
+ /** The model a call should send: its own `model`, else this machine's choice, else none. */
62
+ export function effectiveMediaModel(kind: MediaKind, requested: unknown): string | undefined {
63
+ return typeof requested === "string" && requested.trim() ? requested.trim() : mediaModelPref(kind);
64
+ }
@@ -42,6 +42,7 @@ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSyn
42
42
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
43
43
  import { tmpdir } from "node:os";
44
44
  import { apiRequest } from "../auth/privateer.ts";
45
+ import { effectiveMediaModel } from "../config/mediaModels.ts";
45
46
 
46
47
  /** Tool names these definitions register, for allow-list construction. */
47
48
  export const MEDIA_TOOL_NAMES = [
@@ -280,7 +281,7 @@ export const generateImageToolDefinition = {
280
281
  count: Type.Optional(Type.Number({ description: "How many variations to produce, 1-4. Above 1, files are suffixed -1, -2, … Defaults to 1." })),
281
282
  aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
282
283
  size: Type.Optional(Type.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
283
- model: Type.Optional(Type.String({ description: "Override the account's image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the account default." })),
284
+ model: Type.Optional(Type.String({ description: "Override the image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the one the user chose with /image-model, or the account default." })),
284
285
  }),
285
286
  async execute(
286
287
  _toolCallId: string,
@@ -302,6 +303,7 @@ export const generateImageToolDefinition = {
302
303
  }
303
304
 
304
305
  const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
306
+ const model = effectiveMediaModel("image", params.model);
305
307
  const r = await callAccount<ImageResponse>("/api/agent/media/images", {
306
308
  method: "POST",
307
309
  signal,
@@ -311,7 +313,7 @@ export const generateImageToolDefinition = {
311
313
  ...(inputs.length ? { images: inputs } : {}),
312
314
  ...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
313
315
  ...(params.size ? { imageSize: params.size } : {}),
314
- ...(params.model ? { model: params.model } : {}),
316
+ ...(model ? { model } : {}),
315
317
  },
316
318
  });
317
319
  if (!r.ok) return text(`Image generation failed: ${r.message}`);
@@ -381,7 +383,7 @@ export const generateVideoToolDefinition = {
381
383
  aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16'. Model-specific." })),
382
384
  resolution: Type.Optional(Type.String({ description: "Resolution, e.g. '720p' or '1080p'." })),
383
385
  audio: Type.Optional(Type.Boolean({ description: "Ask the model to generate a soundtrack too, where it supports one. Costs more. Defaults to false." })),
384
- model: Type.Optional(Type.String({ description: "Override the account's video model (e.g. 'google/veo-3.1-lite'). Leave unset to use the account default." })),
386
+ model: Type.Optional(Type.String({ description: "Override the video model (e.g. 'google/veo-3.1-lite'). Leave unset to use the one the user chose with /video-model, or the account default." })),
385
387
  }),
386
388
  async execute(
387
389
  _toolCallId: string,
@@ -417,6 +419,7 @@ export const generateVideoToolDefinition = {
417
419
  return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
418
420
  }
419
421
 
422
+ const model = effectiveMediaModel("video", params.model);
420
423
  const submitted = await callAccount<VideoSubmitResponse>("/api/agent/media/videos", {
421
424
  method: "POST",
422
425
  signal,
@@ -426,7 +429,7 @@ export const generateVideoToolDefinition = {
426
429
  ...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
427
430
  ...(params.resolution ? { resolution: params.resolution } : {}),
428
431
  ...(params.audio ? { generateAudio: true } : {}),
429
- ...(params.model ? { model: params.model } : {}),
432
+ ...(model ? { model } : {}),
430
433
  ...(firstFrame ? { firstFrame } : {}),
431
434
  ...(lastFrame ? { lastFrame } : {}),
432
435
  },
@@ -1154,9 +1157,10 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
1154
1157
  };
1155
1158
  switch (tool) {
1156
1159
  case "generate_image": {
1157
- // The report prices the ACCOUNT's image model; a call naming another one would
1158
- // be quoted at the wrong model's rate, so it isn't quoted at all.
1159
- if (typeof args.model === "string" && args.model) return null;
1160
+ // The report prices the image model it DESCRIBES; a call naming another one would
1161
+ // be quoted at the wrong model's rate, so it isn't quoted at all. (A server that
1162
+ // ignores `?imageModel=` describes the account default — a mismatch, so no quote.)
1163
+ if (typeof args.model === "string" && args.model && args.model !== caps.image?.model) return null;
1160
1164
  const each = caps.image?.priceUsdEach;
1161
1165
  if (typeof each !== "number") return null;
1162
1166
  const n = Math.min(Math.max(Math.trunc(num(args.count) ?? 1), 1), caps.image?.maxPerCall ?? 4);
@@ -1176,7 +1180,7 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
1176
1180
  if (!row || clipUsd === null) return null;
1177
1181
  const stills = row.billedTurnStills ?? 0;
1178
1182
  const each = caps.image?.priceUsdEach;
1179
- if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model))) return null;
1183
+ if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model && args.image_model !== caps.image?.model))) return null;
1180
1184
  return clipUsd * (row.billedClips ?? 1) + stills * (each ?? 0);
1181
1185
  }
1182
1186
  case "generate_sfx":
@@ -1189,12 +1193,13 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
1189
1193
  }
1190
1194
  }
1191
1195
 
1192
- /** Read the capability report (optionally describing a specific video / 3D model). */
1196
+ /** Read the capability report (optionally describing a specific image / video / 3D model). */
1193
1197
  export async function readMediaCapabilities(
1194
- query: { videoModel?: string; model?: string } = {},
1198
+ query: { imageModel?: string; videoModel?: string; model?: string } = {},
1195
1199
  signal?: AbortSignal,
1196
1200
  ): Promise<{ ok: true; data: CapabilitiesResponse } | { ok: false; message: string }> {
1197
1201
  const q = new URLSearchParams();
1202
+ if (query.imageModel) q.set("imageModel", query.imageModel);
1198
1203
  if (query.videoModel) q.set("videoModel", query.videoModel);
1199
1204
  if (query.model) q.set("model", query.model);
1200
1205
  const qs = q.toString();
@@ -1293,10 +1298,17 @@ export const mediaCapabilitiesToolDefinition = {
1293
1298
  "their legal values and the price — is per-model.",
1294
1299
  }),
1295
1300
  ),
1301
+ imageModel: Type.Optional(
1302
+ Type.String({
1303
+ description:
1304
+ "An image model id to describe instead of the default (e.g. 'google/gemini-3.1-flash-image'), " +
1305
+ "so the price per image is the one generate_image will charge for it.",
1306
+ }),
1307
+ ),
1296
1308
  videoModel: Type.Optional(
1297
1309
  Type.String({
1298
1310
  description:
1299
- "A video model id to describe instead of the account default (e.g. 'bytedance/seedance-2.0'). " +
1311
+ "A video model id to describe instead of the default (e.g. 'bytedance/seedance-2.0'). " +
1300
1312
  "Clip lengths, aspect ratios and the price per clip are all per-model — check the one you will " +
1301
1313
  "pass as generate_video's `model` before you spend on it.",
1302
1314
  }),
@@ -1310,10 +1322,20 @@ export const mediaCapabilitiesToolDefinition = {
1310
1322
  }),
1311
1323
  ),
1312
1324
  }),
1313
- async execute(_toolCallId: string, params: { model?: string; ttsModel?: string; videoModel?: string }, signal?: AbortSignal) {
1325
+ async execute(
1326
+ _toolCallId: string,
1327
+ params: { model?: string; ttsModel?: string; imageModel?: string; videoModel?: string },
1328
+ signal?: AbortSignal,
1329
+ ) {
1330
+ // Unset means "the model generate_* would use", which is this machine's
1331
+ // /image-model or /video-model choice before the account default — describing the
1332
+ // account default instead would quote one model's price and clip lengths for another.
1333
+ const imageModel = effectiveMediaModel("image", params?.imageModel);
1334
+ const videoModel = effectiveMediaModel("video", params?.videoModel);
1314
1335
  const query = new URLSearchParams();
1315
1336
  if (params?.model) query.set("model", params.model);
1316
- if (params?.videoModel) query.set("videoModel", params.videoModel);
1337
+ if (imageModel) query.set("imageModel", imageModel);
1338
+ if (videoModel) query.set("videoModel", videoModel);
1317
1339
  if (params?.ttsModel) query.set("ttsModel", params.ttsModel);
1318
1340
  const qs = query.toString();
1319
1341
  const r = await callAccount<CapabilitiesResponse>(
@@ -1324,6 +1346,11 @@ export const mediaCapabilitiesToolDefinition = {
1324
1346
 
1325
1347
  const { image, video, model3d, sfx, speech, privacy } = r.data;
1326
1348
  const lines = describeImageVideo(image, video);
1349
+ // An older server ignores `?imageModel=` and reports the account default; say so
1350
+ // rather than let the agent read that model's price as the chosen one's.
1351
+ if (imageModel && image?.model && image.model !== imageModel) {
1352
+ lines.push(` (generate_image will use ${imageModel}; this server described the account default instead)`);
1353
+ }
1327
1354
 
1328
1355
  lines.push(...describeModel3d(model3d));
1329
1356
  lines.push(...describeSfx(sfx));
@@ -1773,13 +1800,13 @@ export const generateSpriteToolDefinition = {
1773
1800
  }),
1774
1801
  ),
1775
1802
  model: Type.Optional(
1776
- Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
1803
+ Type.String({ description: "Video model id to render the motion with. Omit for the one chosen with /video-model, or the account default." }),
1777
1804
  ),
1778
1805
  image_model: Type.Optional(
1779
1806
  Type.String({
1780
1807
  description:
1781
1808
  "Image model id used to TURN the picture to face each direction, e.g. " +
1782
- "'google/gemini-3.1-flash-image'. Omit for the account default. Only used when " +
1809
+ "'google/gemini-3.1-flash-image'. Omit for the one chosen with /image-model, or the account default. Only used when " +
1783
1810
  "`directions` is 'four' or 'eight' — 'one' renders no turns and never touches an image " +
1784
1811
  "model. Worth setting when a run fails with SPRITE_IMAGE_MODEL_UNAVAILABLE: that is the " +
1785
1812
  "image provider being down, not the request being wrong, and naming another model here " +
@@ -1859,8 +1886,8 @@ export const generateSpriteToolDefinition = {
1859
1886
  const guessed = guessResPath(cwd, params.dir);
1860
1887
  return guessed ? { res_path: guessed } : {};
1861
1888
  })()),
1862
- ...(params.model ? { model: params.model } : {}),
1863
- ...(params.image_model ? { image_model: params.image_model } : {}),
1889
+ ...((m) => (m ? { model: m } : {}))(effectiveMediaModel("video", params.model)),
1890
+ ...((m) => (m ? { image_model: m } : {}))(effectiveMediaModel("image", params.image_model)),
1864
1891
  },
1865
1892
  });
1866
1893
  if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
@@ -0,0 +1,175 @@
1
+ // `/image-model` and `/video-model` — choose which model the media tools generate with
2
+ // on this machine when the agent doesn't name one (see config/mediaModels.ts for where
3
+ // the choice lives and why it is per machine rather than written to the account).
4
+ //
5
+ // /image-model open a picker over the account's live image catalog
6
+ // /image-model <id> set it directly (checked against that catalog)
7
+ // /image-model default clear it — back to the account default
8
+ //
9
+ // The catalog is the same list the app's own picker reads
10
+ // (GET /api/models/openrouter?action=imageGen|videoGen), so the terminal can never
11
+ // offer a model the account can't reach. Each row carries its privacy posture, because
12
+ // a ZDR account choosing a non-ZDR model will have every generation refused — better
13
+ // to see that in the picker than as a failed call later.
14
+ import { apiRequest } from "../auth/privateer.ts";
15
+ import { mediaModelPref, setMediaModelPref, type MediaKind } from "../config/mediaModels.ts";
16
+
17
+ export interface CatalogModel {
18
+ id: string;
19
+ name?: string;
20
+ isZdr?: boolean;
21
+ isTee?: boolean;
22
+ supportedDurations?: number[] | null;
23
+ generateAudio?: boolean | null;
24
+ supportsImageToVideo?: boolean;
25
+ }
26
+
27
+ const ACTION: Record<MediaKind, string> = { image: "imageGen", video: "videoGen" };
28
+ const RESET = new Set(["default", "reset", "clear", "none", "account"]);
29
+
30
+ export async function fetchMediaCatalog(
31
+ kind: MediaKind,
32
+ ): Promise<{ ok: true; models: CatalogModel[] } | { ok: false; message: string }> {
33
+ let res: Response;
34
+ try {
35
+ res = await apiRequest(`/api/models/openrouter?action=${ACTION[kind]}`, { method: "GET" });
36
+ } catch (e) {
37
+ return { ok: false, message: `could not reach Privateer: ${e instanceof Error ? e.message : String(e)}` };
38
+ }
39
+ if (!res.ok) {
40
+ if (res.status === 401) return { ok: false, message: "not signed in — run /login first" };
41
+ return { ok: false, message: `Privateer returned ${res.status}` };
42
+ }
43
+ try {
44
+ const body = (await res.json()) as { models?: CatalogModel[] };
45
+ // OpenRouter's auto routers ride along in the image-output list but pick a CHAT
46
+ // model per request — not something a generation can be pinned to.
47
+ const models = (body.models ?? []).filter(
48
+ (m) => m && typeof m.id === "string" && m.id && !m.id.startsWith("openrouter/"),
49
+ );
50
+ return { ok: true, models };
51
+ } catch {
52
+ return { ok: false, message: "Privateer returned a malformed model list" };
53
+ }
54
+ }
55
+
56
+ /** "3-15s" for a contiguous run of whole seconds, "5/10s" otherwise. Pure. */
57
+ export function formatDurations(d: number[]): string {
58
+ const s = [...new Set(d)].sort((a, b) => a - b);
59
+ const contiguous = s.length > 2 && s.every((v, i) => i === 0 || v === s[i - 1] + 1);
60
+ return contiguous ? `${s[0]}-${s[s.length - 1]}s` : `${s.join("/")}s`;
61
+ }
62
+
63
+ /** One picker row: name, id, posture, and for video what the model can do. Pure. */
64
+ export function describeCatalogRow(m: CatalogModel, kind: MediaKind, current?: string): string {
65
+ const posture = m.isTee ? "confidential" : m.isZdr ? "ZDR" : "non-ZDR";
66
+ const tags = [posture];
67
+ if (kind === "video") {
68
+ if (m.supportedDurations?.length) tags.push(formatDurations(m.supportedDurations));
69
+ if (m.supportsImageToVideo) tags.push("first frame");
70
+ if (m.generateAudio) tags.push("audio");
71
+ }
72
+ const name = m.name && m.name !== m.id ? `${m.name} ` : "";
73
+ return `${m.id === current ? "● " : " "}${name}${m.id} (${tags.join(", ")})`;
74
+ }
75
+
76
+ const LABEL: Record<MediaKind, string> = { image: "Image", video: "Video" };
77
+
78
+ function nonZdrWarning(m: CatalogModel | undefined): string {
79
+ return m && !m.isZdr && !m.isTee
80
+ ? " — non-ZDR: refused if this account requires Zero Data Retention for media (Settings → Privacy)"
81
+ : "";
82
+ }
83
+
84
+ interface Ui {
85
+ notify?: (msg: string, level?: string) => void;
86
+ select?: (title: string, options: string[]) => Promise<string | undefined>;
87
+ }
88
+
89
+ /**
90
+ * The whole command, minus registration — exported so tests can drive it with a fake
91
+ * ui and a stubbed catalog.
92
+ */
93
+ export async function runMediaModelCommand(
94
+ kind: MediaKind,
95
+ args: string,
96
+ ui: Ui | undefined,
97
+ catalog: (kind: MediaKind) => ReturnType<typeof fetchMediaCatalog> = fetchMediaCatalog,
98
+ ): Promise<void> {
99
+ const say = (msg: string, level = "info") => ui?.notify?.(msg, level);
100
+ const label = LABEL[kind];
101
+ const current = mediaModelPref(kind);
102
+ const arg = String(args ?? "").trim();
103
+
104
+ if (RESET.has(arg.toLowerCase())) {
105
+ setMediaModelPref(kind, null);
106
+ say(`${label} model: account default${current ? ` (was ${current})` : ""}`);
107
+ return;
108
+ }
109
+
110
+ const list = await catalog(kind);
111
+
112
+ if (arg) {
113
+ // A typed id is checked against the live catalog when we can read it — a typo
114
+ // saved here would fail every later generation. When the catalog is unreachable
115
+ // it is saved anyway, with that said: the server still refuses an id it can't serve.
116
+ if (list.ok) {
117
+ const hit = list.models.find((m) => m.id === arg) ?? list.models.find((m) => m.id.toLowerCase() === arg.toLowerCase());
118
+ if (!hit) {
119
+ const near = list.models.filter((m) => m.id.toLowerCase().includes(arg.toLowerCase())).slice(0, 5);
120
+ say(
121
+ `No ${kind} model "${arg}" on this account.` +
122
+ (near.length ? ` Did you mean: ${near.map((m) => m.id).join(", ")}?` : ` Run /${kind}-model to browse.`),
123
+ "warning",
124
+ );
125
+ return;
126
+ }
127
+ setMediaModelPref(kind, hit.id);
128
+ say(`${label} model: ${hit.id}${nonZdrWarning(hit)}`);
129
+ return;
130
+ }
131
+ setMediaModelPref(kind, arg);
132
+ say(`${label} model: ${arg} (couldn't check it against the catalog: ${list.message})`, "warning");
133
+ return;
134
+ }
135
+
136
+ if (!list.ok) {
137
+ say(`Couldn't list ${kind} models: ${list.message}`, "error");
138
+ return;
139
+ }
140
+ if (!list.models.length) {
141
+ say(`This account has no ${kind} models available.`, "warning");
142
+ return;
143
+ }
144
+ if (!ui?.select) {
145
+ say(`${label} model: ${current ?? "account default"}. Set one with /${kind}-model <id>.`);
146
+ return;
147
+ }
148
+
149
+ const DEFAULT_ROW = `${current ? " " : "● "}Account default`;
150
+ const rows = list.models.map((m) => describeCatalogRow(m, kind, current));
151
+ const picked = await ui.select(`${label} model for generate_${kind} — current: ${current ?? "account default"}`, [
152
+ DEFAULT_ROW,
153
+ ...rows,
154
+ ]);
155
+ if (picked === undefined) return;
156
+ if (picked === DEFAULT_ROW) {
157
+ setMediaModelPref(kind, null);
158
+ say(`${label} model: account default`);
159
+ return;
160
+ }
161
+ const chosen = list.models[rows.indexOf(picked)];
162
+ if (!chosen) return;
163
+ setMediaModelPref(kind, chosen.id);
164
+ say(`${label} model: ${chosen.id}${nonZdrWarning(chosen)}`);
165
+ }
166
+
167
+ export function registerMediaModelCommands(pi: { registerCommand?: (name: string, opts: unknown) => void }): void {
168
+ for (const kind of ["image", "video"] as const) {
169
+ pi.registerCommand?.(`${kind}-model`, {
170
+ description: `Choose the model generate_${kind} uses on this machine (default = the account's)`,
171
+ argumentHint: "[model id | default]",
172
+ handler: (args: string, ctx: any) => runMediaModelCommand(kind, args, ctx?.ui),
173
+ });
174
+ }
175
+ }