pi-vision-handoff 0.6.0 → 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.
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/vision-handoff.ts +79 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@earendil-works/pi-ai": "0.80.
|
|
35
|
-
"@earendil-works/pi-coding-agent": "0.80.
|
|
36
|
-
"@earendil-works/pi-tui": "0.80.
|
|
34
|
+
"@earendil-works/pi-ai": "0.80.6",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "0.80.6",
|
|
36
|
+
"@earendil-works/pi-tui": "0.80.6",
|
|
37
37
|
"@types/node": "25.9.1",
|
|
38
38
|
"@vitest/coverage-v8": "4.1.7",
|
|
39
39
|
"knip": "6.14.1",
|
package/src/index.ts
CHANGED
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.
|
|
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
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// cache
|
|
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.
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
-
//
|
|
410
|
-
//
|
|
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
|
|
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++) {
|
|
@@ -553,7 +604,7 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
|
|
|
553
604
|
" /vision-handoff enable Enable vision handoff",
|
|
554
605
|
" /vision-handoff disable Disable vision handoff (keeps configured model)",
|
|
555
606
|
" /vision-handoff auto <on|off> Toggle automatic handoff for all non-vision models",
|
|
556
|
-
" /vision-handoff thinking <off|minimal|low|medium|high|xhigh>",
|
|
607
|
+
" /vision-handoff thinking <off|minimal|low|medium|high|xhigh|max>",
|
|
557
608
|
" Set the vision describer's thinking effort (off = disabled)",
|
|
558
609
|
" /vision-handoff prewarm <on|off>",
|
|
559
610
|
" Toggle describing pasted images at paste-time (opt-in, off by default)",
|
|
@@ -714,7 +765,7 @@ function handleThinkingSubcommand(ctx: ExtensionCommandContext, rest: string): v
|
|
|
714
765
|
if (!arg) {
|
|
715
766
|
ctx.ui.notify(
|
|
716
767
|
`Thinking: ${config.thinking ? `on (${config.thinkingLevel})` : "off"}.\n` +
|
|
717
|
-
`Usage: /vision-handoff thinking <off|minimal|low|medium|high|xhigh>`,
|
|
768
|
+
`Usage: /vision-handoff thinking <off|minimal|low|medium|high|xhigh|max>`,
|
|
718
769
|
"info",
|
|
719
770
|
);
|
|
720
771
|
return;
|
|
@@ -725,7 +776,7 @@ function handleThinkingSubcommand(ctx: ExtensionCommandContext, rest: string): v
|
|
|
725
776
|
}
|
|
726
777
|
if (!isThinkingLevel(arg)) {
|
|
727
778
|
ctx.ui.notify(
|
|
728
|
-
`Unknown thinking level: "${arg}". Use off, minimal, low, medium, high, or
|
|
779
|
+
`Unknown thinking level: "${arg}". Use off, minimal, low, medium, high, xhigh, or max.`,
|
|
729
780
|
"error",
|
|
730
781
|
);
|
|
731
782
|
return;
|