pi-multimodal-proxy 1.22.1 → 1.23.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,12 @@ 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.23.0] - 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - **`#` image-recall autocomplete never fired on vision-capable base models** — two compounding defects, found live while dogfooding the 1.22.1 README claims. First, the dropdown drew candidates **only** from persisted `DescriptionEntry`s, so images analyzed via the `analyze_image` tool (which stores bytes in the in-session recall store but appends no description entry) and images retained in the store were invisible to it. Second, in the most common modern configuration (multimodal base model + default `fallback` mode) pasted/attached images pass through as pixels and entered **no** store at all — no description, no bytes, no meta — leaving the autocomplete permanently empty no matter what the user did. Fixes: `collectRecallCandidates` now unions described images with the recall store's keys (described entries win on collision, store-only candidates show the filename or hash-prefix label); and the `before_agent_start` pass-through branch retains attachment bytes+meta in the existing in-memory LRU store (no vision call, no description, no new persistence — the bytes were already in process as message attachments). Net effect: `#` and `analyze_image` recall now work in every mode except `off` (where the extension is disabled by intent and retains nothing), matching the README's "media memory" pillar. Two review fixes folded in (PR #40): retention is skipped entirely in `off` mode, and `storeImageData` runs unconditionally on re-attachment so the LRU refreshes recency (attach A, B, A, C evicts B, not A). Verified against pi 1.1.0's `AutocompleteItem`/`AutocompleteProvider` shapes (no API drift involved).
12
+
7
13
  ## [1.22.1] - 2026-10-10
8
14
 
9
15
  ### Changed
package/README.md CHANGED
@@ -7,7 +7,7 @@ Media understanding for [Pi](https://pi.dev): images, video, audio, and YouTube
7
7
  - **Video, audio & YouTube** — paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`) or a local file path and a multimodal model (default: Grok 4.3) transcribes speech with speaker diarization, describes visual scenes, reads on-screen text, and reasons about the content in a single call. Almost no chat model or agent ingests video natively: this is the package's deepest capability.
8
8
  - **Vision for text-only models** — a large slice of Pi's catalog (DeepSeek, GLM, Nemotron, Devstral, gpt-oss, …) still has no image input. When an image reaches such a model, the extension describes it via your configured vision model, persists the description in the session, and injects it into context. The model "sees" images across turns, cached by content hash.
9
9
  - **Cheaper vision for multimodal models** — select `Multimodal Auto` and media turns escalate natively to the vision model (real pixels, no text fence; prompt caches survive the routing) while text turns stay on your base model. Pin it with `/multimodal-proxy virtual-base` to keep the cheap model after media turns too: unpinned, a text turn reuses whatever physical model handled the turn before, which after a media turn is the vision model. Or proxy every image through a flash-priced describer with mode `always`.
10
- - **Media memory that outlives the turn** — every proxied image gets a stable `image="<id>"`, so the agent can re-query or crop an earlier screenshot without a re-attachment ("zoom into that screenshot from before"), `#` autocompletes image ids in the prompt, and descriptions survive compaction via a recall digest. `analyze_image` answers targeted questions with locally applied crops; `generate_image` creates images that flow straight back through the same pipeline. (Recall applies to described images: text-only base models or mode `always`. Images passed through natively to a vision-capable base model stay in context as pixels and are not duplicated into recall.)
10
+ - **Media memory that outlives the turn** — every image the agent can re-query gets a stable `image="<id>"`: described images (text-only base models, mode `always`, or explicit `analyze_image` calls) carry a description snippet, and pasted attachments on a vision-capable base model are retained in the recall store without a description. The agent can re-query or crop an earlier screenshot without a re-attachment ("zoom into that screenshot from before"), `#` autocompletes image ids in the prompt, and descriptions survive compaction via a recall digest. `analyze_image` answers targeted questions with locally applied crops; `generate_image` creates images that flow straight back through the same pipeline.
11
11
  - **Governed by default** — first-use data-egress consent per provider, persisted pre-consent lists, crops applied locally before upload, upload downscaling, decode-bomb protection, and recall bytes kept in memory only. See [Privacy & security](#privacy--security).
12
12
 
13
13
  Media routing is automatic: content sniffing and path detection feed a pipeline with transient-error retry and model fallback on the image path (the native xAI video path is one-shot), plus compact transcript rows for `analyze_image`/`generate_image` (Pi ≥ 1.0.1).
@@ -206,7 +206,10 @@ Every `<vision_proxy_description>`, `<vision_proxy_analysis>`, and
206
206
  can pass that id back to `analyze_image` (or `/multimodal-proxy describe`) to
207
207
  re-examine or crop an image the user shared earlier in the session — even once
208
208
  it is no longer attached to the current message (e.g. *"zoom into that
209
- screenshot from before"*). The image bytes are retained **in memory only** for
209
+ screenshot from before"*). Pasted attachments on a vision-capable base model
210
+ are also retained (no description; labeled by filename, or by hash prefix when
211
+ the paste carries no filename), so `#` recall works in every mode except `off`.
212
+ The image bytes are retained **in memory only** for
210
213
  the life of the session, never written to the session log or disk, and are
211
214
  evicted oldest-first once the recall budget (`PI_VISION_PROXY_IMAGE_RECALL_BYTES`,
212
215
  default 64 MB) is exceeded.
@@ -64,6 +64,40 @@ describe("collectRecallCandidates", () => {
64
64
  { hash: "h1", description: "first image", filename: undefined },
65
65
  ]);
66
66
  });
67
+
68
+ it("merges store-only hashes (pass-through attachments) after described ones", () => {
69
+ const descriptions = new Map([["h1", "described earlier"]]);
70
+ const meta = new Map([["h3", { width: 8, height: 6, filename: "paste.png" }]]);
71
+ const out = collectRecallCandidates(descriptions, (h) => meta.get(h), ["h2", "h3"]);
72
+ assert.deepEqual(out, [
73
+ { hash: "h1", description: "described earlier", filename: undefined },
74
+ { hash: "h3", description: undefined, filename: "paste.png" }, // newest store entry first
75
+ { hash: "h2", description: undefined, filename: undefined },
76
+ ]);
77
+ });
78
+
79
+ it("descriptions win when a hash appears in both sources", () => {
80
+ const out = collectRecallCandidates(
81
+ new Map([["shared", "the description"]]),
82
+ () => undefined,
83
+ ["shared", "other"],
84
+ );
85
+ assert.equal(out.length, 2);
86
+ assert.equal(out[0]!.description, "the description");
87
+ assert.equal(out[1]!.hash, "other");
88
+ });
89
+
90
+ it("feeds store-only candidates into buildRecallItems labels", () => {
91
+ const hash = "c".repeat(32);
92
+ const candidates = collectRecallCandidates(new Map(), () => ({ width: 2, height: 2, filename: "shot.png" }), [hash]);
93
+ const items = buildRecallItems(candidates, "");
94
+ assert.equal(items.length, 1);
95
+ assert.equal(items[0]!.label, "shot.png");
96
+ assert.ok(items[0]!.value.endsWith(hash));
97
+ // no filename → hash-prefix label
98
+ const bare = buildRecallItems(collectRecallCandidates(new Map(), () => undefined, [hash]), "");
99
+ assert.equal(bare[0]!.label, `${hash.slice(0, 12)}…`);
100
+ });
67
101
  });
