pi-vision-handoff 0.7.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
package/src/dataloader.ts CHANGED
@@ -66,6 +66,9 @@ export interface LoaderDeps extends DescriberDeps {
66
66
  getConfig(): VisionHandoffConfig;
67
67
  /** Resolve the configured vision model against a registry. */
68
68
  resolveVisionModel: VisionModelResolver;
69
+ /** Backoff (ms) before retrying a totally-failed describer batch. Defaults
70
+ * to {@link DESCRIBE_RETRY_BACKOFF_MS} when unset; 0 skips the wait (tests). */
71
+ retryBackoffMs?: number;
69
72
  }
70
73
 
71
74
  /** Defer `fn` to the next check phase (`setImmediate`), so every
@@ -85,6 +88,31 @@ function enqueuePostPromiseJob(fn: () => void): void {
85
88
  setImmediate(fn);
86
89
  }
87
90
 
91
+ /** Default backoff (ms) before retrying a totally-failed describer batch. A
92
+ * short backoff covers a transient provider blip (network hiccup, momentary
93
+ * 429) without adding meaningful latency to the tool-result phase (free
94
+ * time). Generous enough to let a rate-limited provider recover, short enough
95
+ * that a genuinely broken vision model doesn't stall a turn. */
96
+ const DESCRIBE_RETRY_BACKOFF_MS = 500;
97
+
98
+ /** Resolve after `ms`, or immediately if `signal` is already aborted or aborts
99
+ * during the wait. Used by the retry backoff so a user cancel (ESC) doesn't
100
+ * wait the full backoff before the retry is skipped. */
101
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
102
+ return new Promise((resolve) => {
103
+ if (ms <= 0 || signal.aborted) return resolve();
104
+ const onAbort = () => {
105
+ clearTimeout(timer);
106
+ resolve();
107
+ };
108
+ const timer = setTimeout(() => {
109
+ signal.removeEventListener("abort", onAbort);
110
+ resolve();
111
+ }, ms);
112
+ signal.addEventListener("abort", onAbort, { once: true });
113
+ });
114
+ }
115
+
88
116
  export class DescriptionLoader implements Disposable {
89
117
  private readonly cache = new Map<string, Promise<string>>();
90
118
  private batch: DescriptionBatch | null = null;
@@ -253,7 +281,7 @@ export class DescriptionLoader implements Disposable {
253
281
  this.deps.setLastError(null); // clear before a fresh attempt
254
282
  const misses = batch.keys.map((k, i) => ({ hash: k, img: batch.imgs[i] }));
255
283
  const cfg = this.deps.getConfig();
256
- const parsed = await runBatch(
284
+ let parsed = await runBatch(
257
285
  misses,
258
286
  this.pendingTurnPrompt,
259
287
  this.turnVisionModel,
@@ -262,6 +290,30 @@ export class DescriptionLoader implements Disposable {
262
290
  this.deps,
263
291
  this.turnAbortController.signal,
264
292
  );
293
+ // Retry once on a TOTAL batch failure (the batched call itself failed:
294
+ // auth, network, timeout, empty, or stopReason "error"). A transient blip
295
+ // would otherwise cost the agent a full turn — UNAVAILABLE this turn, not
296
+ // cached, re-attempted next turn. Retrying the whole batch recovers it
297
+ // within the same tool-result phase (free time). Skip when the turn was
298
+ // cancelled (ESC); an auth failure re-fails cheaply (runBatch re-checks the
299
+ // API key before the vision call), so no vision call is wasted on a
300
+ // permanent auth error. Partial failures (some images described) are left
301
+ // as-is — only a totally-empty result retries.
302
+ if (parsed.size === 0 && misses.length > 0 && !this.turnAbortController.signal.aborted) {
303
+ await sleep(this.deps.retryBackoffMs ?? DESCRIBE_RETRY_BACKOFF_MS, this.turnAbortController.signal);
304
+ if (!this.turnAbortController.signal.aborted) {
305
+ this.deps.setLastError(null);
306
+ parsed = await runBatch(
307
+ misses,
308
+ this.pendingTurnPrompt,
309
+ this.turnVisionModel,
310
+ this.turnModelRegistry,
311
+ cfg,
312
+ this.deps,
313
+ this.turnAbortController.signal,
314
+ );
315
+ }
316
+ }
265
317
  // Build per-hash results, then fan them out to every callback. This reaches
266
318
  // duplicate-hash callbacks that a key-indexed loop would have skipped (and
267
319
  // left hanging).
package/src/image.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * agrees with pi on whether a file is an image at all.
19
19
  */
20
20
 
21
- import { readFileSync } from "node:fs";
21
+ import { readFileSync, statSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { isAbsolute, join, sep } from "node:path";
24
24
  import crypto from "node:crypto";
@@ -248,6 +248,40 @@ export function readImageBuffer(filePath: string): { buf: Buffer; mimeType: stri
248
248
  }
249
249
  }
250
250
 
251
+ /** Max raw file size the omitted-image recovery will re-read. pi's `read`
252
+ * already read the file; this bounds the handoff's re-read so a pathologically
253
+ * huge file doesn't double the memory. 20MB covers the most generous vision
254
+ * model inline-image limit; a larger file would be rejected by the vision
255
+ * model anyway. */
256
+ export const MAX_RECOVER_IMAGE_BYTES = 20 * 1024 * 1024;
257
+
258
+ /** pi core's `read` tool emits this text note (with NO image block) when it
259
+ * detected an image but `processImage` failed — Photon/WASM unavailable,
260
+ * decode failure, convert-to-PNG failure, or couldn't resize below the inline
261
+ * size limit. The handoff's image-block path never sees these (no image
262
+ * block), so the image goes undescribed and the model is told the image was
263
+ * "omitted". This detects that note so the tool_result handler can re-read the
264
+ * raw file and describe its bytes directly (the vision model decodes them —
265
+ * no Photon needed). */
266
+ export function isOmittedImageNote(text: string): boolean {
267
+ return text.includes("Read image file [") && text.includes("[Image omitted:");
268
+ }
269
+
270
+ /** Read an image file for the omitted-image recovery, bounded by
271
+ * {@link MAX_RECOVER_IMAGE_BYTES}. Returns null if the file can't be read, is
272
+ * too large, or isn't a supported image (aligned with pi's sniff — APNG and
273
+ * unsupported formats are rejected, since the vision model can't decode them
274
+ * either). */
275
+ export function readImageBufferBounded(filePath: string): { buf: Buffer; mimeType: string } | null {
276
+ try {
277
+ const stat = statSync(filePath);
278
+ if (stat.size > MAX_RECOVER_IMAGE_BYTES) return null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ return readImageBuffer(filePath);
283
+ }
284
+
251
285
  /** Regex matching pi's pasted-clipboard temp image file paths anywhere in the
252
286
  * prompt text. pi writes pasted clipboard images to
253
287
  * `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text at the
package/vision-handoff.ts CHANGED
@@ -30,6 +30,7 @@
30
30
 
31
31
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
32
32
  import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
33
+ import { isAbsolute, resolve } from "node:path";
33
34
  import {
34
35
  extractImageFromBlock,
35
36
  formatModelRef,
@@ -50,7 +51,7 @@ import {
50
51
  type VisionHandoffUsageRecord,
51
52
  } from "./src/usage.js";
52
53
  import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
53
- import { imageHash, findClipboardImagePaths, readImageBuffer, resolvePrewarmImage } from "./src/image.js";
54
+ import { imageHash, findClipboardImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
54
55
  import { appendVisionError } from "./src/error-log.js";
55
56
  import { resizeImage } from "@earendil-works/pi-coding-agent";
56
57
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
@@ -368,13 +369,51 @@ export default function (pi: ExtensionAPI) {
368
369
 
369
370
  // Collect image blocks in this read result. (The read tool emits image
370
371
  // blocks even for non-vision models — they reach here untouched.)
372
+ if (!isHandoffTarget(ctx.model, config)) return;
371
373
  const imgs: ExtractedImage[] = [];
374
+ let hasImageBlock = false;
372
375
  for (const block of content) {
373
376
  const img = extractImageFromBlock(block);
374
- if (img) imgs.push(img);
377
+ if (img) {
378
+ imgs.push(img);
379
+ hasImageBlock = true;
380
+ }
375
381
  }
382
+
383
+ // Fallback: `read` detected an image but `processImage` failed (Photon
384
+ // unavailable / decode fail / convert fail / couldn't resize below the
385
+ // inline limit), so it emitted a "[Image omitted: …]" text note with NO
386
+ // image block. The image-block path above never sees it, so the image
387
+ // would go undescribed and the model would be told the image was "omitted".
388
+ // Re-read the raw file and describe its bytes directly — the vision model
389
+ // decodes them itself (no Photon needed). This recovers the Photon-
390
+ // unavailable and under-vision-model-limit cases; APNG/unsupported are
391
+ // rejected by the sniff (the vision model can't decode them either).
392
+ let omittedNoteIndices: number[] = [];
393
+ if (!hasImageBlock) {
394
+ for (let i = 0; i < content.length; i++) {
395
+ const block = content[i];
396
+ if (
397
+ block &&
398
+ typeof block === "object" &&
399
+ (block as { type: string }).type === "text" &&
400
+ typeof (block as { text: string }).text === "string" &&
401
+ isOmittedImageNote((block as { text: string }).text)
402
+ ) {
403
+ omittedNoteIndices.push(i);
404
+ }
405
+ }
406
+ if (omittedNoteIndices.length > 0) {
407
+ const inputPath = event.input.path;
408
+ if (typeof inputPath === "string") {
409
+ const resolved = isAbsolute(inputPath) ? inputPath : resolve(ctx.cwd, inputPath);
410
+ const read = readImageBufferBounded(resolved);
411
+ if (read) imgs.push({ data: read.buf.toString("base64"), mimeType: read.mimeType });
412
+ }
413
+ }
414
+ }
415
+
376
416
  if (imgs.length === 0) return;
377
- if (!isHandoffTarget(ctx.model, config)) return;
378
417
  loader.bindTurnContext(ctx);
379
418
  if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
380
419
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
@@ -398,6 +437,18 @@ export default function (pi: ExtensionAPI) {
398
437
 
399
438
  warnFailedImages(ctx, imgs, descs, lastDescriberError ?? "unknown error");
400
439
 
440
+ // Recovery path: no image block was emitted, so there's nothing for the
441
+ // `context` hook to swap. Replace each "[Image omitted: …]" note directly
442
+ // with its description (the recovered raw-bytes image is the only img here,
443
+ // so descs aligns 1:1 with omittedNoteIndices).
444
+ if (!hasImageBlock) {
445
+ const recovered = content.slice();
446
+ for (let i = 0; i < omittedNoteIndices.length && i < descs.length; i++) {
447
+ recovered[omittedNoteIndices[i]] = { type: "text", text: descs[i] };
448
+ }
449
+ return { content: recovered as (TextContent | ImageContent)[] };
450
+ }
451
+
401
452
  // Warm the cache (done above) and strip pi's
402
453
  // `[Current model does not support images…]` note from text blocks —
403
454
  // since the handoff replaces the image with a description in the
@@ -765,6 +816,29 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
765
816
  .getAll()
766
817
  .map((m) => ({ provider: m.provider, id: m.id, name: m.name, input: m.input, reasoning: m.reasoning }));
767
818
 
819
+ if (ctx.mode !== "tui") {
820
+ const modelItems = ["None", ...allModels.map((m) => `${m.provider}/${m.id}`)];
821
+ const modelPick = await ctx.ui.select("Vision model", modelItems);
822
+ if (modelPick === undefined) return;
823
+ const ref = modelPick === "None" ? null : modelPick;
824
+ const thinkingPick = await ctx.ui.select("Thinking", ["on", "off"]);
825
+ if (thinkingPick === undefined) return;
826
+ const thinking = thinkingPick === "on";
827
+ const thinkingLevel = config.thinkingLevel;
828
+ const thinkingNote = thinking
829
+ ? `thinking on (${thinkingLevel})${ref ? " \u2014 applies only if the vision model supports reasoning" : ""}`
830
+ : "thinking off";
831
+ updateConfig(
832
+ ctx,
833
+ (c) => ({ ...c, visionModel: ref, thinking, thinkingLevel }),
834
+ ref ? `Vision model set to ${ref} \u00b7 ${thinkingNote}` : `Vision model cleared \u00b7 ${thinkingNote}`,
835
+ );
836
+ if (!ref) {
837
+ ctx.ui.notify("Handoff is inactive until you pick a vision model.", "warning");
838
+ }
839
+ return;
840
+ }
841
+
768
842
  const result = await ctx.ui.custom<VisionModelSelectorResult>((tui, theme, _kb, done) => {
769
843
  const selector = new VisionModelSelectorComponent(
770
844
  theme,