pi-multimodal-proxy 1.19.0 → 1.20.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.20.0] - 2026-10-02
8
+
9
+ ### Added
10
+
11
+ - **`generate_image` tool (Pi ≥ 1.0, feature-detected)** — agent-facing image creation through `ModelRegistry.generateImages()` with the session's credentials and request-time auth; usage counts toward session cost. Parameters: `prompt` (1..4000 chars), optional `model` (`provider/model-id` of an image-generation model). Model resolution walks `getAvailableOfType("image")` (cached per session): without an override the first keyed image model wins; a mistyped override errors with recovery-style close matches and a malformed one gets the expected format (Pi 1.0 codemode-error philosophy). The prompt leaves only after the data-egress consent gate (`/multimodal-proxy consent yes` / `allowed-providers`). Results carry the generated image blocks (vision-capable base models see the pixels directly) plus a summary text naming each `image="<id>"` id, and an `outputSchema`-shaped `structuredContent` (provider, model, per-image id/mimeType/dimensions, `costUSD` when the provider reported usage; `usage` passed through on the tool result). Generated images are persisted in the per-session recall stores (`imageData`/`imageMeta`) with `generated-<hash8>.<ext>` filename hints, so the existing pipeline takes over: for text-only base models the `tool_result` hook auto-describes them via the vision model and appends `DescriptionEntry`s (making `#` recall work), and `analyze_image` can re-query or crop them by id regardless of base-model modality. Shares the per-turn tool-call budget with `analyze_image` (10/turn); annotated `readOnlyHint: false` (cost-incurring content creation) and namespaced `multimodal-proxy`. Registered only when the runtime exposes `generateImages` — pi < 1.0 never sees the tool.
12
+ - Five new tests for `resolveImageGenModelChoice` (default-first, exact override, close-match suggestions on typos, expected-format error, actionable hint when nothing is keyed) — 512 total, all green.
13
+
14
+ ## [1.19.1] - 2026-10-02
15
+
16
+ ### Changed
17
+
18
+ - **Leaner `analyze_image` description** — the tool description shrinks ~40% (1.7 KB → ~1.0 KB) while keeping every actionable detail (when to use, the three crop forms with shapes, recall ids, fence attributes, `crop_origin` math). Pi 1.0's codemode lists tool declarations under a 3000-token inline budget and drops what doesn't fit, so a long description both costs tokens on every request and risks pushing other tools out of the listing.
19
+ - **Recovery-style errors (Pi 1.0 codemode philosophy: errors say how to recover)** — `analyze_image`'s model-not-found error now names close registry matches (`anthropic/claude-sont-5` → suggests `anthropic/claude-sonnet-5`, `…-sonnet-4-5`), the recall-id-not-found error lists close ids still retained in the session cache, and zero-area crop errors explain the expected shape (fraction/absolute bounds) instead of just "zero area".
20
+ - **Dynamic implicit-vision-model fallback (Pi ≥ 1.0, feature-detected)** — when the curated fallback chain (`zai/glm-5.3-flash` → `deepseek/…-flash-vision-exp` → `anthropic/claude-sonnet-5/4-5`) is entirely unkeyed but another keyed vision-capable chat model exists (openrouter, google, …), `ModelRegistry.getAvailableOfType("chat")` resolves the implicit default to the first one (cheap-tier ids — flash/mini/fast/haiku/express/lite — preferred so an implicit default never lands on a frontier-priced model). Probe cached per session; older runtimes and the "nothing keyed anywhere" notice path are unchanged.
21
+ - **`#` recall autocomplete regression tests** mirroring pi #10218 (autocomplete must trigger when input starts with whitespace) — the `(?:^|\s)#` token regex already handled it; the tests pin it.
22
+ - Six new tests: `closeMatches` (near-typo ranking, substring bonus, relevance floor, deterministic ties), `pickDynamicVisionCandidate` (cheap-tier preference, first-capable fallback, input filtering), zero-area crop recovery hints (507 total, all green).
23
+
7
24
  ## [1.19.0] - 2026-10-01
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -8,10 +8,21 @@ When **video or audio files** are detected, they are routed to a **multimodal mo
8
8
 
9
9
  **YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
10
10
 
11
+ ## What's new in 1.20.0
12
+
13
+ Image generation, riding Pi 1.0's new `models.generateImages()` API:
14
+
15
+ - **`generate_image` tool** — agent-facing image creation with the session's image-generation models (feature-detected; silently absent on pi < 1.0). Prompt leaves only after the data-egress consent gate; the call reports usage/cost through the normal session accounting.
16
+ - **Generated images join the pipeline** — results return as image blocks (vision base models see the pixels) **and** are persisted in the recall stores, so a text-only base model gets an automatic vision-model description (same as any tool-returned image), `#` recall works once described, and `analyze_image` can re-query or crop them via their `image="<id>"` ids.
17
+ - **Codemode-friendly** — `outputSchema`-shaped `structuredContent` (provider, model, per-image ids/dimensions, `costUSD`) so scripts consume results programmatically; leans into Pi 1.0's leaner codemode with a compact description and recovery-style errors (close-match model suggestions, expected `provider/model-id` format, actionable `pi --login` hint when nothing is keyed).
18
+ - **Shared cost guard** — `generate_image` counts against the same per-turn tool-call budget as `analyze_image`.
19
+
11
20
  ## What's new in 1.19.0
12
21
 
13
22
  Deep integration with recent Pi releases (0.87 / 0.99):
14
23
 
