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 +1 -1
- package/src/dataloader.ts +53 -1
- package/src/image.ts +35 -1
- package/vision-handoff.ts +77 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
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)
|
|
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,
|