pi-multimodal-proxy 1.19.0 → 1.19.1

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,16 @@ 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.19.1] - 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - **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.
12
+ - **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".
13
+ - **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.
14
+ - **`#` 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.
15
+ - 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).
16
+
7
17
  ## [1.19.0] - 2026-10-01
8
18
 
9
19
  ### Added
@@ -79,6 +79,8 @@ import {
79
79
  bufferToPiAiImage,
80
80
  shouldStripImages,
81
81
  selectVisionModels,
82
+ closeMatches,
83
+ pickDynamicVisionCandidate,
82
84
  splitSubcommand,
83
85
  stripImagePaths,
84
86
  stripMediaPaths,
@@ -374,6 +376,66 @@ describe("selectVisionModels", () => {
374
376
  });
375
377
  });
376
378
 
379
+ describe("closeMatches", () => {
380
+ const catalog = [
381
+ "anthropic/claude-sonnet-5",
382
+ "anthropic/claude-sonnet-4-5",
383
+ "deepseek/deepseek-v4-flash-vision-exp",
384
+ "zai/glm-5.3-flash",
385
+ "openai/gpt-5.6",
386
+ ];
387
+
388
+ it("ranks a near-typo above unrelated models", () => {
389
+ assert.deepEqual(closeMatches("anthropic/claude-sont-5", catalog, 2), [
390
+ "anthropic/claude-sonnet-5",
391
+ "anthropic/claude-sonnet-4-5",
392
+ ]);
393
+ });
394
+
395
+ it("gives substring matches a strong bonus", () => {
396
+ assert.deepEqual(closeMatches("glm", catalog, 1), ["zai/glm-5.3-flash"]);
397
+ });
398
+
399
+ it("returns nothing for queries with no reasonable match", () => {
400
+ assert.deepEqual(closeMatches("banana-pudding", catalog), []);
401
+ });
402
+
403
+ it("is deterministic on ties (alphabetical)", () => {
404
+ assert.deepEqual(closeMatches("mod", ["mod-b", "mod-a", "mod-c"], 3), ["mod-a", "mod-b", "mod-c"]);
405
+ });
406
+
407
+ it("handles empty input", () => {
408
+ assert.deepEqual(closeMatches("", catalog), []);
409
+ assert.deepEqual(closeMatches("x", []), []);
410
+ });
411
+ });
412
+
413
+ describe("pickDynamicVisionCandidate", () => {
414
+ const m = (provider: string, modelId: string, input: readonly string[] = ["text", "image"]) => ({
415
+ provider,
416
+ modelId,
417
+ input,
418
+ });
419
+
420
+ it("prefers a cheap-tier model over registry order", () => {
421
+ const out = pickDynamicVisionCandidate([
422
+ m("anthropic", "claude-opus-6"),
423
+ m("zai", "glm-5.3-flash"),
424
+ ]);
425
+ assert.equal(out?.modelId, "glm-5.3-flash");
426
+ });
427
+
428
+ it("falls back to the first image-capable model when none is cheap-tier", () => {
429
+ const out = pickDynamicVisionCandidate([m("anthropic", "claude-opus-6"), m("x", "y")]);
430
+ assert.equal(out?.modelId, "claude-opus-6");
431
+ });
432
+
433
+ it("skips models without image input and empty lists", () => {
434
+ assert.equal(pickDynamicVisionCandidate([m("a", "b", ["text"])]), undefined);
435
+ assert.equal(pickDynamicVisionCandidate([]), undefined);
436
+ });
437
+ });
438
+
377
439
  describe("sanitizeProviderHeaders", () => {
378
440
  it("drops null deletion markers", () => {
379
441
  assert.deepEqual(
@@ -1844,6 +1906,21 @@ describe("resolveCropEntry", () => {
1844
1906
  /zero area/,
1845
1907
  );
1846
1908
  });
1909
+
1910
+ it("zero-area normalized errors carry a recovery hint (pi 1.0 style)", () => {
1911
+ assert.throws(
1912
+ () =>
1913
+ resolveCropEntry({ image_index: 0, normalized: { x: 1.0, y: 1.0, width: 0, height: 0 } }, 100, 100),
1914
+ /fractions of the image/,
1915
+ );
1916
+ });
1917
+
1918
+ it("zero-area pixel errors carry a recovery hint (pi 1.0 style)", () => {
1919
+ assert.throws(
1920
+ () => resolveCropEntry({ image_index: 0, pixels: { x: 200, y: 200, width: 10, height: 10 } }, 100, 100),
1921
+ /inside the image/,
1922
+ );
1923
+ });
1847
1924
  });
1848
1925
 
1849
1926
  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,68 @@ 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
+
2376
2438
  export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
2377
2439
  if (config.mode === "off") return false;
2378
2440
  if (config.mode === "always") return true;
@@ -2524,12 +2586,20 @@ export function resolveCropEntry(crop: CropEntry, imgWidth: number, imgHeight: n
2524
2586
  }
2525
2587
  if ("normalized" in crop) {
2526
2588
  const result = normalizedToPixels(crop.normalized, imgWidth, imgHeight);
2527
- if (!result) throw new Error(`Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2589
+ if (!result)
2590
+ throw new Error(
2591
+ `Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2592
+ `Use fractions of the image: x ≥ 0, y ≥ 0, x + width ≤ 1, y + height ≤ 1.`,
2593
+ );
2528
2594
  return result;
2529
2595
  }
2530
2596
  if ("pixels" in crop) {
2531
2597
  const result = clampPixels(crop.pixels, imgWidth, imgHeight);
2532
- if (!result) throw new Error(`Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2598
+ if (!result)
2599
+ throw new Error(
2600
+ `Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2601
+ `Keep the rectangle inside the image: x ≥ 0, y ≥ 0, x + width ≤ ${imgWidth}, y + height ≤ ${imgHeight}.`,
2602
+ );
2533
2603
  return result;
2534
2604
  }
2535
2605
  throw new Error("Invalid CropEntry: must have exactly one of region, normalized, or pixels");
@@ -282,6 +282,8 @@ import {
282
282
  canonicalYouTubeUrl,
283
283
  youTubeVideoId,
284
284
  applyDefaultModelFallback,
285
+ closeMatches,
286
+ pickDynamicVisionCandidate,
285
287
  DEFAULT_MODEL_FALLBACKS,
286
288
  applyRecallCompletion,
287
289
  buildRecallItems,
@@ -450,22 +452,18 @@ const AnalyzeImageOutput = Type.Object({
450
452
  });
451
453
 
452
454
  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.",
455
+ "Targeted image analysis with cropping and model-native grounding support.",
455
456
  "",
456
- "**Cropping.** Three forms, in order of preference:",
457
+ "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
458
  "",
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 } }`.",
459
+ "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
460
  "",
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.",
461
+ "Cropping, in order of preference (one crop per image, keyed by `image_index`):",
462
+ "- `region` — named cut (`top-left`, `bottom-right`, `center`, `right-half`, …) when you don't know the dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
463
+ "- `normalized` — `{ x, y, width, height }` as 0.0-1.0 fractions; the default for precise crops without dimensions.",
464
+ "- `pixels` — `{ x, y, width, height }` absolute; only with authoritative coordinates from a prior fence's `width`/`height` attributes or a grounded response.",
463
465
  "",
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.",
466
+ "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
467
  ].join("\n");
470
468
 
471
469
  /** Maximum analyze_image tool calls per agent turn. Prevents cost runaway. */
@@ -508,6 +506,8 @@ interface SessionState {
508
506
  compaction?: { reason?: "manual" | "threshold" | "overflow"; willRetry?: boolean };
509
507
  /** Cached "provider/model has an API key" probes (cleared per session). */
510
508
  keyProbe?: Map<string, boolean>;
509
+ /** Cached dynamic (registry-keyed) vision candidate; null = none or runtime without the API (Pi ≥ 1.0). */
510
+ dynamicVisionProbe?: { provider: string; modelId: string } | null;
511
511
  /** Last implicit-default substitution already notified this session. */
512
512
  notifiedDefaultSub?: string;
513
513
  /**
@@ -762,7 +762,56 @@ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): P
762
762
  keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
763
763
  }
764
764
  const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
765
- return applyDefaultModelFallback(config, hasModel, false, hasKey);
765
+ const resolved = applyDefaultModelFallback(config, hasModel, false, hasKey);
766
+ // Pi ≥ 1.0 last resort: the curated chain can be entirely unkeyed while
767
+ // the user has another keyed vision-capable model (openrouter, google, …).
768
+ // getAvailableOfType lists exactly those — land the implicit default on
769
+ // the first one instead of dead-ending on the "No API key" notice.
770
+ // (Explicit choices returned early above; a keyed chain resolution already
771
+ // changed provider/modelId, which the equality check skips.)
772
+ const anyKeyed = [...keys.values()].some(Boolean);
773
+ if (!anyKeyed && resolved.provider === config.provider && resolved.modelId === config.modelId) {
774
+ const dyn = await dynamicVisionCandidate(ctx);
775
+ if (dyn) return { ...resolved, provider: dyn.provider, modelId: dyn.modelId };
776
+ }
777
+ return resolved;
778
+ }
779
+
780
+ /**
781
+ * Pi ≥ 1.0 dynamic vision-candidate probe (ModelRegistry.getAvailableOfType):
782
+ * the first keyed chat model with image input, cheap tier preferred
783
+ * (pickDynamicVisionCandidate). Feature-detected — older runtimes and probe
784
+ * failures cache as null; the probe runs once per session.
785
+ */
786
+ async function dynamicVisionCandidate(ctx: ExtensionContext): Promise<{ provider: string; modelId: string } | null> {
787
+ const state = getSessionState(ctx);
788
+ if (state.dynamicVisionProbe !== undefined) return state.dynamicVisionProbe;
789
+ let out: { provider: string; modelId: string } | null = null;
790
+ const reg = ctx.modelRegistry as ModelRegistry & {
791
+ getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
792
+ };
793
+ if (typeof reg.getAvailableOfType === "function") {
794
+ try {
795
+ const available = await reg.getAvailableOfType("chat");
796
+ const candidates = (
797
+ available as ReadonlyArray<{ provider?: unknown; id?: unknown; input?: unknown }>
798
+ )
799
+ .filter(
800
+ (m): m is { provider: string; id: string; input: readonly string[] } =>
801
+ typeof m.provider === "string" &&
802
+ typeof m.id === "string" &&
803
+ Array.isArray(m.input) &&
804
+ m.input.includes("image"),
805
+ )
806
+ .map((m) => ({ provider: m.provider, modelId: m.id, input: m.input }));
807
+ const pick = pickDynamicVisionCandidate(candidates);
808
+ if (pick) out = { provider: pick.provider, modelId: pick.modelId };
809
+ } catch {
810
+ out = null;
811
+ }
812
+ }
813
+ state.dynamicVisionProbe = out;
814
+ return out;
766
815
  }
767
816
 
768
817
  /**
@@ -1646,6 +1695,52 @@ function analysisError(message: string): AnalyzeImageOutcome {
1646
1695
  return { text: message, isError: true, structured: { ok: false, error: message } };
1647
1696
  }
1648
1697
 
1698
+ /**
1699
+ * Close-match vision-model suggestions for recovery-style tool errors, in
1700
+ * the spirit of Pi 1.0's codemode errors ("tools.Bash suggests
1701
+ * tools.bash"). Labels every registry model with image input; no key
1702
+ * probing — error paths must stay cheap.
1703
+ */
1704
+ function registryVisionSuggestions(ctx: ExtensionContext, query: string, limit = 3): string[] {
1705
+ try {
1706
+ const models = ctx.modelRegistry.getAll() as unknown as ReadonlyArray<{
1707
+ provider?: unknown;
1708
+ id?: unknown;
1709
+ input?: unknown;
1710
+ }>;
1711
+ const labels = models
1712
+ .filter(
1713
+ (m): m is { provider: string; id: string; input: readonly string[] } =>
1714
+ typeof m.provider === "string" &&
1715
+ typeof m.id === "string" &&
1716
+ Array.isArray(m.input) &&
1717
+ m.input.includes("image"),
1718
+ )
1719
+ .map((m) => `${m.provider}/${m.id}`);
1720
+ return closeMatches(query, labels, limit);
1721
+ } catch {
1722
+ return [];
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Close-match recall-id suggestions: matches the mistyped id against every
1728
+ * retained image's hash (and filename, when known) and returns full ids the
1729
+ * agent can pass verbatim.
1730
+ */
1731
+ function recallSuggestions(imageData: ImageDataStore, imageMeta: ImageMetaStore, query: string, limit = 3): string[] {
1732
+ const candidates: string[] = [];
1733
+ try {
1734
+ for (const hash of imageData.map.keys()) {
1735
+ const meta = imageMeta.get(hash);
1736
+ candidates.push(meta?.filename ? `${hash} (${meta.filename})` : hash);
1737
+ }
1738
+ } catch {
1739
+ return [];
1740
+ }
1741
+ return closeMatches(query, candidates, limit);
1742
+ }
1743
+
1649
1744
  /**
1650
1745
  * Answer a targeted analyze_image question about one or more images: resolve
1651
1746
  * references (session recall ids or file paths), validate and apply crops,
@@ -1709,7 +1804,12 @@ async function handleAnalyzeImage(
1709
1804
  // Verify model exists and supports images
1710
1805
  const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
1711
1806
  if (!visionModel) {
1712
- return analysisError(`Error: model "${visionProvider}/${visionModelId}" not found in registry. Use /multimodal-proxy pick to choose a vision model.`);
1807
+ const suggestions = registryVisionSuggestions(ctx, `${visionProvider}/${visionModelId}`);
1808
+ return analysisError(
1809
+ `Error: model "${visionProvider}/${visionModelId}" not found in registry.` +
1810
+ (suggestions.length > 0 ? ` Close matches: ${suggestions.join(", ")}.` : "") +
1811
+ " Use /multimodal-proxy pick to choose a vision model.",
1812
+ );
1713
1813
  }
1714
1814
  if (!visionModel.input.includes("image")) {
1715
1815
  return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
@@ -1731,7 +1831,12 @@ async function handleAnalyzeImage(
1731
1831
  if (recallHash) {
1732
1832
  const stored = getImageData(imageData, recallHash);
1733
1833
  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.`);
1834
+ const suggestions = recallSuggestions(imageData, imageMeta, recallHash);
1835
+ return analysisError(
1836
+ `Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed.` +
1837
+ (suggestions.length > 0 ? ` Close ids still retained: ${suggestions.join(", ")}.` : "") +
1838
+ " Ask the user to re-attach it, or pass a file path.",
1839
+ );
1735
1840
  }
1736
1841
  const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
1737
1842
  // Backfill dimensions if metadata was evicted, so crops still work on recall.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
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"