68
102
 
69
103
  describe("buildRecallItems", () => {
@@ -318,16 +318,28 @@ export function extractRecallToken(
318
318
  }
319
319
 
320
320
  /**
321
- * Collect recall candidates from persisted descriptions, most recent first.
321
+ * Collect recall candidates for the `#` autocomplete, most recent first.
322
322
  * `metaLookup` supplies in-memory filename/dimensions when still available.
323
+ * `recallableHashes` (keys of the in-session image-data store) adds images
324
+ * that are re-queryable by id but were never described: pass-through
325
+ * attachments on vision-capable base models and analyze_image results.
326
+ * Described images win on collision (they carry description text).
323
327
  */
324
328
  export function collectRecallCandidates(
325
329
  descriptions: ReadonlyMap<string, string>,
326
330
  metaLookup: (hash: string) => ImageMeta | undefined,
331
+ recallableHashes?: Iterable<string>,
327
332
  ): RecallCandidate[] {
328
- return [...descriptions]
329
- .reverse()
330
- .map(([hash, description]) => ({ hash, description, filename: metaLookup(hash)?.filename }));
333
+ const out = new Map<string, RecallCandidate>();
334
+ for (const [hash, description] of [...descriptions].reverse()) {
335
+ out.set(hash, { hash, description, filename: metaLookup(hash)?.filename });
336
+ }
337
+ if (recallableHashes) {
338
+ for (const hash of [...recallableHashes].reverse()) {
339
+ if (!out.has(hash)) out.set(hash, { hash, description: undefined, filename: metaLookup(hash)?.filename });
340
+ }
341
+ }
342
+ return [...out.values()];
331
343
  }
332
344
 
333
345
  /** One-line dropdown snippet (no fence markers, single line, hard cap). */
@@ -2366,9 +2366,14 @@ export default function (pi: ExtensionAPI) {
2366
2366
  const entries = ctx.sessionManager.getEntries();
2367
2367
  const config = resolveConfig(entries, process.env, _fileConfig);
2368
2368
  if (config.mode !== "off") {
2369
+ // Candidates = described images ∪ everything retained in the
2370
+ // in-memory recall store (pass-through attachments, analyze_image
2371
+ // results) — anything analyze_image can resolve by id.
2372
+ const sessionState = getSessionState(ctx);
2369
2373
  const candidates = collectRecallCandidates(
2370
2374
  findDescriptions(entries),
2371
- (hash) => getSessionState(ctx).imageMeta.get(hash),
2375
+ (hash) => sessionState.imageMeta.get(hash),
2376
+ sessionState.imageData.map.keys(),
2372
2377
  );
2373
2378
  const items = buildRecallItems(candidates, token.query);
2374
2379
  if (items.length > 0) return { items, prefix: token.prefix };
@@ -3242,8 +3247,28 @@ export default function (pi: ExtensionAPI) {
3242
3247
  }
3243
3248
 
3244
3249
  if (!shouldStripImages(config, ctx.model)) {
3245
- // off, or fallback + model supports images → pass through unchanged
3246
- // But still inject video descriptions if we have them
3250
+ // fallback + model supports images → pass through unchanged (mode
3251
+ // "off" retains nothing: the extension is disabled by intent).
3252
+ // Retain the attachments in the in-memory recall store (no vision
3253
+ // call, no description, bytes were already in process as message
3254
+ // attachments; the store is memory-only and LRU-bounded) so `#`
3255
+ // autocomplete and analyze_image can re-query them by id later in
3256
+ // the session. Path-detected images were already stored on read.
3257
+ // storeImageData runs unconditionally so a re-attached image
3258
+ // refreshes its LRU recency (attach A, B, A, C must evict B, not A).
3259
+ if (config.mode !== "off") {
3260
+ for (const raw of images) {
3261
+ try {
3262
+ const img = toPiAiImage(raw);
3263
+ const hash = hashImageData(img.data);
3264
+ if (!imageMeta.has(hash)) storeImageMeta(imageMeta, hash, img.data);
3265
+ storeImageData(imageData, hash, img.data, img.mimeType);
3266
+ } catch {
3267
+ // Undecodable attachment — pass it through untouched.
3268
+ }
3269
+ }
3270
+ }
3271
+ // Still inject video descriptions if we have them
3247
3272
  if (videoDescriptionFence) {
3248
3273
  return {
3249
3274
  systemPrompt:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.22.1",
3
+ "version": "1.23.0",
4
4
  "description": "Media understanding for any model in Pi: video/audio/YouTube analysis, vision for text-only models, cost-routed vision, and session image recall.",
5
5
  "keywords": [
6
6
  "pi-package"