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.
- package/extensions/privateer-gate.ts +17 -3
- package/extensions/privateer-media.ts +6 -0
- package/extensions/privateer-speak.ts +246 -12
- package/extensions/speak-voices.data.ts +206 -0
- package/package.json +2 -2
- package/src/config/mediaModels.ts +64 -0
- package/src/tools/media.ts +44 -17
- package/src/tools/mediaModelCommands.ts +175 -0
|
@@ -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
|
-
|
|
496
|
-
|
|
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,
|
|
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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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 {
|
|
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
|
|
235
|
+
label: "Privateer",
|
|
79
236
|
privacy: "confidential",
|
|
237
|
+
attestedBy: "Privateer",
|
|
80
238
|
defaultVoice: QWEN3_TTS_VOICES[0],
|
|
81
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/tools/media.ts
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
1158
|
-
// be quoted at the wrong model's rate, so it isn't quoted at all.
|
|
1159
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
-
...(
|
|
1863
|
-
...(
|
|
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
|
+
}
|