pi-vision-handoff 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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,24 +51,12 @@ 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";
57
58
  import { PrewarmEditor } from "./src/prewarm-editor.js";
58
59
 
59
- /**
60
- * pi-fabric prepends this prefix to every nested tool-call id it generates
61
- * inside a fabric_exec run (one per pi.* invocation in full-code mode). The
62
- * LLM's own tool-call ids (e.g. openai "call_…", anthropic "toolu_…") never use
63
- * it, so a tool_result whose toolCallId starts with this prefix is a nested
64
- * fabric call whose result returns to the sandbox program — not the agent's
65
- * message context — so the context hook will never see its image blocks. We
66
- * therefore swap image→description inline in the tool_result handler for
67
- * nested calls. Mirror of pi-fabric's NESTED_TOOL_CALL_ID_PREFIX.
68
- */
69
- const FABRIC_NESTED_TOOL_CALL_ID_PREFIX = "fabric_";
70
-
71
60
  let config: VisionHandoffConfig = readConfig();
72
61
 
73
62
  /** Most recent describer failure message (auth error, network error, abort,
@@ -363,21 +352,15 @@ export default function (pi: ExtensionAPI) {
363
352
  // iteration, so reads completing together land in ONE batch and all resolve
364
353
  // together.
365
354
  //
366
- // Two shapes of caller reach this handler:
367
- // • Normal flow — the agent called `read` directly, so this tool result IS
368
- // the agent's tool result. The `context` hook will swap image→text on the
369
- // LLM-bound clone, so here we only WARM the cache (during the free
370
- // tool-result phase) and strip pi's misleading non-vision note, KEEPING
371
- // the image block so kitty renders it inline and /resume retains it.
372
- // • pi-fabric full-code mode — `pi.read` runs NESTED inside fabric_exec, so
373
- // the result returns to the sandbox program, not the agent's message
374
- // context. The `context` hook never sees these image blocks, and the
375
- // nested result is transient (never rendered by kitty or stored in
376
- // /resume). So we SWAP image→description inline here, mirroring the
377
- // `context` handler, so the description becomes pi.read's return value:
378
- // pi-fabric's normalizeResult extracts the text and the text-only agent
379
- // receives the description instead of a dropped image block. Nested
380
- // fabric calls are tagged with a `fabric_`-prefixed toolCallId.
355
+ // Both a direct agent `read` and a NESTED `pi.read` inside fabric_exec reach
356
+ // this handler and take the SAME path: WARM the cache (during the free
357
+ // tool-result phase), strip pi's misleading non-vision note, and KEEP the
358
+ // image block so kitty renders it inline and /resume retains it. The
359
+ // `context` hook swaps image→description on the LLM-bound clone before the
360
+ // next turn, so the text-only agent receives the description as text. This
361
+ // holds for fabric too: pi-fabric re-attaches the nested read's image to the
362
+ // fabric_exec tool-result content, which IS the agent's message context — so
363
+ // the `context` hook sees and swaps it, exactly like a direct read.
381
364
  pi.on("tool_result", async (event, ctx) => {
382
365
  if (!isConfigured(config)) return;
383
366
  if (event.toolName !== "read") return;
@@ -386,13 +369,51 @@ export default function (pi: ExtensionAPI) {
386
369
 
387
370
  // Collect image blocks in this read result. (The read tool emits image
388
371
  // blocks even for non-vision models — they reach here untouched.)
372
+ if (!isHandoffTarget(ctx.model, config)) return;
389
373
  const imgs: ExtractedImage[] = [];
374
+ let hasImageBlock = false;
390
375
  for (const block of content) {
391
376
  const img = extractImageFromBlock(block);
392
- if (img) imgs.push(img);
377
+ if (img) {
378
+ imgs.push(img);
379
+ hasImageBlock = true;
380
+ }
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
+ }
393
414
  }
415
+
394
416
  if (imgs.length === 0) return;
395
- if (!isHandoffTarget(ctx.model, config)) return;
396
417
  loader.bindTurnContext(ctx);
397
418
  if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
398
419
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
@@ -416,47 +437,19 @@ export default function (pi: ExtensionAPI) {
416
437
 
417
438
  warnFailedImages(ctx, imgs, descs, lastDescriberError ?? "unknown error");
418
439
 
419
- // pi-fabric full-code mode: swap image→description inline (see the header
420
- // comment). The descriptions are already wrapped ([Image: …]) by the
421
- // dataloader, so push them directly; strip pi's non-vision note from any
422
- // surviving text blocks since the description replaces the omitted image.
423
- if (
424
- typeof event.toolCallId === "string" &&
425
- event.toolCallId.startsWith(FABRIC_NESTED_TOOL_CALL_ID_PREFIX)
426
- ) {
427
- const descByHash = new Map<string, string>();
428
- for (let i = 0; i < imgs.length; i++) {
429
- descByHash.set(imageHash(imgs[i].mimeType, imgs[i].data), descs[i]);
430
- }
431
- const next: (TextContent | ImageContent)[] = [];
432
- for (const block of content) {
433
- const img = extractImageFromBlock(block);
434
- if (img) {
435
- next.push({
436
- type: "text",
437
- text: descByHash.get(imageHash(img.mimeType, img.data)) ?? UNAVAILABLE,
438
- });
439
- continue;
440
- }
441
- if (
442
- block &&
443
- typeof block === "object" &&
444
- (block as { type: string }).type === "text" &&
445
- typeof (block as { text: string }).text === "string" &&
446
- (block as { text: string }).text.includes(NON_VISION_IMAGE_NOTE)
447
- ) {
448
- next.push({
449
- type: "text",
450
- text: stripNonVisionImageNote((block as { text: string }).text),
451
- });
452
- } else {
453
- next.push(block as TextContent | ImageContent);
454
- }
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] };
455
448
  }
456
- return { content: next };
449
+ return { content: recovered as (TextContent | ImageContent)[] };
457
450
  }
458
451
 
459
- // Normal flow: warm the cache (done above) and strip pi's
452
+ // Warm the cache (done above) and strip pi's
460
453
  // `[Current model does not support images…]` note from text blocks —
461
454
  // since the handoff replaces the image with a description in the
462
455
  // `context` hook, that note is misleading (the agent WILL receive the