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",
|
package/extensions/internal.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|