pi-vision-handoff 0.6.1 → 0.7.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/vision-handoff.ts +76 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.6.1",
3
+ "version": "0.7.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/vision-handoff.ts CHANGED
@@ -56,6 +56,18 @@ import { resizeImage } from "@earendil-works/pi-coding-agent";
56
56
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
57
57
  import { PrewarmEditor } from "./src/prewarm-editor.js";
58
58
 
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
+
59
71
  let config: VisionHandoffConfig = readConfig();
60
72
 
61
73
  /** Most recent describer failure message (auth error, network error, abort,
@@ -349,15 +361,23 @@ export default function (pi: ExtensionAPI) {
349
361
  // `tool_result` as its I/O completes (poll phase), and the loader's
350
362
  // `setImmediate` dispatch defers to the check phase AFTER the whole poll
351
363
  // iteration, so reads completing together land in ONE batch and all resolve
352
- // together. The descriptions replace the image blocks in the returned
353
- // `content`, so by the time the agent's next turn starts the tool results
354
- // already carry text — the agent never sees raw image blocks it can't
355
- // process.
364
+ // together.
356
365
  //
357
- // Why block here and not in `context`: the tool-result phase is free time
358
- // (the agent is just waiting for tool results), so running the describer
359
- // here adds zero latency to the critical path. `context` then becomes a
360
- // cache-hit no-op for read images.
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.
361
381
  pi.on("tool_result", async (event, ctx) => {
362
382
  if (!isConfigured(config)) return;
363
383
  if (event.toolName !== "read") return;
@@ -385,19 +405,9 @@ export default function (pi: ExtensionAPI) {
385
405
  // loader's `setImmediate` dispatch defers to the check phase, AFTER the
386
406
  // whole poll iteration, so reads completing together (the common case for
387
407
  // cached local files) land in ONE batch — ONE vision call for the whole
388
- // read set, not N. Reads completing in separate iterations get separate
389
- // calls, but always in parallel, never sequential. Awaiting here runs the
390
- // describer during the tool-result phase (free time the agent is just
391
- // waiting for tool results), so the batch is COMPLETE before `context`
392
- // fires, making `context` a non-blocking cache hit instead of a cold miss
393
- // on the critical path.
394
- //
395
- // We do NOT mutate the result content here for the image blocks: returning
396
- // undefined keeps the image block in storage so kitty renders it inline
397
- // and `/resume` retains it. The actual image→text swap happens in the
398
- // `context` hook (on the cloned LLM-bound payload only), by which point
399
- // these are cache hits. We DO strip pi's misleading non-vision note (below)
400
- // since the handoff will replace the image with a description.
408
+ // read set, not N. Awaiting here runs the describer during the tool-result
409
+ // phase (free time), so the batch is COMPLETE before `context` fires,
410
+ // making `context` a non-blocking cache hit instead of a cold miss.
401
411
  const descs = await Promise.all(imgs.map((img) => loader.loadDescription(img)));
402
412
 
403
413
  // On user abort, leave the result untouched — pi is tearing the turn
@@ -406,12 +416,53 @@ export default function (pi: ExtensionAPI) {
406
416
 
407
417
  warnFailedImages(ctx, imgs, descs, lastDescriberError ?? "unknown error");
408
418
 
409
- // Strip pi's `[Current model does not support images…]` note from text
410
- // blocks since the handoff replaces the image with a description in the
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
+ }
455
+ }
456
+ return { content: next };
457
+ }
458
+
459
+ // Normal flow: warm the cache (done above) and strip pi's
460
+ // `[Current model does not support images…]` note from text blocks —
461
+ // since the handoff replaces the image with a description in the
411
462
  // `context` hook, that note is misleading (the agent WILL receive the
412
463
  // image's content, as text). Keep the image block itself so kitty still
413
- // renders it inline and `/resume` retains it; the `context` hook swaps the
414
- // image for its description in the LLM-bound clone before the next turn.
464
+ // renders it inline and `/resume` retains it; the `context` hook swaps
465
+ // the image for its description in the LLM-bound clone before the next turn.
415
466
  let stripped = false;
416
467
  const next = content.slice();
417
468
  for (let i = 0; i < next.length; i++) {