24
+ - **`generate_image` tool (Pi ≥ 1.0)** — the agent can now **create** images, not just understand them. Calls run through `models.generateImages()` with the session's credentials (usage counts toward session cost), the model defaults to the first keyed image-generation model (override per call with `model: "provider/model-id"`), results carry image blocks plus stable `image="<id>"` ids, and the existing pipeline takes over automatically: text-only base models get an auto-description via your vision model, and every generated image is recallable (`#`) and re-analyzable/croppable with `analyze_image` like any attachment. Data-egress consent gates the provider before the first call.
25
+ - **Pi 1.0 alignment (1.19.1)** — leaner `analyze_image` description (~40% smaller — codemode lists tools under a 3000-token budget and drops what doesn't fit), recovery-style errors that name close matches (model typos, recall ids) and expected shapes (crops), and a dynamic implicit-vision-model fallback via `getAvailableOfType()` when the curated chain is entirely unkeyed.
15
26
  - **Virtual model — `Multimodal Auto`** (Pi ≥ 0.99, on by default): a new entry in `/model` that routes each request for you — turns whose pending input carries images go **natively** to your configured vision model (real pixels, no text fence), everything else stays on your base model (auto = whatever physical model handled the session before, or pin one with `/multimodal-proxy virtual-base`). Sticky routing keeps prompt caches valid within a turn; the footer shows the routed model and `/session` lists cost per physical model. Disable with `/multimodal-proxy virtual off`.
16
27
  - **`analyze_image` speaks the 0.99 tool API** — errors now surface as real tool errors (`isError`) instead of fake text, results carry a machine-readable `structuredContent` (analysis text, provider/model, latency, grounding format, per-image ids/dimensions/crop) so **codemode scripts** can consume them programmatically, and the tool is annotated (`readOnlyHint`) and namespaced (`multimodal-proxy`) for codemode listings.
17
28
  - **Model-aware upload downscale** (Pi ≥ 0.87) — when the configured vision model declares `inputLimits.images.resize` in its catalog entry, the proxy derives the downscale target, JPEG quality, and byte budget from it (tightening, never loosening, your `max-upload` settings). No more double-resizing against provider limits.
@@ -79,6 +79,9 @@ import {
79
79
  bufferToPiAiImage,
80
80
  shouldStripImages,
81
81
  selectVisionModels,
82
+ closeMatches,
83
+ pickDynamicVisionCandidate,
84
+ resolveImageGenModelChoice,
82
85
  splitSubcommand,
83
86
  stripImagePaths,
84
87
  stripMediaPaths,
@@ -374,6 +377,116 @@ describe("selectVisionModels", () => {
374
377
  });
375
378
  });
376
379
 
