privateer-agent 0.12.57 → 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.57",
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",
@@ -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
+ }