380
+ describe("closeMatches", () => {
381
+ const catalog = [
382
+ "anthropic/claude-sonnet-5",
383
+ "anthropic/claude-sonnet-4-5",
384
+ "deepseek/deepseek-v4-flash-vision-exp",
385
+ "zai/glm-5.3-flash",
386
+ "openai/gpt-5.6",
387
+ ];
388
+
389
+ it("ranks a near-typo above unrelated models", () => {
390
+ assert.deepEqual(closeMatches("anthropic/claude-sont-5", catalog, 2), [
391
+ "anthropic/claude-sonnet-5",
392
+ "anthropic/claude-sonnet-4-5",
393
+ ]);
394
+ });
395
+
396
+ it("gives substring matches a strong bonus", () => {
397
+ assert.deepEqual(closeMatches("glm", catalog, 1), ["zai/glm-5.3-flash"]);
398
+ });
399
+
400
+ it("returns nothing for queries with no reasonable match", () => {
401
+ assert.deepEqual(closeMatches("banana-pudding", catalog), []);
402
+ });
403
+
404
+ it("is deterministic on ties (alphabetical)", () => {
405
+ assert.deepEqual(closeMatches("mod", ["mod-b", "mod-a", "mod-c"], 3), ["mod-a", "mod-b", "mod-c"]);
406
+ });
407
+
408
+ it("handles empty input", () => {
409
+ assert.deepEqual(closeMatches("", catalog), []);
410
+ assert.deepEqual(closeMatches("x", []), []);
411
+ });
412
+ });
413
+
414
+ describe("pickDynamicVisionCandidate", () => {
415
+ const m = (provider: string, modelId: string, input: readonly string[] = ["text", "image"]) => ({
416
+ provider,
417
+ modelId,
418
+ input,
419
+ });
420
+
421
+ it("prefers a cheap-tier model over registry order", () => {
422
+ const out = pickDynamicVisionCandidate([
423
+ m("anthropic", "claude-opus-6"),
424
+ m("zai", "glm-5.3-flash"),
425
+ ]);
426
+ assert.equal(out?.modelId, "glm-5.3-flash");
427
+ });
428
+
429
+ it("falls back to the first image-capable model when none is cheap-tier", () => {
430
+ const out = pickDynamicVisionCandidate([m("anthropic", "claude-opus-6"), m("x", "y")]);
431
+ assert.equal(out?.modelId, "claude-opus-6");
432
+ });
433
+
434
+ it("skips models without image input and empty lists", () => {
435
+ assert.equal(pickDynamicVisionCandidate([m("a", "b", ["text"])]), undefined);
436
+ assert.equal(pickDynamicVisionCandidate([]), undefined);
437
+ });
438
+ });
439
+
440
+ describe("resolveImageGenModelChoice", () => {
441
+ const available = [
442
+ { provider: "openrouter", modelId: "gpt-image-1" },
443
+ { provider: "openrouter", modelId: "flux-schnell" },
444
+ { provider: "google", modelId: "imagen-4" },
445
+ ];
446
+
447
+ it("defaults to the first available model without an override", () => {
448
+ assert.deepEqual(resolveImageGenModelChoice(undefined, available), {
449
+ ok: true,
450
+ provider: "openrouter",
451
+ modelId: "gpt-image-1",
452
+ });
453
+ assert.deepEqual(resolveImageGenModelChoice("", available), {
454
+ ok: true,
455
+ provider: "openrouter",
456
+ modelId: "gpt-image-1",
457
+ });
458
+ });
459
+
460
+ it("accepts an exact override", () => {
461
+ assert.deepEqual(resolveImageGenModelChoice("google/imagen-4", available), {
462
+ ok: true,
463
+ provider: "google",
464
+ modelId: "imagen-4",
465
+ });
466
+ });
467
+
468
+ it("mistyped overrides carry recovery-style close matches", () => {
469
+ const out = resolveImageGenModelChoice("openrouter/gpt-imge-1", available);
470
+ assert.equal(out.ok, false);
471
+ if (!out.ok) {
472
+ assert.match(out.error, /not found/);
473
+ assert.ok(out.suggestions.includes("openrouter/gpt-image-1"));
474
+ }
475
+ });
476
+
477
+ it("rejects malformed model strings with the expected format", () => {
478
+ const out = resolveImageGenModelChoice("no-slash", available);
479
+ assert.equal(out.ok, false);
480
+ if (!out.ok) assert.match(out.error, /Expected format: provider\/model-id/);
481
+ });
482
+
483
+ it("errors with an actionable hint when nothing is keyed", () => {
484
+ const out = resolveImageGenModelChoice(undefined, []);
485
+ assert.equal(out.ok, false);
486
+ if (!out.ok) assert.match(out.error, /pi --login/);
487
+ });
488
+ });
489
+
377
490
  describe("sanitizeProviderHeaders", () => {
378
491
  it("drops null deletion markers", () => {
379
492
  assert.deepEqual(
@@ -1844,6 +1957,21 @@ describe("resolveCropEntry", () => {
1844
1957
  /zero area/,
1845
1958
  );
1846
1959
  });
1960
+
1961
+ it("zero-area normalized errors carry a recovery hint (pi 1.0 style)", () => {
1962
+ assert.throws(
1963
+ () =>
1964
+ resolveCropEntry({ image_index: 0, normalized: { x: 1.0, y: 1.0, width: 0, height: 0 } }, 100, 100),
1965
+ /fractions of the image/,
1966
+ );
1967
+ });
1968
+
1969
+ it("zero-area pixel errors carry a recovery hint (pi 1.0 style)", () => {
1970
+ assert.throws(
1971
+ () => resolveCropEntry({ image_index: 0, pixels: { x: 200, y: 200, width: 10, height: 10 } }, 100, 100),
1972
+ /inside the image/,
1973
+ );
1974
+ });
1847
1975
  });
1848
1976
 
1849
1977
  describe("cropSignature", () => {
@@ -16,6 +16,16 @@ describe("extractRecallToken", () => {
16
16
  assert.deepEqual(extractRecallToken(["#"], 0, 1), { query: "", prefix: "#" });
17
17
  });
18
18
 
19
+ it("matches a # token after leading whitespace (pi #10218 mirror)", () => {
20
+ // Pi 1.0 fixed slash-command autocomplete not triggering when the input
21
+ // starts with whitespace; the # recall provider must behave the same.
22
+ assert.deepEqual(extractRecallToken([" #shot"], 0, 7), {
23
+ query: "shot",
24
+ prefix: "#shot",
25
+ });
26
+ assert.deepEqual(extractRecallToken([" #"], 0, 4), { query: "", prefix: "#" });
27
+ });
28
+
19
29
  it("matches a # token after whitespace with a partial query", () => {
20
30
  assert.deepEqual(extractRecallToken(["zoom into #shot"], 0, 15), {
21
31
  query: "shot",
@@ -2373,6 +2373,112 @@ export function selectVisionModels<T extends VisionModelLike>(
2373
2373
  return all.filter((m) => m.input.includes("image"));
2374
2374
  }
2375
2375
 
2376
+ /** Lowercased bigram set of a string, for Dice-coefficient similarity. */
2377
+ function bigrams(s: string): Set<string> {
2378
+ const t = s.toLowerCase();
2379
+ const out = new Set<string>();
2380
+ for (let i = 0; i + 1 < t.length; i++) out.add(t.slice(i, i + 2));
2381
+ return out;
2382
+ }
2383
+
2384
+ /**
2385
+ * Close-match suggestions for a mistyped identifier, in the spirit of Pi
2386
+ * 1.0's codemode recovery errors ("tools.Bash suggests tools.bash").
2387
+ * Ranks by Dice bigram similarity with a strong exact/substring bonus;
2388
+ * returns at most `limit` candidates above the relevance floor, best first,
2389
+ * ties broken alphabetically so the output is deterministic.
2390
+ */
2391
+ export function closeMatches(query: string, candidates: readonly string[], limit = 3): string[] {
2392
+ if (!query || candidates.length === 0) return [];
2393
+ const q = query.toLowerCase();
2394
+ const scored = candidates.map((candidate) => {
2395
+ const c = candidate.toLowerCase();
2396
+ let score: number;
2397
+ if (c === q) score = 2;
2398
+ else if (c.includes(q) || q.includes(c)) score = 1.5;
2399
+ else {
2400
+ const bq = bigrams(q);
2401
+ const bc = bigrams(c);
2402
+ let shared = 0;
2403
+ for (const g of bq) if (bc.has(g)) shared++;
2404
+ score = bq.size === 0 || bc.size === 0 ? 0 : (2 * shared) / (bq.size + bc.size);
2405
+ }
2406
+ return { candidate, score };
2407
+ });
2408
+ return scored
2409
+ .filter((s) => s.score >= 0.3)
2410
+ .sort((a, b) => b.score - a.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
2411
+ .slice(0, limit)
2412
+ .map((s) => s.candidate);
2413
+ }
2414
+
2415
+ /** Minimal shape for dynamic (registry-provided) vision candidates. */
2416
+ export interface DynamicCandidateLike {
2417
+ provider: string;
2418
+ modelId: string;
2419
+ input?: readonly string[];
2420
+ }
2421
+
2422
+ /** Cheap-tier heuristic for implicit default resolution (Pi 1.0 last resort). */
2423
+ const CHEAP_MODEL_RE = /flash|mini|fast|haiku|express|lite/i;
2424
+
2425
+ /**
2426
+ * First keyed registry model with image input — the last-resort implicit
2427
+ * vision default when the curated fallback chain is entirely unkeyed
2428
+ * (Pi ≥ 1.0 `getAvailableOfType("chat")`). Cheap-tier ids are preferred so
2429
+ * an implicit default never lands on a frontier-priced model; registry
2430
+ * order breaks remaining ties.
2431
+ */
2432
+ export function pickDynamicVisionCandidate<T extends DynamicCandidateLike>(models: readonly T[]): T | undefined {
2433
+ const vision = models.filter((m) => m.input?.includes("image"));
2434
+ if (vision.length === 0) return undefined;
2435
+ return vision.find((m) => CHEAP_MODEL_RE.test(m.modelId)) ?? vision[0];
2436
+ }
2437
+
2438
+ /** Outcome of image-generation model resolution (generate_image tool). */
2439
+ export type ImageGenModelChoice =
2440
+ | { ok: true; provider: string; modelId: string }
2441
+ | { ok: false; error: string; suggestions: readonly string[] };
2442
+
2443
+ /**
2444
+ * Resolve the image-generation model for one `generate_image` call (Pi ≥ 1.0).
2445
+ * An explicit `provider/model-id` override must match an available (keyed)
2446
+ * image model exactly — otherwise the error carries recovery-style close
2447
+ * matches. Without an override the first available model wins.
2448
+ */
2449
+ export function resolveImageGenModelChoice(
2450
+ override: string | undefined,
2451
+ available: ReadonlyArray<{ provider: string; modelId: string }>,
2452
+ ): ImageGenModelChoice {
2453
+ const labels = available.map((m) => `${m.provider}/${m.modelId}`);
2454
+ if (override === undefined || override === "") {
2455
+ const first = available[0];
2456
+ if (!first) {
2457
+ return {
2458
+ ok: false,
2459
+ error: "no image-generation model is available (keyed) in this session — add a provider key (e.g. pi --login openrouter)",
2460
+ suggestions: [],
2461
+ };
2462
+ }
2463
+ return { ok: true, provider: first.provider, modelId: first.modelId };
2464
+ }
2465
+ const parsed = parseModelString(override);
2466
+ if (!parsed) {
2467
+ return {
2468
+ ok: false,
2469
+ error: `invalid model string "${override}". Expected format: provider/model-id`,
2470
+ suggestions: [],
2471
+ };
2472
+ }
2473
+ const exact = available.find((m) => m.provider === parsed.provider && m.modelId === parsed.modelId);
2474
+ if (exact) return { ok: true, provider: exact.provider, modelId: exact.modelId };
2475
+ return {
2476
+ ok: false,
2477
+ error: `model "${parsed.provider}/${parsed.modelId}" not found among available image-generation models`,
2478
+ suggestions: closeMatches(`${parsed.provider}/${parsed.modelId}`, labels, 3),
2479
+ };
2480
+ }
2481
+
2376
2482
  export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
2377
2483
  if (config.mode === "off") return false;
2378
2484
  if (config.mode === "always") return true;
@@ -2524,12 +2630,20 @@ export function resolveCropEntry(crop: CropEntry, imgWidth: number, imgHeight: n
2524
2630
  }
2525
2631
  if ("normalized" in crop) {
2526
2632
  const result = normalizedToPixels(crop.normalized, imgWidth, imgHeight);
2527
- if (!result) throw new Error(`Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2633
+ if (!result)
2634
+ throw new Error(
2635
+ `Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2636
+ `Use fractions of the image: x ≥ 0, y ≥ 0, x + width ≤ 1, y + height ≤ 1.`,
2637
+ );
2528
2638
  return result;
2529
2639
  }
2530
2640
  if ("pixels" in crop) {
2531
2641
  const result = clampPixels(crop.pixels, imgWidth, imgHeight);
2532
- if (!result) throw new Error(`Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2642
+ if (!result)
2643
+ throw new Error(
2644
+ `Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2645
+ `Keep the rectangle inside the image: x ≥ 0, y ≥ 0, x + width ≤ ${imgWidth}, y + height ≤ ${imgHeight}.`,
2646
+ );
2533
2647
  return result;
2534
2648
  }
2535
2649
  throw new Error("Invalid CropEntry: must have exactly one of region, normalized, or pixels");
@@ -282,6 +282,9 @@ import {
282
282
  canonicalYouTubeUrl,
283
283
  youTubeVideoId,
284
284
  applyDefaultModelFallback,
285
+ closeMatches,
286
+ pickDynamicVisionCandidate,
287
+ resolveImageGenModelChoice,
285
288
  DEFAULT_MODEL_FALLBACKS,
286
289
  applyRecallCompletion,
287
290
  buildRecallItems,
@@ -450,27 +453,69 @@ const AnalyzeImageOutput = Type.Object({
450
453
  });
451
454
 
452
455
  const TOOL_DESCRIPTION = [
453
- "Use `analyze_image` when (a) the cached description of an image lacks a detail you need,",
454
- "(b) you need to compare or cross-reference multiple images, or (c) you need to focus on a specific region.",
456
+ "Targeted image analysis with cropping and model-native grounding support.",
455
457
  "",
456
- "**Cropping.** Three forms, in order of preference:",
458
+ "Use it when (a) a cached image description lacks a detail you need, (b) you must compare or cross-reference images, or (c) you must focus on a region. The result is authoritative for the question asked; the cached generic description stays the default for everything else.",
457
459
  "",
458
- "- **`region`** - coarse cut by name. Use when you don't have exact dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
459
- "- **`normalized`** - fractional coordinates 0.0-1.0. Default choice for precise crops without knowing image dimensions: `{ image_index: 0, normalized: { x: 0.5, y: 0.5, width: 0.4, height: 0.4 } }`.",
460
- "- **`pixels`** - absolute pixels. Use only when you have authoritative coordinates from a prior `<vision_proxy_description>` or `<vision_proxy_analysis>` (which carry `width` and `height` attributes) or from a previous grounded response. Example: `{ image_index: 0, pixels: { x: 1840, y: 120, width: 840, height: 360 } }`.",
460
+ "Image references are file paths OR the `image=\"<id>\"` id carried by every `<vision_proxy_description>`/`<vision_proxy_analysis>`/`<vision_proxy_joint_description>` fence — pass an id to re-examine or crop an image the user shared earlier, even if it is no longer attached.",
461
461
  "",
462
- "Image dimensions and filenames are available in the `width`, `height`, and `filename` attributes of `<vision_proxy_description>`, `<vision_proxy_analysis>`, and `<vision_proxy_joint_description>` blocks in your context.",
462
+ "Cropping, in order of preference (one crop per image, keyed by `image_index`):",
463
+ "- `region` — named cut (`top-left`, `bottom-right`, `center`, `right-half`, …) when you don't know the dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
464
+ "- `normalized` — `{ x, y, width, height }` as 0.0-1.0 fractions; the default for precise crops without dimensions.",
465
+ "- `pixels` — `{ x, y, width, height }` absolute; only with authoritative coordinates from a prior fence's `width`/`height` attributes or a grounded response.",
463
466
  "",
464
- "**Recalling an earlier image.** Every such block also carries an `image=\"...\"` id. To re-examine or crop an image the user shared earlier in the session — even if it is no longer attached to the current message (e.g. \"zoom into that screenshot from before\") — pass that id as the image reference instead of a file path. No re-attachment is required.",
465
- "",
466
- "When a crop is applied, the response fence carries a `crop_origin` attribute (e.g. `crop_origin=\"1840,120\"`). Add the origin's x to any returned x-coordinate and the origin's y to any returned y-coordinate to map coordinates back to the original full image.",
467
- "",
468
- "The tool result is authoritative for the specific question asked; the cached generic description remains the default for everything else.",
467
+ "Fences carry `width`, `height`, and `filename`, and a crop adds `crop_origin=\"x,y\"` — add the origin to returned coordinates to map them back to the full image.",
469
468
  ].join("\n");
470
469
 
471
470
  /** Maximum analyze_image tool calls per agent turn. Prevents cost runaway. */
472
471
  const MAX_TOOL_CALLS_PER_TURN = 10;
473
472
 
473
+ /**
474
+ * generate_image parameters (Pi ≥ 1.0). Lean on purpose: pi 1.0's codemode
475
+ * lists tool declarations under a 3000-token inline budget.
476
+ */
477
+ const GenerateImageParams = Type.Object({
478
+ prompt: Type.String({
479
+ description: "Required, non-empty, max 4000 chars. What to draw; include style and aspect-ratio cues the provider supports.",
480
+ }),
481
+ model: Type.Optional(
482
+ Type.String({
483
+ description: "Optional; provider/model-id of an image-generation model. Defaults to the first available (keyed) image model.",
484
+ }),
485
+ ),
486
+ reason: Type.Optional(Type.String({ description: "Optional; logged for analytics only" })),
487
+ });
488
+
489
+ /**
490
+ * outputSchema of generate_image: codemode scripts receive this
491
+ * structuredContent instead of the text + image blocks.
492
+ */
493
+ const GenerateImageOutput = Type.Object({
494
+ ok: Type.Boolean({ description: "Whether generation succeeded." }),
495
+ error: Type.Optional(Type.String({ description: "Failure message, present when ok is false." })),
496
+ provider: Type.Optional(Type.String({ description: "Provider that generated the images." })),
497
+ model: Type.Optional(Type.String({ description: "Model id that generated the images." })),
498
+ images: Type.Optional(
499
+ Type.Array(
500
+ Type.Object({
501
+ id: Type.String({ description: "Stable image id usable for recall (image=\"...\") and analyze_image." }),
502
+ mimeType: Type.String(),
503
+ width: Type.Optional(Type.Number()),
504
+ height: Type.Optional(Type.Number()),
505
+ }),
506
+ ),
507
+ ),
508
+ costUSD: Type.Optional(Type.Number({ description: "Reported generation cost, when the provider returned usage." })),
509
+ });
510
+
511
+ const GENERATE_IMAGE_DESCRIPTION = [
512
+ "Generate images with the session's image-generation models (Pi ≥ 1.0, request-time credentials).",
513
+ "",
514
+ "`prompt` describes what to draw; `model` optionally picks an image-generation model (provider/model-id — defaults to the first available).",
515
+ "Returns image blocks attached to this result plus stable `image=\"<id>\"` ids that analyze_image can re-analyze or crop like any other image.",
516
+ "Each call costs money — use it when the user asks for an image to be created, not speculatively.",
517
+ ].join("\n");
518
+
474
519
  /** Default tool-result cache size; resized to config.cacheSize on first use. */
475
520
  const DEFAULT_TOOL_CACHE_SIZE = 50;
476
521
 
@@ -508,6 +553,10 @@ interface SessionState {
508
553
  compaction?: { reason?: "manual" | "threshold" | "overflow"; willRetry?: boolean };
509
554
  /** Cached "provider/model has an API key" probes (cleared per session). */
510
555
  keyProbe?: Map<string, boolean>;
556
+ /** Cached dynamic (registry-keyed) vision candidate; null = none or runtime without the API (Pi ≥ 1.0). */
557
+ dynamicVisionProbe?: { provider: string; modelId: string } | null;
558
+ /** Cached available (keyed) image-generation models (Pi ≥ 1.0). */
559
+ imageGenModels?: Array<{ provider: string; modelId: string }>;
511
560
  /** Last implicit-default substitution already notified this session. */
512
561
  notifiedDefaultSub?: string;
513
562
  /**
@@ -762,7 +811,56 @@ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): P
762
811
  keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
763
812
  }
764
813
  const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
765
- return applyDefaultModelFallback(config, hasModel, false, hasKey);
814
+ const resolved = applyDefaultModelFallback(config, hasModel, false, hasKey);
815
+ // Pi ≥ 1.0 last resort: the curated chain can be entirely unkeyed while
816
+ // the user has another keyed vision-capable model (openrouter, google, …).
817
+ // getAvailableOfType lists exactly those — land the implicit default on
818
+ // the first one instead of dead-ending on the "No API key" notice.
819
+ // (Explicit choices returned early above; a keyed chain resolution already
820
+ // changed provider/modelId, which the equality check skips.)
821
+ const anyKeyed = [...keys.values()].some(Boolean);
822
+ if (!anyKeyed && resolved.provider === config.provider && resolved.modelId === config.modelId) {
823
+ const dyn = await dynamicVisionCandidate(ctx);
824
+ if (dyn) return { ...resolved, provider: dyn.provider, modelId: dyn.modelId };
825
+ }
826
+ return resolved;
827
+ }
828
+
829
+ /**
830
+ * Pi ≥ 1.0 dynamic vision-candidate probe (ModelRegistry.getAvailableOfType):
831
+ * the first keyed chat model with image input, cheap tier preferred
832
+ * (pickDynamicVisionCandidate). Feature-detected — older runtimes and probe
833
+ * failures cache as null; the probe runs once per session.
834
+ */
835
+ async function dynamicVisionCandidate(ctx: ExtensionContext): Promise<{ provider: string; modelId: string } | null> {
836
+ const state = getSessionState(ctx);
837
+ if (state.dynamicVisionProbe !== undefined) return state.dynamicVisionProbe;
838
+ let out: { provider: string; modelId: string } | null = null;
839
+ const reg = ctx.modelRegistry as ModelRegistry & {
840
+ getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
841
+ };
842
+ if (typeof reg.getAvailableOfType === "function") {
843
+ try {
844
+ const available = await reg.getAvailableOfType("chat");
845
+ const candidates = (
846
+ available as ReadonlyArray<{ provider?: unknown; id?: unknown; input?: unknown }>
847
+ )
848
+ .filter(
849
+ (m): m is { provider: string; id: string; input: readonly string[] } =>
850
+ typeof m.provider === "string" &&
851
+ typeof m.id === "string" &&
852
+ Array.isArray(m.input) &&
853
+ m.input.includes("image"),
854
+ )
855
+ .map((m) => ({ provider: m.provider, modelId: m.id, input: m.input }));
856
+ const pick = pickDynamicVisionCandidate(candidates);
857
+ if (pick) out = { provider: pick.provider, modelId: pick.modelId };
858
+ } catch {
859
+ out = null;
860
+ }
861
+ }
862
+ state.dynamicVisionProbe = out;
863
+ return out;
766
864
  }
767
865
 
768
866
  /**
@@ -1646,6 +1744,234 @@ function analysisError(message: string): AnalyzeImageOutcome {
1646
1744
  return { text: message, isError: true, structured: { ok: false, error: message } };
1647
1745
  }
1648
1746
 
1747
+ /**
1748
+ * Close-match vision-model suggestions for recovery-style tool errors, in
1749
+ * the spirit of Pi 1.0's codemode errors ("tools.Bash suggests
1750
+ * tools.bash"). Labels every registry model with image input; no key
1751
+ * probing — error paths must stay cheap.
1752
+ */
1753
+ function registryVisionSuggestions(ctx: ExtensionContext, query: string, limit = 3): string[] {
1754
+ try {
1755
+ const models = ctx.modelRegistry.getAll() as unknown as ReadonlyArray<{
1756
+ provider?: unknown;
1757
+ id?: unknown;
1758
+ input?: unknown;
1759
+ }>;
1760
+ const labels = models
1761
+ .filter(
1762
+ (m): m is { provider: string; id: string; input: readonly string[] } =>
1763
+ typeof m.provider === "string" &&
1764
+ typeof m.id === "string" &&
1765
+ Array.isArray(m.input) &&
1766
+ m.input.includes("image"),
1767
+ )
1768
+ .map((m) => `${m.provider}/${m.id}`);
1769
+ return closeMatches(query, labels, limit);
1770
+ } catch {
1771
+ return [];
1772
+ }
1773
+ }
1774
+
1775
+ /**
1776
+ * Close-match recall-id suggestions: matches the mistyped id against every
1777
+ * retained image's hash (and filename, when known) and returns full ids the
1778
+ * agent can pass verbatim.
1779
+ */
1780
+ function recallSuggestions(imageData: ImageDataStore, imageMeta: ImageMetaStore, query: string, limit = 3): string[] {
1781
+ const candidates: string[] = [];
1782
+ try {
1783
+ for (const hash of imageData.map.keys()) {
1784
+ const meta = imageMeta.get(hash);
1785
+ candidates.push(meta?.filename ? `${hash} (${meta.filename})` : hash);
1786
+ }
1787
+ } catch {
1788
+ return [];
1789
+ }
1790
+ return closeMatches(query, candidates, limit);
1791
+ }
1792
+
1793
+ /** Content block shapes a tool result may carry (text and/or image). */
1794
+ type ToolContentBlock = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
1795
+
1796
+ /** Structured outcome of one generate_image call (1.20.0). */
1797
+ interface GenerateImageOutcome {
1798
+ content: ToolContentBlock[];
1799
+ isError?: boolean;
1800
+ structured?: Record<string, unknown>;
1801
+ usage?: unknown;
1802
+ }
1803
+
1804
+ /** Wrap a failure message as a GenerateImageOutcome. */
1805
+ function generationError(message: string): GenerateImageOutcome {
1806
+ return { content: [{ type: "text", text: message }], isError: true, structured: { ok: false, error: message } };
1807
+ }
1808
+
1809
+ /**
1810
+ * Available (keyed) image-generation models via getAvailableOfType("image")
1811
+ * (Pi ≥ 1.0, feature-detected). Cached per session; probe failures cache as
1812
+ * an empty list so a transient error cannot wedge the tool.
1813
+ */
1814
+ async function availableImageGenModels(ctx: ExtensionContext): Promise<Array<{ provider: string; modelId: string }>> {
1815
+ const state = getSessionState(ctx);
1816
+ if (state.imageGenModels) return state.imageGenModels;
1817
+ let out: Array<{ provider: string; modelId: string }> = [];
1818
+ const reg = ctx.modelRegistry as ModelRegistry & {
1819
+ getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
1820
+ };
1821
+ if (typeof reg.getAvailableOfType === "function") {
1822
+ try {
1823
+ const models = (await reg.getAvailableOfType("image")) as ReadonlyArray<{ provider?: unknown; id?: unknown }>;
1824
+ out = models
1825
+ .filter((m): m is { provider: string; id: string } => typeof m.provider === "string" && typeof m.id === "string")
1826
+ .map((m) => ({ provider: m.provider, modelId: m.id }));
1827
+ } catch {
1828
+ out = [];
1829
+ }
1830
+ }
1831
+ state.imageGenModels = out;
1832
+ return out;
1833
+ }
1834
+
1835
+ /** Minimal response shape of ModelRegistry.generateImages() (Pi ≥ 1.0). */
1836
+ interface GenerateImagesResponseLike {
1837
+ output?: ReadonlyArray<{ type?: unknown; data?: unknown; mimeType?: unknown }>;
1838
+ usage?: { costUSD?: unknown; inputTokens?: unknown; outputTokens?: unknown };
1839
+ stopReason?: unknown;
1840
+ errorMessage?: unknown;
1841
+ }
1842
+
1843
+ /**
1844
+ * Generate one or more images with the session's image-generation models
1845
+ * (Pi ≥ 1.0 `models.generateImages()`): resolve the model (explicit override
1846
+ * with recovery-style suggestions, else the first keyed image model), gate
1847
+ * on data-egress consent (the prompt leaves), call the registry, and return
1848
+ * image blocks the conversation can see. Generated images are persisted in
1849
+ * the recall stores, so analyze_image (and `#` recall once described) work
1850
+ * on them exactly like attached images — including when the base model sees
1851
+ * the pixels directly and the tool_result auto-description path never fires.
1852
+ */
1853
+ async function handleGenerateImage(
1854
+ params: { prompt: string; model?: string; reason?: string },
1855
+ ctx: ExtensionContext,
1856
+ config: VisionConfig,
1857
+ ): Promise<GenerateImageOutcome> {
1858
+ const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
1859
+ if (!prompt) return generationError("Error: prompt is required and must be non-empty.");
1860
+ if (prompt.length > 4000) return generationError("Error: prompt must be at most 4000 characters.");
1861
+
1862
+ // Resolve the image-generation model.
1863
+ const available = await availableImageGenModels(ctx);
1864
+ const choice = resolveImageGenModelChoice(params.model, available);
1865
+ if (!choice.ok) {
1866
+ return generationError(
1867
+ `Error: ${choice.error}.` +
1868
+ (choice.suggestions.length > 0 ? ` Close matches: ${choice.suggestions.join(", ")}.` : ""),
1869
+ );
1870
+ }
1871
+
1872
+ // Data-egress consent: the prompt leaves for the image provider.
1873
+ const entries = ctx.sessionManager.getEntries();
1874
+ if (!hasConsent(entries, choice.provider, config.allowedProviders, config.deniedProviders)) {
1875
+ return generationError(
1876
+ `Error: consent required before sending the prompt to ${choice.provider}. Please tell the user to run the following command and then retry:\n\n/multimodal-proxy consent yes\n\n(To pre-consent this provider permanently: /multimodal-proxy allowed-providers add ${choice.provider})`,
1877
+ );
1878
+ }
1879
+
1880
+ // Fetch the ImageModel object for the chosen pair (sync registry lookups).
1881
+ const reg = ctx.modelRegistry as ModelRegistry & {
1882
+ getModelsOfType?: (type: string) => ReadonlyArray<unknown>;
1883
+ findOfType?: (type: string, provider: string, modelId: string) => unknown;
1884
+ generateImages?: (
1885
+ model: unknown,
1886
+ context: { input: Array<{ type: "text"; text: string }> },
1887
+ options?: { signal?: AbortSignal },
1888
+ ) => Promise<GenerateImagesResponseLike>;
1889
+ };
1890
+ if (typeof reg.generateImages !== "function") {
1891
+ return generationError("Error: this pi runtime does not expose models.generateImages() (needs pi ≥ 1.0). Update pi.");
1892
+ }
1893
+ let imageModel: unknown;
1894
+ if (typeof reg.getModelsOfType === "function") {
1895
+ const all = reg.getModelsOfType("image") as ReadonlyArray<{ provider?: unknown; id?: unknown }>;
1896
+ imageModel = all.find(
1897
+ (m) =>
1898
+ typeof m.provider === "string" &&
1899
+ typeof m.id === "string" &&
1900
+ m.provider === choice.provider &&
1901
+ m.id === choice.modelId,
1902
+ );
1903
+ }
1904
+ if (!imageModel && typeof reg.findOfType === "function") {
1905
+ imageModel = reg.findOfType("image", choice.provider, choice.modelId);
1906
+ }
1907
+ if (!imageModel) {
1908
+ return generationError(
1909
+ `Error: image model "${choice.provider}/${choice.modelId}" could not be resolved in the registry.`,
1910
+ );
1911
+ }
1912
+
1913
+ let response: GenerateImagesResponseLike;
1914
+ try {
1915
+ response = await reg.generateImages(imageModel, { input: [{ type: "text", text: prompt }] }, { signal: ctx.signal });
1916
+ } catch (err) {
1917
+ return generationError(`Error: image generation failed: ${err instanceof Error ? err.message : String(err)}`);
1918
+ }
1919
+ if (response.stopReason === "aborted") return generationError("Error: generation was cancelled.");
1920
+ if (response.stopReason === "error") {
1921
+ return generationError(
1922
+ `Error: image generation failed: ${typeof response.errorMessage === "string" ? response.errorMessage : "unknown provider error"}`,
1923
+ );
1924
+ }
1925
+
1926
+ const blocks = (response.output ?? []).filter(
1927
+ (b): b is { type: "image"; data: string; mimeType: string } =>
1928
+ b.type === "image" && typeof b.data === "string" && typeof b.mimeType === "string",
1929
+ );
1930
+ if (blocks.length === 0) {
1931
+ return generationError(
1932
+ "Error: the model returned no images (text-only response). Try a different prompt or model.",
1933
+ );
1934
+ }
1935
+
1936
+ // Persist recall data so analyze_image (and # recall, once a description
1937
+ // exists) work on generated images even when the base model sees the
1938
+ // pixels directly and the tool_result auto-description path never fires.
1939
+ const { imageMeta, imageData } = getSessionState(ctx);
1940
+ const structuredImages: Array<Record<string, unknown>> = [];
1941
+ for (const b of blocks) {
1942
+ const hash = hashImageData(b.data);
1943
+ const ext = b.mimeType.split("/")[1]?.replace(/[^a-z0-9]/gi, "") || "png";
1944
+ storeImageMeta(imageMeta, hash, b.data, `generated-${hash.slice(0, 8)}.${ext}`);
1945
+ storeImageData(imageData, hash, b.data, b.mimeType);
1946
+ const dims = imageMeta.get(hash);
1947
+ structuredImages.push({
1948
+ id: hash,
1949
+ mimeType: b.mimeType,
1950
+ ...(dims ? { width: dims.width, height: dims.height } : {}),
1951
+ });
1952
+ }
1953
+
1954
+ const costUSD = typeof response.usage?.costUSD === "number" ? response.usage.costUSD : undefined;
1955
+ const ids = structuredImages.map((i) => `image="${i.id}"`).join(", ");
1956
+ const text =
1957
+ `[generate_image] ${pluralImages(blocks.length)} generated via ${choice.provider}/${choice.modelId}` +
1958
+ (ids ? ` (${ids})` : "") +
1959
+ (costUSD !== undefined ? ` — cost $${costUSD.toFixed(4)}` : "") +
1960
+ ". The images are attached as image blocks; their ids can be re-analyzed or cropped with analyze_image.";
1961
+
1962
+ return {
1963
+ content: [{ type: "text", text }, ...blocks],
1964
+ structured: {
1965
+ ok: true,
1966
+ provider: choice.provider,
1967
+ model: choice.modelId,
1968
+ images: structuredImages,
1969
+ ...(costUSD !== undefined ? { costUSD } : {}),
1970
+ },
1971
+ ...(response.usage ? { usage: response.usage } : {}),
1972
+ };
1973
+ }
1974
+
1649
1975
  /**
1650
1976
  * Answer a targeted analyze_image question about one or more images: resolve
1651
1977
  * references (session recall ids or file paths), validate and apply crops,
@@ -1709,7 +2035,12 @@ async function handleAnalyzeImage(
1709
2035
  // Verify model exists and supports images
1710
2036
  const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
1711
2037
  if (!visionModel) {
1712
- return analysisError(`Error: model "${visionProvider}/${visionModelId}" not found in registry. Use /multimodal-proxy pick to choose a vision model.`);
2038
+ const suggestions = registryVisionSuggestions(ctx, `${visionProvider}/${visionModelId}`);
2039
+ return analysisError(
2040
+ `Error: model "${visionProvider}/${visionModelId}" not found in registry.` +
2041
+ (suggestions.length > 0 ? ` Close matches: ${suggestions.join(", ")}.` : "") +
2042
+ " Use /multimodal-proxy pick to choose a vision model.",
2043
+ );
1713
2044
  }
1714
2045
  if (!visionModel.input.includes("image")) {
1715
2046
  return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
@@ -1731,7 +2062,12 @@ async function handleAnalyzeImage(
1731
2062
  if (recallHash) {
1732
2063
  const stored = getImageData(imageData, recallHash);
1733
2064
  if (!stored) {
1734
- return analysisError(`Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed. Ask the user to re-attach it, or pass a file path.`);
2065
+ const suggestions = recallSuggestions(imageData, imageMeta, recallHash);
2066
+ return analysisError(
2067
+ `Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed.` +
2068
+ (suggestions.length > 0 ? ` Close ids still retained: ${suggestions.join(", ")}.` : "") +
2069
+ " Ask the user to re-attach it, or pass a file path.",
2070
+ );
1735
2071
  }
1736
2072
  const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
1737
2073
  // Backfill dimensions if metadata was evicted, so crops still work on recall.
@@ -2000,6 +2336,7 @@ interface EditorAutocompleteProvider {
2000
2336
 
2001
2337
  export default function (pi: ExtensionAPI) {
2002
2338
  let _toolRegistered = false;
2339
+ let _generateToolRegistered = false;
2003
2340
  let _autocompleteRegistered = false;
2004
2341
 
2005
2342
  /**
@@ -2235,7 +2572,7 @@ export default function (pi: ExtensionAPI) {
2235
2572
  });
2236
2573
 
2237
2574
  /** Register or unregister the analyze_image tool based on config. */
2238
- function syncToolRegistration(config: VisionConfig) {
2575
+ function syncToolRegistration(config: VisionConfig, runtimeCtx?: ExtensionContext) {
2239
2576
  const shouldHaveTool = config.mode !== "off" && config.tool === "on";
2240
2577
  if (shouldHaveTool && !_toolRegistered) {
2241
2578
  pi.registerTool({
@@ -2298,6 +2635,82 @@ export default function (pi: ExtensionAPI) {
2298
2635
  });
2299
2636
  _toolRegistered = true;
2300
2637
  }
2638
+ // generate_image (Pi ≥ 1.0 only): registered alongside analyze_image
2639
+ // when the runtime exposes ModelRegistry.generateImages; silently
2640
+ // skipped on older hosts so the tool never appears there.
2641
+ if (
2642
+ shouldHaveTool &&
2643
+ !_generateToolRegistered &&
2644
+ runtimeCtx &&
2645
+ typeof (runtimeCtx.modelRegistry as ModelRegistry & { generateImages?: unknown }).generateImages ===
2646
+ "function"
2647
+ ) {
2648
+ pi.registerTool({
2649
+ name: "generate_image",
2650
+ label: "Generate Image",
2651
+ description: GENERATE_IMAGE_DESCRIPTION,
2652
+ promptSnippet: "Generate images with the session's image-generation models",
2653
+ promptGuidelines: [
2654
+ "Use generate_image when the user explicitly asks to create an image; each call costs money.",
2655
+ 'Generated images carry image="<id>" ids and can be re-analyzed or cropped with analyze_image like any other image.',
2656
+ ],
2657
+ // Cost-incurring content creation: not read-only. openWorldHint — the
2658
+ // prompt leaves for the configured image provider.
2659
+ annotations: {
2660
+ readOnlyHint: false,
2661
+ destructiveHint: false,
2662
+ idempotentHint: false,
2663
+ openWorldHint: true,
2664
+ },
2665
+ namespace: {
2666
+ name: "multimodal-proxy",
2667
+ description: "pi-multimodal-proxy media analysis (targeted image re-analysis with crop and grounding)",
2668
+ },
2669
+ outputSchema: GenerateImageOutput,
2670
+ parameters: GenerateImageParams,
2671
+ execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
2672
+ const entries = extCtx.sessionManager.getEntries();
2673
+ const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
2674
+
2675
+ // Runtime check - tool may have been disabled mid-session
2676
+ if (config.tool !== "on" || config.mode === "off") {
2677
+ return {
2678
+ content: [
2679
+ {
2680
+ type: "text" as const,
2681
+ text: "Error: generate_image tool is currently disabled. Use /multimodal-proxy tool on to enable.",
2682
+ },
2683
+ ],
2684
+ isError: true,
2685
+ };
2686
+ }
2687
+
2688
+ // Share the per-turn cost-runaway budget with analyze_image.
2689
+ const state = getSessionState(extCtx);
2690
+ state.toolCallCount++;
2691
+ if (state.toolCallCount > MAX_TOOL_CALLS_PER_TURN) {
2692
+ return {
2693
+ content: [
2694
+ {
2695
+ type: "text" as const,
2696
+ text: `Error: tool call limit reached (${MAX_TOOL_CALLS_PER_TURN} per turn). Try again in the next turn.`,
2697
+ },
2698
+ ],
2699
+ isError: true,
2700
+ };
2701
+ }
2702
+
2703
+ const outcome = await handleGenerateImage(params, extCtx, config);
2704
+ return {
2705
+ content: outcome.content,
2706
+ ...(outcome.isError ? { isError: true } : {}),
2707
+ ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2708
+ ...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
2709
+ };
2710
+ },
2711
+ });
2712
+ _generateToolRegistered = true;
2713
+ }
2301
2714
  // Note: Pi's extension API doesn't have unregisterTool - tool registration
2302
2715
  // persists for the session. The tool's execute handler checks the current
2303
2716
  // config at runtime and returns an error if disabled.
@@ -2313,6 +2726,8 @@ export default function (pi: ExtensionAPI) {
2313
2726
  clearImageData(state.imageData);
2314
2727
  state.compaction = undefined;
2315
2728
  state.keyProbe = undefined;
2729
+ state.dynamicVisionProbe = undefined;
2730
+ state.imageGenModels = undefined;
2316
2731
  state.notifiedDefaultSub = undefined;
2317
2732
 
2318
2733
  _fileConfig = await readPersistentFile();
@@ -2322,7 +2737,7 @@ export default function (pi: ExtensionAPI) {
2322
2737
  ctx.ui.setStatus("multimodal-proxy", steadyStatusText(config, ctx.modelRegistry));
2323
2738
 
2324
2739
  // Register tool if enabled
2325
- syncToolRegistration(config);
2740
+ syncToolRegistration(config, ctx);
2326
2741
 
2327
2742
  // Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
2328
2743
  syncVirtualModelRegistration(config);
@@ -3214,7 +3629,7 @@ export default function (pi: ExtensionAPI) {
3214
3629
  next.mode === "off" ? "warning" : "info",
3215
3630
  );
3216
3631
  // Sync tool registration on mode change
3217
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
3632
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
3218
3633
  return;
3219
3634
  }
3220
3635
 
@@ -3626,7 +4041,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3626
4041
  }
3627
4042
  if (valueLower === "on") {
3628
4043
  const next = writePersisted({ ...persisted, tool: "on" });
3629
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4044
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
3630
4045
  ctx.ui.notify(`[multimodal-proxy] analyze_image tool: ON`, "info");
3631
4046
  return;
3632
4047
  }
@@ -4367,7 +4782,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
4367
4782
  if (modeChoice !== "fallback" && modeChoice !== "always" && modeChoice !== "off") return;
4368
4783
  const next = writePersisted({ ...persisted, mode: modeChoice });
4369
4784
  ctx.ui.notify(`Mode set to: ${next.mode}`, "info");
4370
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4785
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
4371
4786
  return;
4372
4787
  }
4373
4788
 
@@ -4396,7 +4811,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
4396
4811
  }
4397
4812
  const nextTool = effective.tool === "on" ? "off" : "on";
4398
4813
  writePersisted({ ...persisted, tool: nextTool });
4399
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4814
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
4400
4815
  ctx.ui.notify(`Tool: ${nextTool}`, nextTool === "on" ? "info" : "warning");
4401
4816
  return;
4402
4817
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
5
5
  "keywords": [
6
6
  "pi-package"